From 1a5e5808070b40a6a7b61499ff9fdc31c87ddb1b Mon Sep 17 00:00:00 2001 From: Herklos Date: Tue, 28 Oct 2025 12:15:02 +0100 Subject: [PATCH 1/4] Add Coindesk, Lunarcrush and Alternative.me services --- Evaluator/Social/news_evaluator/__init__.py | 2 +- .../config/CryptoNewsEvaluator.json | 3 + Evaluator/Social/news_evaluator/metadata.json | 4 +- Evaluator/Social/news_evaluator/news.py | 71 +++++- .../resources/CryptoNewsEvaluator.md | 7 + .../Social/sentiment_evaluator/__init__.py | 1 + .../config/FearAndGreedIndexEvaluator.json | 3 + .../config/SocialScoreEvaluator.json | 2 + .../Social/sentiment_evaluator/metadata.json | 6 + .../resources/FearAndGreedIndexEvaluator.md | 7 + .../resources/SocialScoreEvaluator.md | 7 + .../Social/sentiment_evaluator/sentiment.py | 113 +++++++++ Evaluator/Social/trends_evaluator/__init__.py | 2 +- .../config/MarketCapEvaluator.json | 4 + .../Social/trends_evaluator/metadata.json | 4 +- .../resources/MarketCapEvaluator.md | 7 + Evaluator/Social/trends_evaluator/trends.py | 50 +++- .../blank_strategy.py | 2 + .../dip_analyser_strategy.py | 2 + .../mixed_strategies.py | 8 +- .../move_signals_strategy.py | 2 + .../alternative_me_service/__init__.py | 1 + .../alternative_me_service/alternative_me.py | 42 ++++ .../alternative_me_service/metadata.json | 6 + .../coindesk_service/__init__.py | 1 + .../coindesk_service/coindesk.py | 42 ++++ .../coindesk_service/metadata.json | 6 + .../lunarcrush_service/__init__.py | 1 + .../lunarcrush_service/lunarcrush.py | 65 +++++ .../lunarcrush_service/metadata.json | 6 + .../alternative_me_service_feed/__init__.py | 2 + .../alternative_me_feed.py | 136 +++++++++++ .../alternative_me_service_feed/metadata.json | 6 + .../coindesk_service_feed/__init__.py | 3 + .../coindesk_service_feed/coindesk_feed.py | 227 ++++++++++++++++++ .../coindesk_service_feed/metadata.json | 6 + .../google_service_feed/google_feed.py | 12 +- .../lunarcrush_service_feed/__init__.py | 1 + .../lunarcrush_feed.py | 151 ++++++++++++ .../lunarcrush_service_feed/metadata.json | 6 + Trading/Mode/index_trading_mode/__init__.py | 2 +- 41 files changed, 1015 insertions(+), 14 deletions(-) create mode 100644 Evaluator/Social/news_evaluator/config/CryptoNewsEvaluator.json create mode 100644 Evaluator/Social/news_evaluator/resources/CryptoNewsEvaluator.md create mode 100644 Evaluator/Social/sentiment_evaluator/__init__.py create mode 100644 Evaluator/Social/sentiment_evaluator/config/FearAndGreedIndexEvaluator.json create mode 100644 Evaluator/Social/sentiment_evaluator/config/SocialScoreEvaluator.json create mode 100644 Evaluator/Social/sentiment_evaluator/metadata.json create mode 100644 Evaluator/Social/sentiment_evaluator/resources/FearAndGreedIndexEvaluator.md create mode 100644 Evaluator/Social/sentiment_evaluator/resources/SocialScoreEvaluator.md create mode 100644 Evaluator/Social/sentiment_evaluator/sentiment.py create mode 100644 Evaluator/Social/trends_evaluator/config/MarketCapEvaluator.json create mode 100644 Evaluator/Social/trends_evaluator/resources/MarketCapEvaluator.md create mode 100644 Services/Services_bases/alternative_me_service/__init__.py create mode 100644 Services/Services_bases/alternative_me_service/alternative_me.py create mode 100644 Services/Services_bases/alternative_me_service/metadata.json create mode 100644 Services/Services_bases/coindesk_service/__init__.py create mode 100644 Services/Services_bases/coindesk_service/coindesk.py create mode 100644 Services/Services_bases/coindesk_service/metadata.json create mode 100644 Services/Services_bases/lunarcrush_service/__init__.py create mode 100644 Services/Services_bases/lunarcrush_service/lunarcrush.py create mode 100644 Services/Services_bases/lunarcrush_service/metadata.json create mode 100644 Services/Services_feeds/alternative_me_service_feed/__init__.py create mode 100644 Services/Services_feeds/alternative_me_service_feed/alternative_me_feed.py create mode 100644 Services/Services_feeds/alternative_me_service_feed/metadata.json create mode 100644 Services/Services_feeds/coindesk_service_feed/__init__.py create mode 100644 Services/Services_feeds/coindesk_service_feed/coindesk_feed.py create mode 100644 Services/Services_feeds/coindesk_service_feed/metadata.json create mode 100644 Services/Services_feeds/lunarcrush_service_feed/__init__.py create mode 100644 Services/Services_feeds/lunarcrush_service_feed/lunarcrush_feed.py create mode 100644 Services/Services_feeds/lunarcrush_service_feed/metadata.json diff --git a/Evaluator/Social/news_evaluator/__init__.py b/Evaluator/Social/news_evaluator/__init__.py index 065d74377..b3eabe5b0 100644 --- a/Evaluator/Social/news_evaluator/__init__.py +++ b/Evaluator/Social/news_evaluator/__init__.py @@ -1 +1 @@ -from .news import TwitterNewsEvaluator \ No newline at end of file +from .news import TwitterNewsEvaluator, CryptoNewsEvaluator \ No newline at end of file diff --git a/Evaluator/Social/news_evaluator/config/CryptoNewsEvaluator.json b/Evaluator/Social/news_evaluator/config/CryptoNewsEvaluator.json new file mode 100644 index 000000000..db3a4f277 --- /dev/null +++ b/Evaluator/Social/news_evaluator/config/CryptoNewsEvaluator.json @@ -0,0 +1,3 @@ +{ + "language": "en" +} \ No newline at end of file diff --git a/Evaluator/Social/news_evaluator/metadata.json b/Evaluator/Social/news_evaluator/metadata.json index 685eaba67..5bc986899 100644 --- a/Evaluator/Social/news_evaluator/metadata.json +++ b/Evaluator/Social/news_evaluator/metadata.json @@ -1,6 +1,6 @@ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", - "tentacles": ["TwitterNewsEvaluator"], - "tentacles-requirements": ["text_analysis", "twitter_service_feed"] + "tentacles": ["TwitterNewsEvaluator", "CryptoNewsEvaluator"], + "tentacles-requirements": ["text_analysis", "twitter_service_feed", "coindesk_service_feed"] } \ No newline at end of file diff --git a/Evaluator/Social/news_evaluator/news.py b/Evaluator/Social/news_evaluator/news.py index 7a22ad384..5f137a814 100644 --- a/Evaluator/Social/news_evaluator/news.py +++ b/Evaluator/Social/news_evaluator/news.py @@ -16,12 +16,11 @@ import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums - import octobot_commons.tentacles_management as tentacles_management import octobot_services.constants as services_constants import octobot_evaluators.evaluators as evaluators -from tentacles.Evaluator.Util.text_analysis import TextAnalysis import tentacles.Services.Services_feeds as Services_feeds +import tentacles.Evaluator.Util as EvaluatorUtil # disable inheritance to disable tentacle visibility. Disabled as starting from feb 9 2023, API is now paid only @@ -177,4 +176,70 @@ def _get_config_elements(self, config_cryptocurrencies, key): return {} async def prepare(self): - self.sentiment_analyser = tentacles_management.get_single_deepest_child_class(TextAnalysis)() + self.sentiment_analyser = tentacles_management.get_single_deepest_child_class(EvaluatorUtil.TextAnalysis)() + + +NEWS_CONFIG_LANGUAGE = "language" + +# Should use any feed available to fetch crypto news (coindesk, etc.) +class CryptoNewsEvaluator(evaluators.SocialEvaluator): + SERVICE_FEED_CLASS = Services_feeds.CoindeskServiceFeed + + def __init__(self, tentacles_setup_config): + evaluators.SocialEvaluator.__init__(self, tentacles_setup_config) + self.stats_analyser = None + self.language = None + + def init_user_inputs(self, inputs: dict) -> None: + self.language = self.UI.user_input(NEWS_CONFIG_LANGUAGE, + commons_enums.UserInputTypes.TEXT, + self.language, inputs, + title="Language to use to fetch crypto news.", + options=["en", "fr"]) + self.feed_config = { + services_constants.CONFIG_COINDESK_TOPICS: [services_constants.COINDESK_TOPIC_NEWS], + services_constants.CONFIG_COINDESK_LANGUAGE: self.language + } + + @classmethod + def get_is_cryptocurrencies_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency dependant else False + """ + return True + + @classmethod + def get_is_cryptocurrency_name_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency name dependant else False + """ + return True + + async def _feed_callback(self, data): + if self._is_interested_by_this_notification(data[services_constants.FEED_METADATA]): + latest_news = self.get_data_cache(self.get_current_exchange_time(), key=services_constants.COINDESK_TOPIC_NEWS) + if latest_news is not None and len(latest_news) > 0: + sentiment_sum = 0 + news_count = 0 + news_titles = [] + for news in latest_news: + sentiment = news.sentiment + sentiment_sum += 0 if sentiment is None else -1 if sentiment == "NEGATIVE" else 1 if sentiment == "POSITIVE" else 0 + news_count += 1 + news_titles.append(news.title) + + if news_count > 0: + self.eval_note = sentiment_sum / news_count + await self.evaluation_completed( + cryptocurrency=None, + eval_time=self.get_current_exchange_time(), + eval_note_description=f"Overall news sentiment: {'POSITIVE' if self.eval_note > 0 else 'NEGATIVE' if self.eval_note < 0 else 'NEUTRAL'}. News titles: " + "; ".join(news_titles) + ) + else: + self.debug(f"No news found") + + def _is_interested_by_this_notification(self, notification_description): + return notification_description == services_constants.COINDESK_TOPIC_NEWS + + async def prepare(self): + self.sentiment_analyser = tentacles_management.get_single_deepest_child_class(EvaluatorUtil.TextAnalysis)() diff --git a/Evaluator/Social/news_evaluator/resources/CryptoNewsEvaluator.md b/Evaluator/Social/news_evaluator/resources/CryptoNewsEvaluator.md new file mode 100644 index 000000000..4473083bf --- /dev/null +++ b/Evaluator/Social/news_evaluator/resources/CryptoNewsEvaluator.md @@ -0,0 +1,7 @@ +Analyzes overall crypto market sentiment through cryptocurrency news articles. + +This evaluator interprets aggregated news signals (e.g., article headlines, content, +and sentiment classifications) to produce a normalized score +indicating bullish or bearish market sentiment based on recent news coverage. + +Data source: ([CoinDesk Data API](https://developers.coindesk.com/documentation/data-api/news)) diff --git a/Evaluator/Social/sentiment_evaluator/__init__.py b/Evaluator/Social/sentiment_evaluator/__init__.py new file mode 100644 index 000000000..2bb7b4cd2 --- /dev/null +++ b/Evaluator/Social/sentiment_evaluator/__init__.py @@ -0,0 +1 @@ +from .sentiment import FearAndGreedIndexEvaluator, SocialScoreEvaluator diff --git a/Evaluator/Social/sentiment_evaluator/config/FearAndGreedIndexEvaluator.json b/Evaluator/Social/sentiment_evaluator/config/FearAndGreedIndexEvaluator.json new file mode 100644 index 000000000..dcb8b2121 --- /dev/null +++ b/Evaluator/Social/sentiment_evaluator/config/FearAndGreedIndexEvaluator.json @@ -0,0 +1,3 @@ +{ + "trend_averages" : [40, 30, 20, 15, 10] +} diff --git a/Evaluator/Social/sentiment_evaluator/config/SocialScoreEvaluator.json b/Evaluator/Social/sentiment_evaluator/config/SocialScoreEvaluator.json new file mode 100644 index 000000000..2c63c0851 --- /dev/null +++ b/Evaluator/Social/sentiment_evaluator/config/SocialScoreEvaluator.json @@ -0,0 +1,2 @@ +{ +} diff --git a/Evaluator/Social/sentiment_evaluator/metadata.json b/Evaluator/Social/sentiment_evaluator/metadata.json new file mode 100644 index 000000000..bf8c5a831 --- /dev/null +++ b/Evaluator/Social/sentiment_evaluator/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["FearAndGreedIndexEvaluator", "SocialScoreEvaluator"], + "tentacles-requirements": ["alternative_me_service_feed", "lunarcrush_service_feed"] +} \ No newline at end of file diff --git a/Evaluator/Social/sentiment_evaluator/resources/FearAndGreedIndexEvaluator.md b/Evaluator/Social/sentiment_evaluator/resources/FearAndGreedIndexEvaluator.md new file mode 100644 index 000000000..e15f7a803 --- /dev/null +++ b/Evaluator/Social/sentiment_evaluator/resources/FearAndGreedIndexEvaluator.md @@ -0,0 +1,7 @@ +Analyzes overall crypto market sentiment through a Fear & Greed Index. + +This evaluator interprets aggregated market signals (e.g., volatility, volume/momentum, +social media sentiment, dominance, and trends) to produce a normalized score +indicating prevailing fear or greed. + +Data source: ([alternative.me](https://alternative.me/crypto/fear-and-greed-index/)) \ No newline at end of file diff --git a/Evaluator/Social/sentiment_evaluator/resources/SocialScoreEvaluator.md b/Evaluator/Social/sentiment_evaluator/resources/SocialScoreEvaluator.md new file mode 100644 index 000000000..fa45b13a8 --- /dev/null +++ b/Evaluator/Social/sentiment_evaluator/resources/SocialScoreEvaluator.md @@ -0,0 +1,7 @@ +Analyzes cryptocurrency-specific social sentiment through LunarCrush social metrics. + +This evaluator interprets aggregated social signals (e.g., social volume, social engagement, +social dominance, and community sentiment) to produce a normalized score +indicating bullish or bearish social sentiment for a specific cryptocurrency. + +Data source: ([LunarCrush](https://lunarcrush.com/faq/what-metrics-are-available-on-lunarcrush)) diff --git a/Evaluator/Social/sentiment_evaluator/sentiment.py b/Evaluator/Social/sentiment_evaluator/sentiment.py new file mode 100644 index 000000000..9be178753 --- /dev/null +++ b/Evaluator/Social/sentiment_evaluator/sentiment.py @@ -0,0 +1,113 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import octobot_commons.enums as commons_enums +import octobot_commons.tentacles_management as tentacles_management +import octobot_evaluators.evaluators as evaluators +import octobot_services.constants as services_constants +import tentacles.Evaluator.Util as EvaluatorUtil +import tentacles.Services.Services_feeds as Services_feeds + +CONFIG_TREND_AVERAGES = "trend_averages" + +class FearAndGreedIndexEvaluator(evaluators.SocialEvaluator): + SERVICE_FEED_CLASS = Services_feeds.AlternativeMeServiceFeed + + def __init__(self, tentacles_setup_config): + evaluators.SocialEvaluator.__init__(self, tentacles_setup_config) + self.stats_analyser = None + self.history_data = None + self.feed_config = { + services_constants.CONFIG_ALTERNATIVE_ME_TOPICS: [services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED] + } + self.trend_averages = [40, 30, 20, 15, 10] + + def init_user_inputs(self, inputs: dict) -> None: + self.trend_averages = self.UI.user_input(CONFIG_TREND_AVERAGES, + commons_enums.UserInputTypes.OBJECT_ARRAY, + self.trend_averages, inputs, + title="Averages to use to compute the trend evaluation.") + + @classmethod + def get_is_cryptocurrencies_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency dependant else False + """ + return True + + @classmethod + def get_is_cryptocurrency_name_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency name dependant else False + """ + return True + + async def _feed_callback(self, data): + if self._is_interested_by_this_notification(data[services_constants.FEED_METADATA]): + fear_and_greed_history = self.get_data_cache(self.get_current_exchange_time(), key=services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED) + if fear_and_greed_history is not None and len(fear_and_greed_history) > 0: + fear_and_greed_history_values = [item.value for item in fear_and_greed_history] + self.eval_note = self.stats_analyser.get_trend(fear_and_greed_history_values, self.trend_averages) + await self.evaluation_completed(cryptocurrency=None, + eval_time=self.get_current_exchange_time(), + eval_note_description="Latest values: " + ", ".join([str(v) for v in fear_and_greed_history_values[-5:]])) + + def _is_interested_by_this_notification(self, notification_description): + return notification_description == services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED + + async def prepare(self): + self.stats_analyser = tentacles_management.get_single_deepest_child_class(EvaluatorUtil.TrendAnalysis)() + +class SocialScoreEvaluator(evaluators.SocialEvaluator): + SERVICE_FEED_CLASS = Services_feeds.LunarCrushServiceFeed + + def __init__(self, tentacles_setup_config): + evaluators.SocialEvaluator.__init__(self, tentacles_setup_config) + self.stats_analyser = None + + def init_user_inputs(self, inputs: dict) -> None: + self.feed_config = { + services_constants.CONFIG_LUNARCRUSH_COINS: [self.cryptocurrency] + } + + @classmethod + def get_is_cryptocurrencies_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency dependant else False + """ + return False + + @classmethod + def get_is_cryptocurrency_name_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency name dependant else False + """ + return False + + async def _feed_callback(self, data): + if self._is_interested_by_this_notification(data[services_constants.FEED_METADATA]): + coin, _ = data[services_constants.FEED_METADATA].split(";") + coin_data = self.get_data_cache(self.get_current_exchange_time(), key=f"{coin};{services_constants.LUNARCRUSH_COIN_METRICS}") + if coin_data is not None and len(coin_data) > 0: + self.eval_note = coin_data[-1].sentiment + await self.evaluation_completed(cryptocurrency=self.cryptocurrency, eval_time=self.get_current_exchange_time()) + + def _is_interested_by_this_notification(self, notification_description): + try: + coin, topic = notification_description.split(";") + return coin == self.cryptocurrency and topic == services_constants.LUNARCRUSH_COIN_METRICS + except KeyError: + pass + return False diff --git a/Evaluator/Social/trends_evaluator/__init__.py b/Evaluator/Social/trends_evaluator/__init__.py index 70be43c7b..f4ce9d300 100644 --- a/Evaluator/Social/trends_evaluator/__init__.py +++ b/Evaluator/Social/trends_evaluator/__init__.py @@ -1 +1 @@ -from .trends import GoogleTrendsEvaluator \ No newline at end of file +from .trends import GoogleTrendsEvaluator, MarketCapEvaluator \ No newline at end of file diff --git a/Evaluator/Social/trends_evaluator/config/MarketCapEvaluator.json b/Evaluator/Social/trends_evaluator/config/MarketCapEvaluator.json new file mode 100644 index 000000000..582e79d70 --- /dev/null +++ b/Evaluator/Social/trends_evaluator/config/MarketCapEvaluator.json @@ -0,0 +1,4 @@ +{ + "trend_averages" : [40, 30, 20, 15, 10] + } + \ No newline at end of file diff --git a/Evaluator/Social/trends_evaluator/metadata.json b/Evaluator/Social/trends_evaluator/metadata.json index 83b1a83fb..79f3e902a 100644 --- a/Evaluator/Social/trends_evaluator/metadata.json +++ b/Evaluator/Social/trends_evaluator/metadata.json @@ -1,6 +1,6 @@ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", - "tentacles": ["GoogleTrendsEvaluator"], - "tentacles-requirements": ["statistics_analysis", "google_service_feed"] + "tentacles": ["GoogleTrendsEvaluator", "MarketCapEvaluator"], + "tentacles-requirements": ["statistics_analysis", "google_service_feed", "coindesk_service_feed"] } \ No newline at end of file diff --git a/Evaluator/Social/trends_evaluator/resources/MarketCapEvaluator.md b/Evaluator/Social/trends_evaluator/resources/MarketCapEvaluator.md new file mode 100644 index 000000000..1bcadca7e --- /dev/null +++ b/Evaluator/Social/trends_evaluator/resources/MarketCapEvaluator.md @@ -0,0 +1,7 @@ +Analyzes overall crypto market trends through total market capitalization data. + +This evaluator interprets aggregated market cap signals (e.g., historical market cap values, +trend changes, and long-term averages) to produce a normalized score +indicating bullish or bearish market trends based on capitalization movements. + +Data source: ([CoinDesk Data API](https://developers.coindesk.com/documentation/data-api/overview_v1_latest_marketcap_all_tick)) diff --git a/Evaluator/Social/trends_evaluator/trends.py b/Evaluator/Social/trends_evaluator/trends.py index 5eb5c8940..7b73592fe 100644 --- a/Evaluator/Social/trends_evaluator/trends.py +++ b/Evaluator/Social/trends_evaluator/trends.py @@ -23,7 +23,6 @@ import tentacles.Evaluator.Util as EvaluatorUtil import tentacles.Services.Services_feeds as Services_feeds - class GoogleTrendsEvaluator(evaluators.SocialEvaluator): SERVICE_FEED_CLASS = Services_feeds.GoogleServiceFeed if hasattr(Services_feeds, 'GoogleServiceFeed') else None @@ -81,3 +80,52 @@ def _build_trend_topics(self): async def prepare(self): self.stats_analyser = tentacles_management.get_single_deepest_child_class(EvaluatorUtil.StatisticAnalysis)() + +CONFIG_TREND_AVERAGES = "trend_averages" + +class MarketCapEvaluator(evaluators.SocialEvaluator): + SERVICE_FEED_CLASS = Services_feeds.CoindeskServiceFeed + + def __init__(self, tentacles_setup_config): + evaluators.SocialEvaluator.__init__(self, tentacles_setup_config) + self.stats_analyser = None + self.feed_config = { + services_constants.CONFIG_COINDESK_TOPICS: [services_constants.COINDESK_TOPIC_MARKETCAP] + } + self.trend_averages = [40, 30, 20, 15, 10] + + def init_user_inputs(self, inputs: dict) -> None: + self.trend_averages = self.UI.user_input(CONFIG_TREND_AVERAGES, + commons_enums.UserInputTypes.OBJECT_ARRAY, + self.trend_averages, inputs, + title="Averages to use to compute the trend evaluation.") + + @classmethod + def get_is_cryptocurrencies_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency dependant else False + """ + return True + + @classmethod + def get_is_cryptocurrency_name_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency name dependant else False + """ + return True + + async def _feed_callback(self, data): + if self._is_interested_by_this_notification(data[services_constants.FEED_METADATA]): + marketcap_data = self.get_data_cache(self.get_current_exchange_time(), key=services_constants.COINDESK_TOPIC_MARKETCAP) + if marketcap_data is not None and len(marketcap_data) > 0: + marketcap_history = [item.close for item in marketcap_data] + self.eval_note = self.stats_analyser.get_trend(marketcap_history, self.trend_averages) + await self.evaluation_completed(cryptocurrency=None, + eval_time=self.get_current_exchange_time(), + eval_note_description="Latest market cap values: " + ", ".join([str(v) for v in marketcap_history[-5:]])) + + def _is_interested_by_this_notification(self, notification_description): + return notification_description == services_constants.COINDESK_TOPIC_MARKETCAP + + async def prepare(self): + self.stats_analyser = tentacles_management.get_single_deepest_child_class(EvaluatorUtil.TrendAnalysis)() diff --git a/Evaluator/Strategies/blank_strategy_evaluator/blank_strategy.py b/Evaluator/Strategies/blank_strategy_evaluator/blank_strategy.py index 183600976..8f2069282 100644 --- a/Evaluator/Strategies/blank_strategy_evaluator/blank_strategy.py +++ b/Evaluator/Strategies/blank_strategy_evaluator/blank_strategy.py @@ -42,6 +42,8 @@ async def matrix_callback(self, evaluator_type, eval_note, eval_note_type, + eval_note_description, + eval_note_metadata, exchange_name, cryptocurrency, symbol, diff --git a/Evaluator/Strategies/dip_analyser_strategy_evaluator/dip_analyser_strategy.py b/Evaluator/Strategies/dip_analyser_strategy_evaluator/dip_analyser_strategy.py index f1c62fc2f..146169bd8 100644 --- a/Evaluator/Strategies/dip_analyser_strategy_evaluator/dip_analyser_strategy.py +++ b/Evaluator/Strategies/dip_analyser_strategy_evaluator/dip_analyser_strategy.py @@ -61,6 +61,8 @@ async def matrix_callback(self, evaluator_type, eval_note, eval_note_type, + eval_note_description, + eval_note_metadata, exchange_name, cryptocurrency, symbol, diff --git a/Evaluator/Strategies/mixed_strategies_evaluator/mixed_strategies.py b/Evaluator/Strategies/mixed_strategies_evaluator/mixed_strategies.py index c12ab99e2..f2503e6c6 100644 --- a/Evaluator/Strategies/mixed_strategies_evaluator/mixed_strategies.py +++ b/Evaluator/Strategies/mixed_strategies_evaluator/mixed_strategies.py @@ -78,7 +78,9 @@ def init_user_inputs(self, inputs: dict) -> None: default_config[self.BACKGROUND_SOCIAL_EVALUATORS], inputs, other_schema_values={"minItems": 0, "uniqueItems": True}, options=["RedditForumEvaluator", "TwitterNewsEvaluator", - "TelegramSignalEvaluator", "GoogleTrendsEvaluator"], + "TelegramSignalEvaluator", "GoogleTrendsEvaluator", + "FearAndGreedIndexEvaluator", "SocialScoreEvaluator", + "CryptoNewsEvaluator", "MarketCapEvaluator"], title="Social evaluator to consider as background evaluators: they won't trigger technical " "evaluators re-evaluation when updated. Avoiding unnecessary updates increases " "performances.") @@ -101,6 +103,8 @@ async def matrix_callback(self, evaluator_type, eval_note, eval_note_type, + eval_note_description, + eval_note_metadata, exchange_name, cryptocurrency, symbol, @@ -300,6 +304,8 @@ async def matrix_callback(self, evaluator_type, eval_note, eval_note_type, + eval_note_description, + eval_note_metadata, exchange_name, cryptocurrency, symbol, diff --git a/Evaluator/Strategies/move_signals_strategy_evaluator/move_signals_strategy.py b/Evaluator/Strategies/move_signals_strategy_evaluator/move_signals_strategy.py index a0ea42760..ad8fe05ce 100644 --- a/Evaluator/Strategies/move_signals_strategy_evaluator/move_signals_strategy.py +++ b/Evaluator/Strategies/move_signals_strategy_evaluator/move_signals_strategy.py @@ -58,6 +58,8 @@ async def matrix_callback(self, evaluator_type, eval_note, eval_note_type, + eval_note_description, + eval_note_metadata, exchange_name, cryptocurrency, symbol, diff --git a/Services/Services_bases/alternative_me_service/__init__.py b/Services/Services_bases/alternative_me_service/__init__.py new file mode 100644 index 000000000..1331b1a8c --- /dev/null +++ b/Services/Services_bases/alternative_me_service/__init__.py @@ -0,0 +1 @@ +from .alternative_me import AlternativeMeService \ No newline at end of file diff --git a/Services/Services_bases/alternative_me_service/alternative_me.py b/Services/Services_bases/alternative_me_service/alternative_me.py new file mode 100644 index 000000000..13d9ba186 --- /dev/null +++ b/Services/Services_bases/alternative_me_service/alternative_me.py @@ -0,0 +1,42 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import octobot_services.constants as services_constants +import octobot_services.services as services + + +class AlternativeMeService(services.AbstractService): + @staticmethod + def is_setup_correctly(config): + return True + + @staticmethod + def get_is_enabled(config): + return True + + def has_required_configuration(self): + return True + + def get_endpoint(self) -> None: + return None + + def get_type(self) -> None: + return services_constants.CONFIG_ALTERNATIVE_ME + + async def prepare(self) -> None: + pass + + def get_successful_startup_message(self): + return "", True diff --git a/Services/Services_bases/alternative_me_service/metadata.json b/Services/Services_bases/alternative_me_service/metadata.json new file mode 100644 index 000000000..826dca428 --- /dev/null +++ b/Services/Services_bases/alternative_me_service/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["AlternativeMeService"], + "tentacles-requirements": [] +} \ No newline at end of file diff --git a/Services/Services_bases/coindesk_service/__init__.py b/Services/Services_bases/coindesk_service/__init__.py new file mode 100644 index 000000000..daffc1144 --- /dev/null +++ b/Services/Services_bases/coindesk_service/__init__.py @@ -0,0 +1 @@ +from .coindesk import CoindeskService \ No newline at end of file diff --git a/Services/Services_bases/coindesk_service/coindesk.py b/Services/Services_bases/coindesk_service/coindesk.py new file mode 100644 index 000000000..c1f5b74a7 --- /dev/null +++ b/Services/Services_bases/coindesk_service/coindesk.py @@ -0,0 +1,42 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import octobot_services.constants as services_constants +import octobot_services.services as services + + +class CoindeskService(services.AbstractService): + @staticmethod + def is_setup_correctly(config): + return True + + @staticmethod + def get_is_enabled(config): + return True + + def has_required_configuration(self): + return True + + def get_endpoint(self) -> None: + return None + + def get_type(self) -> None: + return services_constants.CONFIG_COINDESK + + async def prepare(self) -> None: + pass + + def get_successful_startup_message(self): + return "", True diff --git a/Services/Services_bases/coindesk_service/metadata.json b/Services/Services_bases/coindesk_service/metadata.json new file mode 100644 index 000000000..cf90b334c --- /dev/null +++ b/Services/Services_bases/coindesk_service/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["CoindeskService"], + "tentacles-requirements": [] +} \ No newline at end of file diff --git a/Services/Services_bases/lunarcrush_service/__init__.py b/Services/Services_bases/lunarcrush_service/__init__.py new file mode 100644 index 000000000..4d81dc242 --- /dev/null +++ b/Services/Services_bases/lunarcrush_service/__init__.py @@ -0,0 +1 @@ +from .lunarcrush import LunarCrushService \ No newline at end of file diff --git a/Services/Services_bases/lunarcrush_service/lunarcrush.py b/Services/Services_bases/lunarcrush_service/lunarcrush.py new file mode 100644 index 000000000..739b0f2c4 --- /dev/null +++ b/Services/Services_bases/lunarcrush_service/lunarcrush.py @@ -0,0 +1,65 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import typing +import octobot_services.constants as services_constants +import octobot_services.services as services + + +class LunarCrushService(services.AbstractService): + @staticmethod + def is_setup_correctly(config): + return True + + @staticmethod + def get_is_enabled(config): + return True + + def has_required_configuration(self): + return services_constants.CONFIG_CATEGORY_SERVICES in self.config \ + and services_constants.CONFIG_LUNARCRUSH in self.config[services_constants.CONFIG_CATEGORY_SERVICES] \ + and self.check_required_config( + self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_LUNARCRUSH]) + + def get_endpoint(self) -> None: + return None + + def get_type(self) -> None: + return services_constants.CONFIG_LUNARCRUSH + + async def prepare(self) -> None: + pass + + def get_successful_startup_message(self): + return "", True + + def get_fields_description(self): + return { + services_constants.CONFIG_LUNARCRUSH_API_KEY: "Api key.", + } + + def get_default_value(self): + return { + services_constants.CONFIG_LUNARCRUSH_API_KEY: "" + } + + def get_required_config(self): + return [services_constants.CONFIG_LUNARCRUSH_API_KEY] + + def get_authentication_headers(self) -> typing.Optional[dict]: + api_key = self.config[services_constants.CONFIG_CATEGORY_SERVICES].get(services_constants.CONFIG_LUNARCRUSH, {}).get(services_constants.CONFIG_LUNARCRUSH_API_KEY, None) + return { + "Authorization": f"Bearer {api_key}" + } if api_key else None diff --git a/Services/Services_bases/lunarcrush_service/metadata.json b/Services/Services_bases/lunarcrush_service/metadata.json new file mode 100644 index 000000000..211d51cba --- /dev/null +++ b/Services/Services_bases/lunarcrush_service/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["LunarCrushService"], + "tentacles-requirements": [] +} \ No newline at end of file diff --git a/Services/Services_feeds/alternative_me_service_feed/__init__.py b/Services/Services_feeds/alternative_me_service_feed/__init__.py new file mode 100644 index 000000000..7a15f5cb8 --- /dev/null +++ b/Services/Services_feeds/alternative_me_service_feed/__init__.py @@ -0,0 +1,2 @@ +from .alternative_me_feed import AlternativeMeServiceFeed +from .alternative_me_feed import AlternativeMeFearAndGreed diff --git a/Services/Services_feeds/alternative_me_service_feed/alternative_me_feed.py b/Services/Services_feeds/alternative_me_service_feed/alternative_me_feed.py new file mode 100644 index 000000000..fdcb9111e --- /dev/null +++ b/Services/Services_feeds/alternative_me_service_feed/alternative_me_feed.py @@ -0,0 +1,136 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import asyncio +import aiohttp +import typing +import dataclasses +import datetime + +import octobot_commons.enums as commons_enums +import octobot_commons.constants as commons_constants +import octobot_services.channel as services_channel +import octobot_services.constants as services_constants +import octobot_services.service_feeds as service_feeds +import tentacles.Services.Services_bases as Services_bases + + +class AlternativeMeServiceFeedChannel(services_channel.AbstractServiceFeedChannel): + pass + + +@dataclasses.dataclass +class AlternativeMeFearAndGreed: + timestamp: float + value: float + value_classification: str + +class AlternativeMeServiceFeed(service_feeds.AbstractServiceFeed): + FEED_CHANNEL = AlternativeMeServiceFeedChannel + REQUIRED_SERVICES = [Services_bases.AlternativeMeService] + + API_RATE_LIMIT_SECONDS = 10 + + def __init__(self, config, main_async_loop, bot_id): + super().__init__(config, main_async_loop, bot_id) + self.alternative_me_topics = [] + self.data_cache = {} + self.refresh_time_frame = commons_enums.TimeFrames.ONE_DAY + self.listener_task = None + + # merge new config into existing config + def update_feed_config(self, config): + self.alternative_me_topics.extend(topic + for topic in config.get(services_constants.CONFIG_ALTERNATIVE_ME_TOPICS, []) + if topic not in self.alternative_me_topics) + self.refresh_time_frame = config.get(services_constants.CONFIG_ALTERNATIVE_ME_REFRESH_TIME_FRAME, commons_enums.TimeFrames.ONE_DAY) + + def _initialize(self): + pass # Nothing to do + + def _something_to_watch(self): + return bool(self.alternative_me_topics) + + def _get_sleep_time_before_next_wakeup(self): + return commons_enums.TimeFramesMinutes[self.refresh_time_frame] * commons_constants.MINUTE_TO_SECONDS + + async def _get_fear_and_greed_data(self, session: aiohttp.ClientSession, limit: typing.Optional[int] = 100) -> bool: + api_url = f"https://api.alternative.me/fng/?limit={limit}&format=json&date_format=us" + async with session.get(api_url) as response: + if response.status != 200: + self.logger.error(f"Alternative.me API request failed with status: {response.status}") + return False + + fear_and_greed_data = await response.json() + data = fear_and_greed_data["data"] + self.data_cache[services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED] = [ + AlternativeMeFearAndGreed( + timestamp=datetime.datetime.strptime(entry["timestamp"], '%m-%d-%Y').timestamp(), + value=float(entry["value"]), + value_classification=entry["value_classification"] + ) for entry in data] + return True + + def get_data_cache(self, current_time: float, key: typing.Optional[str] = None): + if self.data_cache is None: + return None + + if key is None: + return self.data_cache + + if key == services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED and self.data_cache.get(services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED) is not None: + return [item for item in self.data_cache.get(services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED) if item.timestamp <= current_time] + return None + + async def _push_update_and_wait(self, session: aiohttp.ClientSession): + for topic in self.alternative_me_topics: + self.logger.debug(f"Fetching alternative.me {topic} topic data...") + result = False + if topic == services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED: + result = await self._get_fear_and_greed_data(session) + + if result: + await self._async_notify_consumers( + { + services_constants.FEED_METADATA: topic, + } + ) + await asyncio.sleep(self.API_RATE_LIMIT_SECONDS) + await asyncio.sleep(self._get_sleep_time_before_next_wakeup()) + + async def _update_loop(self): + async with aiohttp.ClientSession() as session: + while not self.should_stop: + try: + await self._push_update_and_wait(session) + except Exception as e: + self.logger.exception(e, True, f"Error when receiving Alternative.me data: ({e})") + self.should_stop = True + return False + + async def _start_service_feed(self): + try: + self.listener_task = asyncio.create_task(self._update_loop()) + return True + except Exception as e: + self.logger.exception(e, True, f"Error when initializing Alternative.me feed: {e}") + return False + + async def stop(self): + await super().stop() + if self.listener_task is not None: + self.listener_task.cancel() + self.listener_task = None + diff --git a/Services/Services_feeds/alternative_me_service_feed/metadata.json b/Services/Services_feeds/alternative_me_service_feed/metadata.json new file mode 100644 index 000000000..ca5b23c10 --- /dev/null +++ b/Services/Services_feeds/alternative_me_service_feed/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["AlternativeMeServiceFeed"], + "tentacles-requirements": ["alternative_me_service"] +} \ No newline at end of file diff --git a/Services/Services_feeds/coindesk_service_feed/__init__.py b/Services/Services_feeds/coindesk_service_feed/__init__.py new file mode 100644 index 000000000..8ceaf4bb0 --- /dev/null +++ b/Services/Services_feeds/coindesk_service_feed/__init__.py @@ -0,0 +1,3 @@ +from .coindesk_feed import CoindeskServiceFeed +from .coindesk_feed import CoindeskNews +from .coindesk_feed import CoindeskMarketcap diff --git a/Services/Services_feeds/coindesk_service_feed/coindesk_feed.py b/Services/Services_feeds/coindesk_service_feed/coindesk_feed.py new file mode 100644 index 000000000..5ac383bcc --- /dev/null +++ b/Services/Services_feeds/coindesk_service_feed/coindesk_feed.py @@ -0,0 +1,227 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import asyncio +import aiohttp +import typing +import datetime +import dataclasses + +import octobot_commons.enums as commons_enums +import octobot_commons.constants as commons_constants +import octobot_services.channel as services_channel +import octobot_services.constants as services_constants +import octobot_services.service_feeds as service_feeds +import tentacles.Services.Services_bases as Services_bases + + +class CoindeskServiceFeedChannel(services_channel.AbstractServiceFeedChannel): + pass + + +@dataclasses.dataclass +class CoindeskNews: + id: str + guid: str + published_on: datetime.datetime + image_url: str + title: str + url: str + source_id: str + body: str + keywords: str + lang: str + upvotes: int + downvotes: int + score: int + sentiment: str # POSITIVE, NEGATIVE, NEUTRAL + status: str + source_name: str + source_key: str + source_url: str + source_lang: str + source_type: str + categories: str + +@dataclasses.dataclass +class CoindeskMarketcap: + timestamp: datetime.datetime + open: float + close: float + high: float + low: float + top_tier_volume: float + +class CoindeskServiceFeed(service_feeds.AbstractServiceFeed): + FEED_CHANNEL = CoindeskServiceFeedChannel + REQUIRED_SERVICES = [Services_bases.CoindeskService] + + API_RATE_LIMIT_SECONDS = 10 + + def __init__(self, config, main_async_loop, bot_id): + super().__init__(config, main_async_loop, bot_id) + self.coindesk_api_key = config.get(services_constants.CONFIG_COINDESK_API_KEY, None) + self.coindesk_language = config.get(services_constants.CONFIG_COINDESK_LANGUAGE, "en") + self.coindesk_topics = [] + self.data_cache = {} + self.refresh_time_frame = commons_enums.TimeFrames.ONE_DAY + self.listener_task = None + + # merge new config into existing config + def update_feed_config(self, config): + self.coindesk_topics.extend(topic + for topic in config.get(services_constants.CONFIG_COINDESK_TOPICS, []) + if topic not in self.coindesk_topics) + self.refresh_time_frame = config.get(services_constants.CONFIG_COINDESK_REFRESH_TIME_FRAME, commons_enums.TimeFrames.ONE_DAY) + self.coindesk_language = config.get(services_constants.CONFIG_COINDESK_LANGUAGE, "en") + + def _initialize(self): + pass # Nothing to do + + def _something_to_watch(self): + return bool(self.coindesk_topics) + + def _get_sleep_time_before_next_wakeup(self): + return commons_enums.TimeFramesMinutes[self.refresh_time_frame] * commons_constants.MINUTE_TO_SECONDS + + def _get_marketcap_api_url(self, limit: typing.Optional[int] = 2000): + return f"https://data-api.coindesk.com/overview/v1/historical/marketcap/all/assets/days?limit={limit}&response_format=JSON" + + async def _get_marketcap_data(self, session: aiohttp.ClientSession) -> bool: + async with session.get(self._get_marketcap_api_url()) as response: + if response.status != 200: + self.logger.error(f"Coindesk API request failed with status: {response.status}") + return False + + market_cap_data = await response.json() + + # We should append and check duplicates + self.data_cache[services_constants.COINDESK_TOPIC_MARKETCAP] = [ + CoindeskMarketcap( + timestamp=entry["TIMESTAMP"], + open=entry["OPEN"], + close=entry["CLOSE"], + high=entry["HIGH"], + low=entry["LOW"], + top_tier_volume=entry["TOP_TIER_VOLUME"] + ) for entry in market_cap_data["Data"] + ] + return True + + + def _get_news_api_url(self, limit: typing.Optional[int] = 10): + return f"https://data-api.coindesk.com/news/v1/article/list?lang={self.coindesk_language}&limit={limit}" + + async def _get_news_data(self, session: aiohttp.ClientSession) -> bool: + async with session.get(self._get_news_api_url()) as response: + if response.status != 200: + self.logger.error(f"API request failed with status: {response.status}") + return False + + news_data = await response.json() + articles = news_data.get("Data", []) + + if not articles: + self.logger.error("No articles found in API response") + return False + + values = [] + for article in articles: + source_data = article.get("SOURCE_DATA", {}) + category_data = article.get("CATEGORY_DATA", []) + categories_str = str([cat["NAME"] for cat in category_data]) + + values.append(CoindeskNews( + id=article["ID"], + guid=article["GUID"], + published_on=article["PUBLISHED_ON"], + image_url=article.get("IMAGE_URL", ""), + title=article["TITLE"], + url=article["URL"], + source_id=article["SOURCE_ID"], + body=article.get("BODY", ""), + keywords=article.get("KEYWORDS", ""), + lang=article["LANG"], + upvotes=article.get("UPVOTES", 0), + downvotes=article.get("DOWNVOTES", 0), + score=article.get("SCORE", 0), + sentiment=article.get("SENTIMENT", ""), + status=article.get("STATUS", "ACTIVE"), + source_name=source_data.get("NAME", ""), + source_key=source_data.get("SOURCE_KEY", ""), + source_url=source_data.get("URL", ""), + source_lang=source_data.get("LANG", ""), + source_type=source_data.get("SOURCE_TYPE", ""), + categories=categories_str + )) + + # We should append and check duplicates + self.data_cache[services_constants.COINDESK_TOPIC_NEWS] = values + return True + + def get_data_cache(self, current_time: float, key: typing.Optional[str] = None): + if self.data_cache is None: + return None + + if key is None: + return self.data_cache + + if key == services_constants.COINDESK_TOPIC_NEWS and self.data_cache.get(services_constants.COINDESK_TOPIC_NEWS) is not None: + return [item for item in self.data_cache.get(services_constants.COINDESK_TOPIC_NEWS) if item.published_on <= current_time] + elif key == services_constants.COINDESK_TOPIC_MARKETCAP and self.data_cache.get(services_constants.COINDESK_TOPIC_MARKETCAP) is not None: + return [item for item in self.data_cache.get(services_constants.COINDESK_TOPIC_MARKETCAP) if item.timestamp <= current_time] + return None + + async def _push_update_and_wait(self, session: aiohttp.ClientSession): + for topic in self.coindesk_topics: + self.logger.debug(f"Fetching coindesk {topic} topic data...") + result = False + if topic == services_constants.COINDESK_TOPIC_NEWS: + result = await self._get_news_data(session) + elif topic == services_constants.COINDESK_TOPIC_MARKETCAP: + result = await self._get_marketcap_data(session) + + if result: + await self._async_notify_consumers( + { + services_constants.FEED_METADATA: topic, + } + ) + await asyncio.sleep(self.API_RATE_LIMIT_SECONDS) + await asyncio.sleep(self._get_sleep_time_before_next_wakeup()) + + async def _update_loop(self): + async with aiohttp.ClientSession() as session: + while not self.should_stop: + try: + await self._push_update_and_wait(session) + except Exception as e: + self.logger.exception(e, True, f"Error when receiving Coindesk feed: ({e})") + self.should_stop = True + return False + + async def _start_service_feed(self): + try: + self.listener_task = asyncio.create_task(self._update_loop()) + return True + except Exception as e: + self.logger.exception(e, True, f"Error when initializing Coindesk feed: {e}") + return False + + async def stop(self): + await super().stop() + if self.listener_task is not None: + self.listener_task.cancel() + self.listener_task = None diff --git a/Services/Services_feeds/coindesk_service_feed/metadata.json b/Services/Services_feeds/coindesk_service_feed/metadata.json new file mode 100644 index 000000000..c332735a0 --- /dev/null +++ b/Services/Services_feeds/coindesk_service_feed/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["CoindeskServiceFeed"], + "tentacles-requirements": ["coindesk_service"] +} \ No newline at end of file diff --git a/Services/Services_feeds/google_service_feed/google_feed.py b/Services/Services_feeds/google_service_feed/google_feed.py index b2d6d107b..7ae8d9c15 100644 --- a/Services/Services_feeds/google_service_feed/google_feed.py +++ b/Services/Services_feeds/google_service_feed/google_feed.py @@ -56,6 +56,7 @@ def __init__(self, config, main_async_loop, bot_id): super().__init__(config, main_async_loop, bot_id) self.trends_req_builder = None self.trends_topics = [] + self.listener_task = None def _initialize(self): # if the url changes (google sometimes changes it), use the following line: @@ -116,8 +117,15 @@ async def _update_loop(self): async def _start_service_feed(self): try: - asyncio.create_task(self._update_loop()) + self.listener_task = asyncio.create_task(self._update_loop()) + return True except Exception as e: self.logger.exception(e, True, f"Error when initializing Google trends feed: {e}") return False - return True + + async def stop(self): + await super().stop() + if self.listener_task is not None: + self.listener_task.cancel() + self.listener_task = None + diff --git a/Services/Services_feeds/lunarcrush_service_feed/__init__.py b/Services/Services_feeds/lunarcrush_service_feed/__init__.py new file mode 100644 index 000000000..fd3dc3774 --- /dev/null +++ b/Services/Services_feeds/lunarcrush_service_feed/__init__.py @@ -0,0 +1 @@ +from .lunarcrush_feed import LunarCrushServiceFeed diff --git a/Services/Services_feeds/lunarcrush_service_feed/lunarcrush_feed.py b/Services/Services_feeds/lunarcrush_service_feed/lunarcrush_feed.py new file mode 100644 index 000000000..e33c44c2f --- /dev/null +++ b/Services/Services_feeds/lunarcrush_service_feed/lunarcrush_feed.py @@ -0,0 +1,151 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import asyncio +import datetime +import aiohttp +import json +import dataclasses +import typing + +import octobot_commons.enums as commons_enums +import octobot_commons.constants as commons_constants +import octobot_commons.dataclasses as commons_dataclasses +import octobot_services.channel as services_channel +import octobot_services.constants as services_constants +import octobot_services.service_feeds as service_feeds +import tentacles.Services.Services_bases as Services_bases + + +class LunarCrushServiceFeedChannel(services_channel.AbstractServiceFeedChannel): + pass + + +@dataclasses.dataclass +class LunarCrushCoinMetrics(commons_dataclasses.FlexibleDataclass): + time: int # A unix timestamp (in seconds) + contributors_active: int # number of unique social accounts with posts that have interactions + contributors_created: int # number of unique social accounts that created new posts + interactions: int # number of all publicly measurable interactions on a social post (views, likes, comments, thumbs up, upvote, share etc) + posts_active: int # number of unique social posts with interactions + posts_created: int # number of unique social posts created + sentiment: float # % of posts (weighted by interactions) that are positive. 100% means all posts are positive, 50% is half positive and half negative, and 0% is all negative posts. + spam: int # The number of posts created that are considered spam + alt_rank: float # A proprietary score based on how an asset is performing relative to all other assets supported + circulating_supply: int # Circulating Supply is the total number of coins or tokens that are actively available for trade and are being used in the market and in general public + close: float # Close price for the time period + galaxy_score: float # A proprietary score based on technical indicators of price, average social sentiment, relative social activity, and a factor of how closely social indicators correlate with price and volume + high: float # Higest price fo rthe time period + low: float # Lowest price for the time period + market_cap: int # Total dollar market value of all circulating supply or outstanding shares + market_dominance: float # The percent of the total market cap that this asset represents + open: float # Open price for the time period + social_dominance: float # The percent of the total social volume that this topic represents + volume_24h: float # Volume in USD for 24 hours up to this data point + +class LunarCrushServiceFeed(service_feeds.AbstractServiceFeed): + FEED_CHANNEL = LunarCrushServiceFeedChannel + REQUIRED_SERVICES = [Services_bases.LunarCrushService] + + def __init__(self, config, main_async_loop, bot_id): + super().__init__(config, main_async_loop, bot_id) + self.lunarcrush_coins = [] + self.data_cache = {} + self.refresh_time_frame = commons_enums.TimeFrames.ONE_DAY + self.listener_task = None + + # merge new config into existing config + def update_feed_config(self, config): + self.lunarcrush_coins.extend(coin + for coin in config.get(services_constants.CONFIG_LUNARCRUSH_COINS, []) + if coin not in self.lunarcrush_coins) + self.refresh_time_frame = config.get(services_constants.CONFIG_LUNARCRUSH_REFRESH_TIME_FRAME, commons_enums.TimeFrames.ONE_DAY) + + def _initialize(self): + pass # Nothing to do + + def _something_to_watch(self): + return bool(self.lunarcrush_coins) + + def _get_sleep_time_before_next_wakeup(self): + return commons_enums.TimeFramesMinutes[self.refresh_time_frame] * commons_constants.MINUTE_TO_SECONDS + + def get_data_cache(self, current_time: float, key: typing.Optional[str] = None): + if self.data_cache is None: + return None + + if key is None: + return self.data_cache + + coin, topic = key.split(";") + if topic == services_constants.LUNARCRUSH_COIN_METRICS and self.data_cache.get(services_constants.LUNARCRUSH_COIN_METRICS) is not None: + return [item for item in self.data_cache.get(services_constants.LUNARCRUSH_COIN_METRICS).get(coin) if item.time <= current_time] + return None + + async def _get_coin_data(self, session: aiohttp.ClientSession, coin: str, start_date: datetime.datetime, end_date: datetime.datetime) -> bool: + self.logger.debug(f"Getting lunarcrush coin data for {coin} from {start_date.timestamp()} to {end_date.timestamp()}...") + api_url = f"https://lunarcrush.com/api3/public/coins/{coin}/time-series/v2?bucket=day&interval=1d&start={int(start_date.timestamp())}&end={int(end_date.timestamp())}" + async with session.get(api_url) as response: + if response.status != 200: + self.logger.error(f"Lunarcrush API request failed with status: {response.status}") + return False + + coin_metrics_data = await response.json() + data = coin_metrics_data["data"] + self.data_cache[services_constants.LUNARCRUSH_COIN_METRICS] = { + coin: [ + LunarCrushCoinMetrics.from_dict(entry) + for entry in data + ] + } + return True + + async def _push_update_and_wait(self, session: aiohttp.ClientSession): + end_date = datetime.datetime.now(datetime.timezone.utc) + start_date = end_date - datetime.timedelta(days=30) + for coin in self.lunarcrush_coins: + result = await self._get_coin_data(session, coin, start_date, end_date) + if result: + await self._async_notify_consumers( + { + services_constants.FEED_METADATA: f"{coin};{services_constants.LUNARCRUSH_COIN_METRICS}", + } + ) + await asyncio.sleep(self._get_sleep_time_before_next_wakeup()) + + async def _update_loop(self): + async with aiohttp.ClientSession(headers=self.services[0].get_authentication_headers()) as session: + while not self.should_stop: + try: + await self._push_update_and_wait(session) + except Exception as e: + self.logger.exception(e, True, f"Error when receiving LunarCrush data: ({e})") + self.should_stop = True + return False + + async def _start_service_feed(self): + try: + self.listener_task = asyncio.create_task(self._update_loop()) + return True + except Exception as e: + self.logger.exception(e, True, f"Error when initializing LunarCrush feed: {e}") + return False + + async def stop(self): + await super().stop() + if self.listener_task is not None: + self.listener_task.cancel() + self.listener_task = None + diff --git a/Services/Services_feeds/lunarcrush_service_feed/metadata.json b/Services/Services_feeds/lunarcrush_service_feed/metadata.json new file mode 100644 index 000000000..ad7c73f8c --- /dev/null +++ b/Services/Services_feeds/lunarcrush_service_feed/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["LunarCrushServiceFeed"], + "tentacles-requirements": ["lunarcrush_service"] +} \ No newline at end of file diff --git a/Trading/Mode/index_trading_mode/__init__.py b/Trading/Mode/index_trading_mode/__init__.py index a2de9048e..be63f6e6b 100644 --- a/Trading/Mode/index_trading_mode/__init__.py +++ b/Trading/Mode/index_trading_mode/__init__.py @@ -1 +1 @@ -from .index_trading import IndexTradingMode \ No newline at end of file +from .index_trading import IndexTradingMode From 219c4fcb030dc05f23fb115760b5bfb7f21e42b2 Mon Sep 17 00:00:00 2001 From: Herklos Date: Sat, 24 Jan 2026 12:57:14 +0100 Subject: [PATCH 2/4] Add Coingecko services --- Evaluator/Social/trends_evaluator/__init__.py | 2 +- .../config/CryptoMarketCapEvaluator.json | 1 + .../Social/trends_evaluator/metadata.json | 4 +- .../resources/CryptoMarketCapEvaluator.md | 11 + Evaluator/Social/trends_evaluator/trends.py | 77 +++++ .../coingecko_service/__init__.py | 1 + .../coingecko_service/coingecko.py | 62 ++++ .../coingecko_service/metadata.json | 6 + .../alternative_me_feed.py | 4 +- .../coindesk_service_feed/coindesk_feed.py | 21 +- .../coingecko_service_feed/__init__.py | 1 + .../coingecko_service_feed/coingecko_feed.py | 280 ++++++++++++++++++ .../coingecko_service_feed/metadata.json | 6 + 13 files changed, 465 insertions(+), 11 deletions(-) create mode 100644 Evaluator/Social/trends_evaluator/config/CryptoMarketCapEvaluator.json create mode 100644 Evaluator/Social/trends_evaluator/resources/CryptoMarketCapEvaluator.md create mode 100644 Services/Services_bases/coingecko_service/__init__.py create mode 100644 Services/Services_bases/coingecko_service/coingecko.py create mode 100644 Services/Services_bases/coingecko_service/metadata.json create mode 100644 Services/Services_feeds/coingecko_service_feed/__init__.py create mode 100644 Services/Services_feeds/coingecko_service_feed/coingecko_feed.py create mode 100644 Services/Services_feeds/coingecko_service_feed/metadata.json diff --git a/Evaluator/Social/trends_evaluator/__init__.py b/Evaluator/Social/trends_evaluator/__init__.py index f4ce9d300..f721b7f44 100644 --- a/Evaluator/Social/trends_evaluator/__init__.py +++ b/Evaluator/Social/trends_evaluator/__init__.py @@ -1 +1 @@ -from .trends import GoogleTrendsEvaluator, MarketCapEvaluator \ No newline at end of file +from .trends import GoogleTrendsEvaluator, MarketCapEvaluator, CryptoMarketCapEvaluator \ No newline at end of file diff --git a/Evaluator/Social/trends_evaluator/config/CryptoMarketCapEvaluator.json b/Evaluator/Social/trends_evaluator/config/CryptoMarketCapEvaluator.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/Evaluator/Social/trends_evaluator/config/CryptoMarketCapEvaluator.json @@ -0,0 +1 @@ +{} diff --git a/Evaluator/Social/trends_evaluator/metadata.json b/Evaluator/Social/trends_evaluator/metadata.json index 79f3e902a..552512841 100644 --- a/Evaluator/Social/trends_evaluator/metadata.json +++ b/Evaluator/Social/trends_evaluator/metadata.json @@ -1,6 +1,6 @@ { "version": "1.2.0", "origin_package": "OctoBot-Default-Tentacles", - "tentacles": ["GoogleTrendsEvaluator", "MarketCapEvaluator"], - "tentacles-requirements": ["statistics_analysis", "google_service_feed", "coindesk_service_feed"] + "tentacles": ["GoogleTrendsEvaluator", "MarketCapEvaluator", "CryptoMarketCapEvaluator"], + "tentacles-requirements": ["statistics_analysis", "google_service_feed", "coindesk_service_feed", "coingecko_service_feed"] } \ No newline at end of file diff --git a/Evaluator/Social/trends_evaluator/resources/CryptoMarketCapEvaluator.md b/Evaluator/Social/trends_evaluator/resources/CryptoMarketCapEvaluator.md new file mode 100644 index 000000000..74a52f80c --- /dev/null +++ b/Evaluator/Social/trends_evaluator/resources/CryptoMarketCapEvaluator.md @@ -0,0 +1,11 @@ +Analyzes cryptocurrency market trends through CoinGecko market capitalization data for individual coins. + +This evaluator interprets market cap signals from the top 100 cryptocurrencies (ordered by market cap) to produce a normalized score indicating bullish or bearish trends based on: +- Coin position/rank in the top 100 (higher rank = more established) +- Market cap change percentage over 24 hours +- Price change percentage over 24 hours +- Trading volume relative to other top coins + +The evaluator generates eval notes by combining these factors with position-based weighting, where higher-ranked coins (e.g., rank 1-10) receive higher confidence multipliers than lower-ranked coins. + +Data source: ([CoinGecko API](https://www.coingecko.com/en/api)) diff --git a/Evaluator/Social/trends_evaluator/trends.py b/Evaluator/Social/trends_evaluator/trends.py index 7b73592fe..869dd67f1 100644 --- a/Evaluator/Social/trends_evaluator/trends.py +++ b/Evaluator/Social/trends_evaluator/trends.py @@ -129,3 +129,80 @@ def _is_interested_by_this_notification(self, notification_description): async def prepare(self): self.stats_analyser = tentacles_management.get_single_deepest_child_class(EvaluatorUtil.TrendAnalysis)() + + +class CryptoMarketCapEvaluator(evaluators.SocialEvaluator): + SERVICE_FEED_CLASS = Services_feeds.CoingeckoServiceFeed + + def __init__(self, tentacles_setup_config): + evaluators.SocialEvaluator.__init__(self, tentacles_setup_config) + self.feed_config = { + services_constants.CONFIG_COINGECKO_TOPICS: [services_constants.COINGECKO_TOPIC_MARKETS] + } + + @classmethod + def get_is_cryptocurrencies_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency dependant else False + """ + return False + + @classmethod + def get_is_cryptocurrency_name_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency name dependant else False + """ + return False + + async def _feed_callback(self, data): + if self._is_interested_by_this_notification(data[services_constants.FEED_METADATA]): + markets_data = self.get_data_cache(self.get_current_exchange_time(), key=services_constants.COINGECKO_TOPIC_MARKETS) + if markets_data is not None and len(markets_data) > 0: + # Find the coin in the markets data by symbol (case-insensitive) + coin_data = None + for market_coin in markets_data: + if market_coin.symbol and market_coin.symbol.upper() == self.cryptocurrency.upper(): + coin_data = market_coin + break + + if coin_data is None: + # Coin not found in top 100, return neutral eval note + self.eval_note = 0.0 + await self.evaluation_completed( + cryptocurrency=self.cryptocurrency, + eval_time=self.get_current_exchange_time(), + eval_note_description=f"{self.cryptocurrency} not found in top 100 market cap coins" + ) + return + + # Calculate base signal from market_cap_change_percentage_24h + market_cap_change = coin_data.market_cap_change_percentage_24h or 0.0 + market_cap_signal = max(-1.0, min(1.0, market_cap_change / 50.0)) + + # Calculate price signal + price_change = coin_data.price_change_percentage_24h or 0.0 + price_signal = max(-1.0, min(1.0, price_change / 20.0)) + + # Calculate position weight (higher rank = more established = higher confidence) + rank = coin_data.market_cap_rank or 100 + position_weight = max(0.7, 1.0 - (rank - 1) / 100.0) + + # Calculate volume factor (normalize by max volume in top 100) + max_volume = max((coin.total_volume or 0.0) for coin in markets_data) + volume_factor = 1.0 + if max_volume > 0: + volume_factor = min(1.0, (coin_data.total_volume or 0.0) / max_volume) + + # Combine all factors + eval_note = (market_cap_signal * 0.6 + price_signal * 0.3 + volume_factor * 0.1) * position_weight + eval_note = max(-1.0, min(1.0, eval_note)) + + self.eval_note = eval_note + await self.evaluation_completed( + cryptocurrency=self.cryptocurrency, + eval_time=self.get_current_exchange_time(), + eval_note_description=f"Rank: {rank}, Market Cap Change: {market_cap_change:.2f}%, Price Change: {price_change:.2f}%, Position Weight: {position_weight:.2f}" + ) + + def _is_interested_by_this_notification(self, notification_description): + return notification_description == services_constants.COINGECKO_TOPIC_MARKETS diff --git a/Services/Services_bases/coingecko_service/__init__.py b/Services/Services_bases/coingecko_service/__init__.py new file mode 100644 index 000000000..aa318d8c9 --- /dev/null +++ b/Services/Services_bases/coingecko_service/__init__.py @@ -0,0 +1 @@ +from .coingecko import CoingeckoService diff --git a/Services/Services_bases/coingecko_service/coingecko.py b/Services/Services_bases/coingecko_service/coingecko.py new file mode 100644 index 000000000..aab472897 --- /dev/null +++ b/Services/Services_bases/coingecko_service/coingecko.py @@ -0,0 +1,62 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import typing +import octobot_services.constants as services_constants +import octobot_services.services as services + + +class CoingeckoService(services.AbstractService): + @staticmethod + def is_setup_correctly(config): + return True + + @staticmethod + def get_is_enabled(config): + return True + + def has_required_configuration(self): + # Coingecko has a free tier, so API key is optional + return True + + def get_endpoint(self) -> None: + return None + + def get_type(self) -> None: + return services_constants.CONFIG_COINGECKO + + async def prepare(self) -> None: + pass + + def get_successful_startup_message(self): + return "", True + + def get_fields_description(self): + return { + services_constants.CONFIG_COINGECKO_API_KEY: "Api key (optional, for pro API).", + } + + def get_default_value(self): + return { + services_constants.CONFIG_COINGECKO_API_KEY: "" + } + + def get_required_config(self): + return [] + + def get_api_key(self) -> typing.Optional[str]: + return self.config.get(services_constants.CONFIG_CATEGORY_SERVICES, {}).get( + services_constants.CONFIG_COINGECKO, {} + ).get(services_constants.CONFIG_COINGECKO_API_KEY, None) diff --git a/Services/Services_bases/coingecko_service/metadata.json b/Services/Services_bases/coingecko_service/metadata.json new file mode 100644 index 000000000..e8c41ee82 --- /dev/null +++ b/Services/Services_bases/coingecko_service/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["CoingeckoService"], + "tentacles-requirements": [] +} diff --git a/Services/Services_feeds/alternative_me_service_feed/alternative_me_feed.py b/Services/Services_feeds/alternative_me_service_feed/alternative_me_feed.py index fdcb9111e..aaff0e91b 100644 --- a/Services/Services_feeds/alternative_me_service_feed/alternative_me_feed.py +++ b/Services/Services_feeds/alternative_me_service_feed/alternative_me_feed.py @@ -77,7 +77,7 @@ async def _get_fear_and_greed_data(self, session: aiohttp.ClientSession, limit: data = fear_and_greed_data["data"] self.data_cache[services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED] = [ AlternativeMeFearAndGreed( - timestamp=datetime.datetime.strptime(entry["timestamp"], '%m-%d-%Y').timestamp(), + timestamp=datetime.datetime.strptime(entry["timestamp"], '%m-%d-%Y').replace(tzinfo=datetime.timezone.utc).timestamp(), value=float(entry["value"]), value_classification=entry["value_classification"] ) for entry in data] @@ -117,7 +117,7 @@ async def _update_loop(self): await self._push_update_and_wait(session) except Exception as e: self.logger.exception(e, True, f"Error when receiving Alternative.me data: ({e})") - self.should_stop = True + await asyncio.sleep(self._get_sleep_time_before_next_wakeup()) return False async def _start_service_feed(self): diff --git a/Services/Services_feeds/coindesk_service_feed/coindesk_feed.py b/Services/Services_feeds/coindesk_service_feed/coindesk_feed.py index 5ac383bcc..c973515b6 100644 --- a/Services/Services_feeds/coindesk_service_feed/coindesk_feed.py +++ b/Services/Services_feeds/coindesk_service_feed/coindesk_feed.py @@ -96,6 +96,12 @@ def _something_to_watch(self): def _get_sleep_time_before_next_wakeup(self): return commons_enums.TimeFramesMinutes[self.refresh_time_frame] * commons_constants.MINUTE_TO_SECONDS + def _merge_cache_data(self, cache_key: str, new_values: list, id_getter: typing.Callable) -> list: + existing = self.data_cache.get(cache_key, []) + existing_ids = {id_getter(item) for item in existing} + new_unique = [item for item in new_values if id_getter(item) not in existing_ids] + return existing + new_unique + def _get_marketcap_api_url(self, limit: typing.Optional[int] = 2000): return f"https://data-api.coindesk.com/overview/v1/historical/marketcap/all/assets/days?limit={limit}&response_format=JSON" @@ -107,8 +113,7 @@ async def _get_marketcap_data(self, session: aiohttp.ClientSession) -> bool: market_cap_data = await response.json() - # We should append and check duplicates - self.data_cache[services_constants.COINDESK_TOPIC_MARKETCAP] = [ + new_values = [ CoindeskMarketcap( timestamp=entry["TIMESTAMP"], open=entry["OPEN"], @@ -117,7 +122,10 @@ async def _get_marketcap_data(self, session: aiohttp.ClientSession) -> bool: low=entry["LOW"], top_tier_volume=entry["TOP_TIER_VOLUME"] ) for entry in market_cap_data["Data"] - ] + ] + self.data_cache[services_constants.COINDESK_TOPIC_MARKETCAP] = self._merge_cache_data( + services_constants.COINDESK_TOPIC_MARKETCAP, new_values, lambda x: x.timestamp + ) return True @@ -167,8 +175,9 @@ async def _get_news_data(self, session: aiohttp.ClientSession) -> bool: categories=categories_str )) - # We should append and check duplicates - self.data_cache[services_constants.COINDESK_TOPIC_NEWS] = values + self.data_cache[services_constants.COINDESK_TOPIC_NEWS] = self._merge_cache_data( + services_constants.COINDESK_TOPIC_NEWS, values, lambda x: x.id + ) return True def get_data_cache(self, current_time: float, key: typing.Optional[str] = None): @@ -209,7 +218,7 @@ async def _update_loop(self): await self._push_update_and_wait(session) except Exception as e: self.logger.exception(e, True, f"Error when receiving Coindesk feed: ({e})") - self.should_stop = True + await asyncio.sleep(self._get_sleep_time_before_next_wakeup()) return False async def _start_service_feed(self): diff --git a/Services/Services_feeds/coingecko_service_feed/__init__.py b/Services/Services_feeds/coingecko_service_feed/__init__.py new file mode 100644 index 000000000..4f74ccb6d --- /dev/null +++ b/Services/Services_feeds/coingecko_service_feed/__init__.py @@ -0,0 +1 @@ +from .coingecko_feed import CoingeckoServiceFeed diff --git a/Services/Services_feeds/coingecko_service_feed/coingecko_feed.py b/Services/Services_feeds/coingecko_service_feed/coingecko_feed.py new file mode 100644 index 000000000..cee14a032 --- /dev/null +++ b/Services/Services_feeds/coingecko_service_feed/coingecko_feed.py @@ -0,0 +1,280 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import asyncio +import typing +import dataclasses + +import coingecko_openapi_client + +import octobot_commons.enums as commons_enums +import octobot_commons.constants as commons_constants +import octobot_commons.dataclasses as commons_dataclasses +import octobot_services.channel as services_channel +import octobot_services.constants as services_constants +import octobot_services.service_feeds as service_feeds +import tentacles.Services.Services_bases as Services_bases + + +class CoingeckoServiceFeedChannel(services_channel.AbstractServiceFeedChannel): + pass + + +@dataclasses.dataclass +class CoingeckoCoinMarket(commons_dataclasses.FlexibleDataclass): + id: str + symbol: str + name: str + current_price: float + market_cap: float + market_cap_rank: int + total_volume: float + high_24h: float + low_24h: float + price_change_24h: float + price_change_percentage_24h: float + market_cap_change_24h: float + market_cap_change_percentage_24h: float + circulating_supply: float + total_supply: float + ath: float + ath_change_percentage: float + atl: float + atl_change_percentage: float + last_updated: str + + +@dataclasses.dataclass +class CoingeckoTrendingCoin(commons_dataclasses.FlexibleDataclass): + id: str + coin_id: int + name: str + symbol: str + market_cap_rank: int + thumb: str + small: str + large: str + slug: str + price_btc: float + score: int + + +@dataclasses.dataclass +class CoingeckoGlobalData(commons_dataclasses.FlexibleDataclass): + active_cryptocurrencies: int + upcoming_icos: int + ongoing_icos: int + ended_icos: int + markets: int + total_market_cap: dict + total_volume: dict + market_cap_percentage: dict + market_cap_change_percentage_24h_usd: float + updated_at: int + + +class CoingeckoServiceFeed(service_feeds.AbstractServiceFeed): + FEED_CHANNEL = CoingeckoServiceFeedChannel + REQUIRED_SERVICES = [Services_bases.CoingeckoService] + + API_RATE_LIMIT_SECONDS = 10 + + def __init__(self, config, main_async_loop, bot_id): + super().__init__(config, main_async_loop, bot_id) + self.coingecko_topics = [] + self.coingecko_coins = [] + self.data_cache = {} + self.refresh_time_frame = commons_enums.TimeFrames.ONE_DAY + self.listener_task = None + self.api_client = None + self.coins_api = None + self.trending_api = None + self.global_api = None + + def _initialize_api_client(self): + self.api_client = coingecko_openapi_client.ApiClient() + self.coins_api = coingecko_openapi_client.api.coins_api.CoinsApi(self.api_client) + self.trending_api = coingecko_openapi_client.api.trending_api.TrendingApi(self.api_client) + self.global_api = coingecko_openapi_client.api.global_api.GlobalApi(self.api_client) + + # merge new config into existing config + def update_feed_config(self, config): + self.coingecko_topics.extend( + topic + for topic in config.get(services_constants.CONFIG_COINGECKO_TOPICS, []) + if topic not in self.coingecko_topics + ) + self.coingecko_coins.extend( + coin + for coin in config.get(services_constants.CONFIG_COINGECKO_COINS, []) + if coin not in self.coingecko_coins + ) + self.refresh_time_frame = config.get( + services_constants.CONFIG_COINGECKO_REFRESH_TIME_FRAME, + commons_enums.TimeFrames.ONE_DAY + ) + + def _initialize(self): + self._initialize_api_client() + + def _something_to_watch(self): + return bool(self.coingecko_topics) + + def _get_sleep_time_before_next_wakeup(self): + return commons_enums.TimeFramesMinutes[self.refresh_time_frame] * commons_constants.MINUTE_TO_SECONDS + + async def _get_markets_data(self, per_page: int = 100) -> bool: + try: + self.logger.debug("Fetching coingecko markets data...") + markets_data = await self.coins_api.coins_markets_get( + vs_currency='usd', + per_page=per_page, + order='market_cap_desc' + ) + self.data_cache[services_constants.COINGECKO_TOPIC_MARKETS] = [ + CoingeckoCoinMarket.from_dict({ + 'id': coin.get('id', ''), + 'symbol': coin.get('symbol', ''), + 'name': coin.get('name', ''), + 'current_price': coin.get('current_price', 0.0), + 'market_cap': coin.get('market_cap', 0), + 'market_cap_rank': coin.get('market_cap_rank', 0), + 'total_volume': coin.get('total_volume', 0.0), + 'high_24h': coin.get('high_24h', 0.0), + 'low_24h': coin.get('low_24h', 0.0), + 'price_change_24h': coin.get('price_change_24h', 0.0), + 'price_change_percentage_24h': coin.get('price_change_percentage_24h', 0.0), + 'market_cap_change_24h': coin.get('market_cap_change_24h', 0.0), + 'market_cap_change_percentage_24h': coin.get('market_cap_change_percentage_24h', 0.0), + 'circulating_supply': coin.get('circulating_supply', 0.0), + 'total_supply': coin.get('total_supply', 0.0), + 'ath': coin.get('ath', 0.0), + 'ath_change_percentage': coin.get('ath_change_percentage', 0.0), + 'atl': coin.get('atl', 0.0), + 'atl_change_percentage': coin.get('atl_change_percentage', 0.0), + 'last_updated': coin.get('last_updated', ''), + }) + for coin in markets_data + ] + return True + except Exception as e: + self.logger.error(f"Coingecko markets API request failed: {e}") + return False + + async def _get_trending_data(self) -> bool: + try: + self.logger.debug("Fetching coingecko trending data...") + trending_data = await self.trending_api.search_trending_get() + coins = trending_data.get('coins', []) + self.data_cache[services_constants.COINGECKO_TOPIC_TRENDING] = [ + CoingeckoTrendingCoin.from_dict({ + 'id': coin.get('item', {}).get('id', ''), + 'coin_id': coin.get('item', {}).get('coin_id', 0), + 'name': coin.get('item', {}).get('name', ''), + 'symbol': coin.get('item', {}).get('symbol', ''), + 'market_cap_rank': coin.get('item', {}).get('market_cap_rank', 0), + 'thumb': coin.get('item', {}).get('thumb', ''), + 'small': coin.get('item', {}).get('small', ''), + 'large': coin.get('item', {}).get('large', ''), + 'slug': coin.get('item', {}).get('slug', ''), + 'price_btc': coin.get('item', {}).get('price_btc', 0.0), + 'score': coin.get('item', {}).get('score', 0), + }) + for coin in coins + ] + return True + except Exception as e: + self.logger.error(f"Coingecko trending API request failed: {e}") + return False + + async def _get_global_data(self) -> bool: + try: + self.logger.debug("Fetching coingecko global data...") + global_data = await self.global_api.global_get() + data = global_data.get('data', {}) + self.data_cache[services_constants.COINGECKO_TOPIC_GLOBAL] = CoingeckoGlobalData.from_dict({ + 'active_cryptocurrencies': data.get('active_cryptocurrencies', 0), + 'upcoming_icos': data.get('upcoming_icos', 0), + 'ongoing_icos': data.get('ongoing_icos', 0), + 'ended_icos': data.get('ended_icos', 0), + 'markets': data.get('markets', 0), + 'total_market_cap': data.get('total_market_cap', {}), + 'total_volume': data.get('total_volume', {}), + 'market_cap_percentage': data.get('market_cap_percentage', {}), + 'market_cap_change_percentage_24h_usd': data.get('market_cap_change_percentage_24h_usd', 0.0), + 'updated_at': data.get('updated_at', 0), + }) + return True + except Exception as e: + self.logger.error(f"Coingecko global API request failed: {e}") + return False + + def get_data_cache(self, current_time: float, key: typing.Optional[str] = None): + if self.data_cache is None: + return None + + if key is None: + return self.data_cache + + if key == services_constants.COINGECKO_TOPIC_MARKETS: + return self.data_cache.get(services_constants.COINGECKO_TOPIC_MARKETS) + elif key == services_constants.COINGECKO_TOPIC_TRENDING: + return self.data_cache.get(services_constants.COINGECKO_TOPIC_TRENDING) + elif key == services_constants.COINGECKO_TOPIC_GLOBAL: + return self.data_cache.get(services_constants.COINGECKO_TOPIC_GLOBAL) + return None + + async def _push_update_and_wait(self): + for topic in self.coingecko_topics: + self.logger.debug(f"Fetching coingecko {topic} topic data...") + result = False + if topic == services_constants.COINGECKO_TOPIC_MARKETS: + result = await self._get_markets_data() + elif topic == services_constants.COINGECKO_TOPIC_TRENDING: + result = await self._get_trending_data() + elif topic == services_constants.COINGECKO_TOPIC_GLOBAL: + result = await self._get_global_data() + + if result: + await self._async_notify_consumers( + { + services_constants.FEED_METADATA: topic, + } + ) + await asyncio.sleep(self.API_RATE_LIMIT_SECONDS) + await asyncio.sleep(self._get_sleep_time_before_next_wakeup()) + + async def _update_loop(self): + while not self.should_stop: + try: + await self._push_update_and_wait() + except Exception as e: + self.logger.exception(e, True, f"Error when receiving Coingecko data: ({e})") + await asyncio.sleep(self._get_sleep_time_before_next_wakeup()) + return False + + async def _start_service_feed(self): + try: + self.listener_task = asyncio.create_task(self._update_loop()) + return True + except Exception as e: + self.logger.exception(e, True, f"Error when initializing Coingecko feed: {e}") + return False + + async def stop(self): + await super().stop() + if self.listener_task is not None: + self.listener_task.cancel() + self.listener_task = None diff --git a/Services/Services_feeds/coingecko_service_feed/metadata.json b/Services/Services_feeds/coingecko_service_feed/metadata.json new file mode 100644 index 000000000..7f0b28622 --- /dev/null +++ b/Services/Services_feeds/coingecko_service_feed/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["CoingeckoServiceFeed"], + "tentacles-requirements": ["coingecko_service"] +} From d98842be3c883fc25ed8ae48d7243a923d2172d8 Mon Sep 17 00:00:00 2001 From: Herklos Date: Wed, 21 Jan 2026 15:25:38 +0100 Subject: [PATCH 3/4] [AI] Add TradingMode and Evaluators --- .../ai_strategies_evaluator/__init__.py | 13 + .../agents/__init__.py | 30 + .../agents/base_agent.py | 102 +++ .../ai_strategies_evaluator/agents/factory.py | 56 ++ .../ai_strategies_evaluator/agents/models.py | 209 ++++++ .../agents/real_time_analysis_agent.py | 100 +++ .../agents/sentiment_analysis_agent.py | 102 +++ .../agents/summarization_agent.py | 134 ++++ .../agents/technical_analysis_agent.py | 100 +++ .../ai_strategies_evaluator/ai_strategies.py | 554 ++++++++++++++++ .../config/CryptoLLMAIStrategyEvaluator.json | 18 + .../config/GlobalLLMAIStrategyEvaluator.json | 14 + .../ai_strategies_evaluator/metadata.json | 9 + .../resources/LLMAIStrategyEvaluator.md | 106 +++ .../ai_strategies_evaluator/tests/__init__.py | 15 + .../tests/test_llm_ai_strategy_evaluator.py | 94 +++ .../Services_bases/gpt_service/__init__.py | 2 +- Services/Services_bases/gpt_service/gpt.py | 606 +++++++++++++----- Trading/Mode/ai_trading_mode/__init__.py | 18 + .../Mode/ai_trading_mode/agents/__init__.py | 27 + .../Mode/ai_trading_mode/agents/base_agent.py | 152 +++++ .../agents/distribution_agent.py | 236 +++++++ Trading/Mode/ai_trading_mode/agents/models.py | 240 +++++++ .../Mode/ai_trading_mode/agents/risk_agent.py | 205 ++++++ .../ai_trading_mode/agents/signal_agent.py | 242 +++++++ Trading/Mode/ai_trading_mode/agents/state.py | 97 +++ Trading/Mode/ai_trading_mode/agents/team.py | 310 +++++++++ .../ai_trading_mode/ai_index_distribution.py | 113 ++++ .../Mode/ai_trading_mode/ai_index_trading.py | 404 ++++++++++++ .../config/AIIndexTradingMode.json | 12 + Trading/Mode/ai_trading_mode/metadata.json | 6 + .../resources/AIIndexTradingMode.md | 152 +++++ .../Mode/ai_trading_mode/tests/__init__.py | 1 + .../tests/test_ai_index_trading_mode.py | 80 +++ .../config/AIIndexTradingMode.json | 7 + .../resources/AIIndexTradingMode.md | 219 +++++++ .../tests/test_ai_index_trading_mode.py | 197 ++++++ profiles/ai_trading/profile.json | 55 ++ .../specific_config/AIIndexTradingMode.json | 12 + .../CryptoLLMAIStrategyEvaluator.json | 18 + .../GlobalLLMAIStrategyEvaluator.json | 14 + profiles/ai_trading/tentacles_config.json | 25 + 42 files changed, 4930 insertions(+), 176 deletions(-) create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/__init__.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/__init__.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/base_agent.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/factory.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/models.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/real_time_analysis_agent.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/sentiment_analysis_agent.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/summarization_agent.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/technical_analysis_agent.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/ai_strategies.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/config/CryptoLLMAIStrategyEvaluator.json create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/config/GlobalLLMAIStrategyEvaluator.json create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/metadata.json create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/resources/LLMAIStrategyEvaluator.md create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/tests/__init__.py create mode 100644 Evaluator/Strategies/ai_strategies_evaluator/tests/test_llm_ai_strategy_evaluator.py create mode 100644 Trading/Mode/ai_trading_mode/__init__.py create mode 100644 Trading/Mode/ai_trading_mode/agents/__init__.py create mode 100644 Trading/Mode/ai_trading_mode/agents/base_agent.py create mode 100644 Trading/Mode/ai_trading_mode/agents/distribution_agent.py create mode 100644 Trading/Mode/ai_trading_mode/agents/models.py create mode 100644 Trading/Mode/ai_trading_mode/agents/risk_agent.py create mode 100644 Trading/Mode/ai_trading_mode/agents/signal_agent.py create mode 100644 Trading/Mode/ai_trading_mode/agents/state.py create mode 100644 Trading/Mode/ai_trading_mode/agents/team.py create mode 100644 Trading/Mode/ai_trading_mode/ai_index_distribution.py create mode 100644 Trading/Mode/ai_trading_mode/ai_index_trading.py create mode 100644 Trading/Mode/ai_trading_mode/config/AIIndexTradingMode.json create mode 100644 Trading/Mode/ai_trading_mode/metadata.json create mode 100644 Trading/Mode/ai_trading_mode/resources/AIIndexTradingMode.md create mode 100644 Trading/Mode/ai_trading_mode/tests/__init__.py create mode 100644 Trading/Mode/ai_trading_mode/tests/test_ai_index_trading_mode.py create mode 100644 Trading/Mode/index_trading_mode/config/AIIndexTradingMode.json create mode 100644 Trading/Mode/index_trading_mode/resources/AIIndexTradingMode.md create mode 100644 Trading/Mode/index_trading_mode/tests/test_ai_index_trading_mode.py create mode 100644 profiles/ai_trading/profile.json create mode 100644 profiles/ai_trading/specific_config/AIIndexTradingMode.json create mode 100644 profiles/ai_trading/specific_config/CryptoLLMAIStrategyEvaluator.json create mode 100644 profiles/ai_trading/specific_config/GlobalLLMAIStrategyEvaluator.json create mode 100644 profiles/ai_trading/tentacles_config.json diff --git a/Evaluator/Strategies/ai_strategies_evaluator/__init__.py b/Evaluator/Strategies/ai_strategies_evaluator/__init__.py new file mode 100644 index 000000000..a5d827e36 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/__init__.py @@ -0,0 +1,13 @@ +from .ai_strategies import ( + BaseLLMAIStrategyEvaluator, + CryptoLLMAIStrategyEvaluator, + GlobalLLMAIStrategyEvaluator +) +from .agents import ( + BaseAgent, + SummarizationAgent, + TechnicalAnalysisAgent, + SentimentAnalysisAgent, + RealTimeAnalysisAgent, + AgentFactory, +) diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/__init__.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/__init__.py new file mode 100644 index 000000000..05725ef8d --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/agents/__init__.py @@ -0,0 +1,30 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +from .base_agent import BaseAgent +from .summarization_agent import SummarizationAgent +from .technical_analysis_agent import TechnicalAnalysisAgent +from .sentiment_analysis_agent import SentimentAnalysisAgent +from .real_time_analysis_agent import RealTimeAnalysisAgent +from .factory import AgentFactory + +__all__ = [ + "BaseAgent", + "SummarizationAgent", + "TechnicalAnalysisAgent", + "SentimentAnalysisAgent", + "RealTimeAnalysisAgent", + "AgentFactory", +] diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/base_agent.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/base_agent.py new file mode 100644 index 000000000..b11003ced --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/agents/base_agent.py @@ -0,0 +1,102 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import typing +import json +import logging +from abc import ABC, abstractmethod + + +class BaseAgent(ABC): + """Abstract base class for LLM-powered agents with common configuration and functionality.""" + + DEFAULT_MODEL = None + DEFAULT_MAX_TOKENS = 10000 + DEFAULT_TEMPERATURE = 0.3 + MAX_RETRIES = 3 + + def __init__(self, name: str, model=None, max_tokens=None, temperature=None): + self.name = name + self.model = model or self.DEFAULT_MODEL + self.max_tokens = max_tokens or self.DEFAULT_MAX_TOKENS + self.temperature = temperature or self.DEFAULT_TEMPERATURE + self.evaluator_type = None # Optional: associated evaluator type + self._custom_prompt = None + self.logger = logging.getLogger(f"[Agent][{self.name}]") + + @property + def prompt(self) -> str: + """Get the agent's prompt, allowing override via config.""" + return self._custom_prompt or self._get_default_prompt() + + @prompt.setter + def prompt(self, value: str): + """Allow custom prompt override.""" + self._custom_prompt = value + + @abstractmethod + def _get_default_prompt(self) -> str: + """Return the default prompt for this agent type.""" + pass + + @abstractmethod + async def execute(self, input_data, llm_service) -> typing.Any: + """Execute the agent's primary function.""" + pass + + async def _call_llm(self, messages, llm_service, json_output=True, response_schema=None): + """ + Common LLM calling method with error handling and automatic retries. + + Args: + messages: List of message dicts with 'role' and 'content'. + llm_service: The LLM service instance. + json_output: Whether to parse response as JSON. + response_schema: Optional Pydantic model or JSON schema for structured output. + + Returns: + Parsed JSON dict or raw string response. + + Raises: + RuntimeError: If all retries are exhausted. + """ + last_exception = None + + for attempt in range(1, self.MAX_RETRIES + 1): + try: + response = await llm_service.get_completion( + messages=messages, + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + json_output=json_output, + response_schema=response_schema, + ) + if json_output: + return json.loads(response.strip()) + return response.strip() + except (json.JSONDecodeError, ValueError, KeyError, AttributeError) as e: + last_exception = e + if attempt < self.MAX_RETRIES: + self.logger.warning( + f"LLM call failed on attempt {attempt}/{self.MAX_RETRIES} for agent {self.name}: {str(e)}. Retrying..." + ) + else: + self.logger.error( + f"LLM call failed on final attempt {attempt}/{self.MAX_RETRIES} for agent {self.name}: {str(e)}" + ) + + # All retries exhausted + raise RuntimeError(f"LLM call failed for agent {self.name} after {self.MAX_RETRIES} retries: {str(last_exception)}") diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/factory.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/factory.py new file mode 100644 index 000000000..34edda666 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/agents/factory.py @@ -0,0 +1,56 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import octobot_evaluators.enums as evaluators_enums +from .base_agent import BaseAgent +from .summarization_agent import SummarizationAgent +from .technical_analysis_agent import TechnicalAnalysisAgent +from .sentiment_analysis_agent import SentimentAnalysisAgent +from .real_time_analysis_agent import RealTimeAnalysisAgent + + +class AgentFactory: + """Factory for creating LLM agents.""" + + @staticmethod + def create_summarization_agent( + synthesis_method="weighted", **config + ) -> SummarizationAgent: + return SummarizationAgent(synthesis_method=synthesis_method, **config) + + @staticmethod + def create_technical_agent(**config) -> TechnicalAnalysisAgent: + return TechnicalAnalysisAgent(**config) + + @staticmethod + def create_sentiment_agent(**config) -> SentimentAnalysisAgent: + return SentimentAnalysisAgent(**config) + + @staticmethod + def create_realtime_agent(**config) -> RealTimeAnalysisAgent: + return RealTimeAnalysisAgent(**config) + + @staticmethod + def create_agent_for_evaluator_type(evaluator_type: str, **config) -> BaseAgent: + """Create appropriate agent for evaluator type.""" + type_to_agent = { + evaluators_enums.EvaluatorMatrixTypes.TA.value: TechnicalAnalysisAgent, + evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value: SentimentAnalysisAgent, + evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value: RealTimeAnalysisAgent, + } + agent_class = type_to_agent.get(evaluator_type) + if agent_class: + return agent_class(**config) + raise ValueError(f"No agent available for evaluator type: {evaluator_type}") diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/models.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/models.py new file mode 100644 index 000000000..13eaaec8e --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/agents/models.py @@ -0,0 +1,209 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Pydantic models for AI strategy evaluator agent outputs. +""" +from typing import Dict, List, Optional +from pydantic import BaseModel, Field, field_validator + + +class TechnicalAnalysisOutput(BaseModel): + """Output from the technical analysis agent.""" + eval_note: float = Field( + ge=-1.0, + le=1.0, + description="Evaluation score from -1 (strong sell) to 1 (strong buy)." + ) + confidence: float = Field( + ge=0.0, + le=1.0, + description="Confidence level of the analysis (0-1)." + ) + description: str = Field( + description="Summary description of the technical analysis." + ) + trend: Optional[str] = Field( + default=None, + description="Current market trend: 'uptrend', 'downtrend', 'sideways'. Leave empty if unclear." + ) + support_level: Optional[float] = Field( + default=None, + description="Identified support level price. Leave empty if no clear support identified." + ) + resistance_level: Optional[float] = Field( + default=None, + description="Identified resistance level price. Leave empty if no clear resistance identified." + ) + key_indicators: Optional[List[str]] = Field( + default=None, + description="Key technical indicators analyzed. Leave empty if no relevant indicators." + ) + recommendations: Optional[List[str]] = Field( + default=None, + description="Trading recommendations based on technical analysis. Leave empty if no specific recommendations." + ) + + @field_validator("trend") + def validate_trend(cls, v: str) -> str: + if v is None: + return None + allowed_trends = ["uptrend", "downtrend", "sideways"] + v_lower = v.lower() + if v_lower not in allowed_trends: + raise ValueError(f"Trend must be one of {allowed_trends}") + return v_lower + + +class SentimentMetrics(BaseModel): + """Sentiment analysis metrics.""" + sentiment_score: float = Field( + ge=-1.0, + le=1.0, + description="Sentiment score from -1 (very negative) to 1 (very positive)." + ) + + @field_validator("sentiment_score") + def validate_score(cls, v: float) -> float: + return max(-1.0, min(1.0, v)) + + +class SentimentAnalysisOutput(BaseModel): + """Output from the sentiment analysis agent.""" + eval_note: float = Field( + ge=-1.0, + le=1.0, + description="Evaluation score from -1 (very negative) to 1 (very positive)." + ) + confidence: float = Field( + ge=0.0, + le=1.0, + description="Confidence level of the sentiment analysis (0-1)." + ) + description: str = Field( + description="Summary description of the sentiment analysis." + ) + sentiment_score: float = Field( + ge=-1.0, + le=1.0, + description="Detailed sentiment score from -1 to 1. Same as eval_note." + ) + sources_analyzed: Optional[List[str]] = Field( + default=None, + description="Data sources used for sentiment analysis. Leave empty if none identified." + ) + key_mentions: Optional[List[str]] = Field( + default=None, + description="Key mentions or topics driving sentiment. Leave empty if none." + ) + market_implications: Optional[str] = Field( + default=None, + description="Implications of sentiment for market direction. Leave empty if unclear." + ) + recommendations: Optional[List[str]] = Field( + default=None, + description="Trading recommendations based on sentiment. Leave empty if none." + ) + + +class RealTimeAnalysisOutput(BaseModel): + """Output from the real-time analysis agent.""" + eval_note: float = Field( + ge=-1.0, + le=1.0, + description="Evaluation score from -1 (strong selling pressure) to 1 (strong buying pressure)." + ) + confidence: float = Field( + ge=0.0, + le=1.0, + description="Confidence level of the real-time analysis (0-1)." + ) + description: str = Field( + description="Summary description of the real-time market analysis." + ) + price_momentum: Optional[float] = Field( + default=None, + ge=-1.0, + le=1.0, + description="Price momentum indicator from -1 (strong downward) to 1 (strong upward). Leave empty if no momentum." + ) + current_status: Optional[str] = Field( + default=None, + description="Current market status: 'bullish', 'bearish', 'neutral', 'volatile'. Leave empty if unclear." + ) + volume_signal: Optional[str] = Field( + default=None, + description="Volume analysis: 'high', 'normal', 'low'. Leave empty if no volume data." + ) + urgency_level: Optional[str] = Field( + default=None, + description="Action urgency: 'immediate', 'high', 'medium', 'low', 'none'. Leave empty if no urgency." + ) + critical_events: Optional[List[str]] = Field( + default=None, + description="Any critical events or catalysts detected. Leave empty if none." + ) + recommendations: Optional[List[str]] = Field( + default=None, + description="Real-time trading recommendations. Leave empty if none." + ) + + @field_validator("current_status") + def validate_status(cls, v: str) -> str: + if v is None: + return None + allowed_statuses = ["bullish", "bearish", "neutral", "volatile"] + v_lower = v.lower() + if v_lower not in allowed_statuses: + raise ValueError(f"Status must be one of {allowed_statuses}") + return v_lower + + @field_validator("volume_signal") + def validate_volume(cls, v: str) -> str: + if v is None: + return None + allowed_volumes = ["high", "normal", "low"] + v_lower = v.lower() + if v_lower not in allowed_volumes: + raise ValueError(f"Volume signal must be one of {allowed_volumes}") + return v_lower + + @field_validator("urgency_level") + def validate_urgency(cls, v: str) -> str: + if v is None: + return None + allowed_urgencies = ["immediate", "high", "medium", "low", "none"] + v_lower = v.lower() + if v_lower not in allowed_urgencies: + raise ValueError(f"Urgency level must be one of {allowed_urgencies}") + return v_lower + + +class SummarizationOutput(BaseModel): + """Output from the summarization agent.""" + eval_note: float = Field( + ge=-1.0, + le=1.0, + description="Final evaluation score from -1 (strong sell) to 1 (strong buy)." + ) + confidence: float = Field( + ge=0.0, + le=1.0, + description="Confidence level of the final analysis (0-1)." + ) + description: str = Field( + description="Comprehensive summary of market analysis including key consensus points, overall outlook (bullish/bearish/mixed), key recommendations, and identified risks." + ) diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/real_time_analysis_agent.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/real_time_analysis_agent.py new file mode 100644 index 000000000..9b26ad8f2 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/agents/real_time_analysis_agent.py @@ -0,0 +1,100 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import json +from .base_agent import BaseAgent +from .models import RealTimeAnalysisOutput + + +class RealTimeAnalysisAgent(BaseAgent): + """Agent specialized in real-time market analysis.""" + + def __init__(self, **kwargs): + super().__init__("real_time_analysis", **kwargs) + + def _get_default_prompt(self) -> str: + return ( + "You are a Real-Time Market Analysis AI expert. Follow these steps to analyze the provided real-time evaluator signals:\n" + "1. Examine real-time data comprehensively: Review order book dynamics, recent trades, price velocity, and market depth.\n" + "2. Assess market momentum: Determine current buying/selling pressure, accumulation/distribution patterns, and directional bias.\n" + "3. Evaluate market microstructure: Consider bid-ask spreads, order book imbalance, and trade flow characteristics.\n" + "4. Calculate momentum eval_note: Use the full range from -1 (strong selling pressure) to 1 (strong buying pressure), but most real-time data shows neutral to mild momentum.\n" + "5. Assess confidence (0-1) based on data quality: Higher confidence for strong, consistent signals; lower for noisy or conflicting data.\n" + "6. Provide detailed description: Explain current market dynamics, momentum indicators, and short-term outlook.\n\n" + "Important: Real-time momentum is rarely extreme. Use extreme values (-1/1) only for very strong, sustained pressure.\n\n" + "MANDATORY FIELDS (always include):\n" + "- eval_note: float between -1 (strong selling pressure) to 1 (strong buying pressure)\n" + "- confidence: float between 0 (low confidence) to 1 (high confidence)\n" + "- description: detailed explanation of current market momentum and dynamics\n\n" + "OPTIONAL FIELDS (only include if available):\n" + "- price_momentum: float for price momentum strength (-1 to 1) - Leave empty if not clearly identifiable\n" + "- current_status: string like 'accumulating', 'distributing', 'consolidating' - Leave empty if unclear\n" + "- volume_signal: string describing volume patterns - Leave empty if no strong volume signals\n" + "- urgency_level: string like 'low', 'medium', 'high' - Leave empty if no clear urgency\n" + "- critical_events: list of critical market events affecting momentum - Leave empty if none\n" + "- recommendations: list of action recommendations based on real-time analysis - Leave empty if none\n\n" + "If you lack data for any optional field, omit it from the response (leave as null).\n" + "Output only valid JSON matching the RealTimeAnalysisOutput schema." + ) + + async def execute(self, input_data, llm_service) -> dict: + """Evaluate aggregated real-time market data.""" + aggregated_data = input_data + if not aggregated_data: + return { + "eval_note": 0, + "eval_note_description": "No real-time market data available", + "confidence": 0, + } + + data_str = json.dumps(aggregated_data, indent=2) + + messages = [ + llm_service.create_message("system", self.prompt), + llm_service.create_message( + "user", + f"Real-time market data:\n{data_str}\n\n" + "Provide evaluation as JSON matching the RealTimeAnalysisOutput schema. " + "Include mandatory fields (eval_note, confidence, description). " + "Include optional fields only if you have data for them.", + ), + ] + + try: + parsed = await self._call_llm( + messages, + llm_service, + json_output=True, + response_schema=RealTimeAnalysisOutput, + ) + eval_note = float(parsed.get("eval_note", 0)) + eval_note_description = parsed.get("description", "Real-time analysis") + confidence = float(parsed.get("confidence", 0)) + + # Clamp values + eval_note = max(-1, min(1, eval_note)) + confidence = max(0, min(1, confidence)) + + return { + "eval_note": eval_note, + "eval_note_description": eval_note_description, + "confidence": int(confidence * 100), # Convert to 0-100 range + } + except RuntimeError as e: + return { + "eval_note": 0, + "eval_note_description": f"Error in real-time analysis: {str(e)}", + "confidence": 0, + } diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/sentiment_analysis_agent.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/sentiment_analysis_agent.py new file mode 100644 index 000000000..d1b3d5f81 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/agents/sentiment_analysis_agent.py @@ -0,0 +1,102 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import json +from .base_agent import BaseAgent +from .models import SentimentAnalysisOutput + + +class SentimentAnalysisAgent(BaseAgent): + """Agent specialized in sentiment analysis evaluation.""" + + def __init__(self, **kwargs): + super().__init__("sentiment_analysis", **kwargs) + + def _get_default_prompt(self) -> str: + return ( + "You are a Social Sentiment Analysis AI expert.\n\n" + "Follow these steps:\n" + "1. Review diverse social signals: news sentiment, market buzz, community discussions, global indicators\n" + "2. Assess overall market mood objectively: Determine if sentiment is bullish, bearish, or neutral\n" + "3. Consider signal sources: Institutional news (ETFs, regulations) outweighs social media noise\n" + "4. Evaluate sentiment strength and consistency across sources\n" + "5. Calculate eval_note using full range from -1 to 1: Most markets show neutral to mildly bullish/bearish sentiment\n" + "6. Assess confidence (0-1) based on data quality and signal clarity\n" + "7. Provide detailed analysis explaining the score\n\n" + "IMPORTANT: Positive regulatory developments are MAJOR bullish signals. Institutional news outweighs social media sentiment.\n" + "Markets are rarely extremely bullish or bearish - use extreme values (-1/1) only for very strong, consistent signals.\n\n" + "MANDATORY FIELDS (always include):\n" + "- eval_note: float between -1 (very bearish sentiment) to 1 (very bullish sentiment)\n" + "- confidence: float between 0 (low confidence) to 1 (high confidence)\n" + "- description: detailed explanation of the sentiment analysis\n" + "- sentiment_score: float between -1 to 1 for aggregate sentiment\n\n" + "OPTIONAL FIELDS (only include if available):\n" + "- sources_analyzed: list of sentiment sources examined (e.g., 'Twitter', 'News', 'Crypto Forums') - Leave empty if not clearly identified\n" + "- key_mentions: list of key topics/assets/events mentioned - Leave empty if none stand out\n" + "- market_implications: string describing sentiment impact on market - Leave empty if unclear\n" + "- recommendations: list of action recommendations based on sentiment - Leave empty if none\n\n" + "If you lack data for any optional field, omit it from the response (leave as null).\n" + "Output only valid JSON matching the SentimentAnalysisOutput schema." + ) + + async def execute(self, input_data, llm_service) -> dict: + """Evaluate aggregated sentiment analysis data.""" + aggregated_data = input_data + if not aggregated_data: + return { + "eval_note": 0, + "eval_note_description": "No sentiment analysis data available", + "confidence": 0, + } + + data_str = json.dumps(aggregated_data, indent=2) + + messages = [ + llm_service.create_message("system", self.prompt), + llm_service.create_message( + "user", + f"Sentiment analysis data:\n{data_str}\n\n" + "Provide evaluation as JSON matching the SentimentAnalysisOutput schema. " + "Include mandatory fields (eval_note, confidence, description, sentiment_score). " + "Include optional fields only if you have data for them.", + ), + ] + + try: + parsed = await self._call_llm( + messages, + llm_service, + json_output=True, + response_schema=SentimentAnalysisOutput, + ) + eval_note = float(parsed.get("eval_note", 0)) + eval_note_description = parsed.get("description", "Sentiment analysis") + confidence = float(parsed.get("confidence", 0)) + + # Clamp values + eval_note = max(-1, min(1, eval_note)) + confidence = max(0, min(1, confidence)) + + return { + "eval_note": eval_note, + "eval_note_description": eval_note_description, + "confidence": int(confidence * 100), # Convert to 0-100 range + } + except RuntimeError as e: + return { + "eval_note": 0, + "eval_note_description": f"Error in sentiment analysis: {str(e)}", + "confidence": 0, + } diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/summarization_agent.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/summarization_agent.py new file mode 100644 index 000000000..bd23014ad --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/agents/summarization_agent.py @@ -0,0 +1,134 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import json +from .base_agent import BaseAgent +from .models import SummarizationOutput + + +class SummarizationAgent(BaseAgent): + """Agent specialized in combining multiple evaluations into a final recommendation.""" + + def __init__(self, synthesis_method="weighted", **kwargs): + super().__init__("summarization", **kwargs) + self.synthesis_method = synthesis_method + + def _get_default_prompt(self) -> str: + return ( + "You are a Market Analysis Synthesis AI expert. Your task is to combine multiple specialized AI agent evaluations " + "into a single, coherent, and actionable trading recommendation.\n\n" + "Follow these steps:\n" + "1. Review all agent evaluations comprehensively: technical, sentiment, and real-time analysis\n" + "2. Assess overall market direction: Determine if signals indicate strong bullish, bearish, neutral, or mixed conditions\n" + "3. Consider different perspectives: Weight technical signals, social sentiment, and real-time momentum appropriately\n" + "4. Evaluate signal convergence/divergence: Look for confirmation across agents vs. conflicting signals\n" + "5. Calculate balanced final eval_note: Use the full range from -1 (strong sell) to 1 (strong buy), but most syntheses result in neutral to mildly bullish/bearish recommendations\n" + "6. Consider confidence levels (0-1): Higher confidence for consistent signals; lower for conflicting or weak data\n" + "7. Provide detailed reasoning in description: Explain key consensus points, overall outlook, recommendations, and identified risks\n\n" + "Important: Markets are rarely extremely bullish or bearish. Use extreme values (-1/1) only for very strong, consistent signals across all agents.\n\n" + "MANDATORY FIELDS:\n" + "- eval_note: final score from -1 (strong sell) to 1 (strong buy)\n" + "- confidence: confidence level (0-1)\n" + "- description: comprehensive summary including key points, outlook (bullish/bearish/mixed), recommendations, and risks\n\n" + "Output only valid JSON matching the SummarizationOutput schema with these three fields." + ) + + async def execute(self, input_data, llm_service, context_info=None) -> tuple: + """Combine multiple agent results into final evaluation.""" + agent_results = input_data + if not agent_results: + return 0, "No agent results available" + + # Filter out empty/error results + valid_results = [r for r in agent_results if r.get("eval_note") is not None] + + if not valid_results: + return 0, "All agent evaluations failed" + + # If only one result, use it directly + if len(valid_results) == 1: + result = valid_results[0] + return result["eval_note"], result["eval_note_description"] + + # Prepare summarization data + summary_data = {} + for i, result in enumerate(valid_results): + summary_data[f"agent_{i}"] = { + "eval_note": result.get("eval_note", 0), + "description": result.get("eval_note_description", ""), + "confidence": result.get("confidence", 0), + } + + # Add context about data completeness + context_str = "" + if context_info: + missing = context_info.get("missing_data_types", []) + available = context_info.get("available_data_types", []) + total = context_info.get("total_expected_types", []) + if missing: + context_str = f"\n\nNote: Analysis is based on incomplete data. Missing evaluator types: {missing}. Available: {available}. Expected total: {total}." + + messages = [ + llm_service.create_message("system", self.prompt), + llm_service.create_message( + "user", + f"Agent evaluations to synthesize:\n{json.dumps(summary_data, indent=2)}{context_str}\n\n" + "Provide final evaluation as JSON matching the SummarizationOutput schema with three fields: eval_note, confidence, and description.", + ), + ] + + try: + parsed = await self._call_llm( + messages, + llm_service, + json_output=True, + response_schema=SummarizationOutput, + ) + final_eval_note = float(parsed.get("eval_note", 0)) + final_eval_note_description = parsed.get("description", "AI synthesis") + confidence = float(parsed.get("confidence", 0)) + + # Clamp eval_note + final_eval_note = max(-1, min(1, final_eval_note)) + + # Include confidence in description + final_eval_note_description = f"{final_eval_note_description} (Confidence: {confidence:.1%})" + + return final_eval_note, final_eval_note_description + except RuntimeError: + # Fallback: weighted average of agent results + total_weight = 0 + weighted_sum = 0 + descriptions = [] + + for result in valid_results: + confidence = result.get("confidence", 50) / 100.0 # Normalize to 0-1 + eval_note = result.get("eval_note", 0) + weighted_sum += eval_note * confidence + total_weight += confidence + descriptions.append(result.get("eval_note_description", "")) + + if total_weight > 0: + final_eval_note = weighted_sum / total_weight + else: + final_eval_note = sum( + r.get("eval_note", 0) for r in valid_results + ) / len(valid_results) + + final_eval_note_description = ( + " | ".join(descriptions) if descriptions else "Fallback synthesis" + ) + + return max(-1, min(1, final_eval_note)), final_eval_note_description diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/technical_analysis_agent.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/technical_analysis_agent.py new file mode 100644 index 000000000..821a4aac2 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/agents/technical_analysis_agent.py @@ -0,0 +1,100 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import json +from .base_agent import BaseAgent +from .models import TechnicalAnalysisOutput + + +class TechnicalAnalysisAgent(BaseAgent): + """Agent specialized in technical analysis evaluation.""" + + def __init__(self, **kwargs): + super().__init__("technical_analysis", **kwargs) + + def _get_default_prompt(self) -> str: + return ( + "You are a Technical Analysis AI expert. Follow these steps to analyze the provided technical evaluator signals:\n" + "1. Examine TA signals comprehensively: Review RSI, MACD, moving averages, Bollinger Bands, volume patterns, and price action.\n" + "2. Assess trend strength and direction: Determine if signals indicate strong bullish, bearish, neutral, or mixed conditions.\n" + "3. Consider timeframe context: Different timeframes may show different trends - longer timeframes are generally more significant.\n" + "4. Evaluate indicator convergence/divergence: Look for confirmation across multiple indicators vs. conflicting signals.\n" + "5. Calculate balanced eval_note: Use the full range from -1 (strong sell) to 1 (strong buy), but most markets show neutral to mildly bullish/bearish signals.\n" + "6. Assess confidence realistically: Base confidence on signal strength, agreement (0-1 range), and data quality.\n" + "7. Provide detailed description: Explain key indicators, their significance, and potential market implications.\n\n" + "MANDATORY FIELDS (always include):\n" + "- eval_note: float between -1 (strong sell) to 1 (strong buy)\n" + "- confidence: float between 0 (low confidence) to 1 (high confidence)\n" + "- description: detailed explanation of the analysis\n\n" + "OPTIONAL FIELDS (only include if available):\n" + "- trend: string like 'uptrend', 'downtrend', 'ranging' - Leave empty if unclear\n" + "- support_level: float for identified support price level - Leave empty if not identified\n" + "- resistance_level: float for identified resistance price level - Leave empty if not identified\n" + "- key_indicators: list of important technical indicators and their signals - Leave empty if none clearly identified\n" + "- recommendations: list of trading recommendations - Leave empty if none\n\n" + "Important: Markets are rarely extremely bullish or bearish. Use extreme values (-1/1) only for very strong, consistent signals across multiple timeframes. Avoid bias toward negative signals.\n" + "If you lack data for any optional field, omit it from the response (leave as null).\n" + "Output only valid JSON matching the TechnicalAnalysisOutput schema." + ) + + async def execute(self, input_data, llm_service) -> dict: + """Evaluate aggregated technical analysis data.""" + aggregated_data = input_data + if not aggregated_data: + return { + "eval_note": 0, + "eval_note_description": "No technical analysis data available", + "confidence": 0, + } + + data_str = json.dumps(aggregated_data, indent=2) + + messages = [ + llm_service.create_message("system", self.prompt), + llm_service.create_message( + "user", + f"Technical analysis data:\n{data_str}\n\n" + "Provide evaluation as JSON matching the TechnicalAnalysisOutput schema. " + "Include mandatory fields (eval_note, confidence, description). " + "Include optional fields only if you have data for them.", + ), + ] + + try: + parsed = await self._call_llm( + messages, + llm_service, + json_output=True, + response_schema=TechnicalAnalysisOutput, + ) + eval_note = float(parsed.get("eval_note", 0)) + eval_note_description = parsed.get("description", "Technical analysis") + confidence = float(parsed.get("confidence", 0)) + + # Clamp values + eval_note = max(-1, min(1, eval_note)) + confidence = max(0, min(1, confidence)) + + return { + "eval_note": eval_note, + "eval_note_description": eval_note_description, + "confidence": int(confidence * 100), # Convert to 0-100 range + } + except RuntimeError as e: + return { + "eval_note": 0, + "eval_note_description": f"Error in technical analysis: {str(e)}", + "confidence": 0, + } diff --git a/Evaluator/Strategies/ai_strategies_evaluator/ai_strategies.py b/Evaluator/Strategies/ai_strategies_evaluator/ai_strategies.py new file mode 100644 index 000000000..452efdf20 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/ai_strategies.py @@ -0,0 +1,554 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import typing +import asyncio + +import octobot_commons.constants as common_constants +import octobot_commons.enums as commons_enums +import octobot_commons.evaluators_util as evaluators_util +import octobot_evaluators.matrix as matrix +import octobot_evaluators.enums as evaluators_enums +import octobot_evaluators.constants as evaluators_constants +import octobot_evaluators.evaluators as evaluators +import octobot_services.api.services as services_api +import tentacles.Services.Services_bases + +from .agents import AgentFactory + + +class BaseLLMAIStrategyEvaluator(evaluators.StrategyEvaluator): + """ + Base class for LLM-powered AI Strategy Evaluators. + Contains shared configuration and agent execution logic. + """ + + PROMPT_KEY = "prompt" + MODEL_KEY = "model" + MAX_TOKENS_KEY = "max_tokens" + TEMPERATURE_KEY = "temperature" + OUTPUT_FORMAT_KEY = "output_format" + EVALUATOR_TYPES_KEY = "evaluator_types" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.services_config = None + self.model = None + self.prompt = None + self.max_tokens = None + self.temperature = None + self.output_format = "with_confidence" + self.evaluator_types = [ + evaluators_enums.EvaluatorMatrixTypes.TA.value, + evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value, + evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value, + ] + + def init_user_inputs(self, inputs: dict) -> None: + super().init_user_inputs(inputs) + default_config = self.get_default_config() + self.prompt = self.UI.user_input( + self.PROMPT_KEY, + commons_enums.UserInputTypes.TEXT, + default_config[self.PROMPT_KEY], + inputs, + title="Custom prompt for LLM analysis. Leave empty to use default.", + ) + self.model = self.UI.user_input( + self.MODEL_KEY, + commons_enums.UserInputTypes.TEXT, + default_config[self.MODEL_KEY], + inputs, + title="LLM model to use for analysis.", + ) + self.max_tokens = self.UI.user_input( + self.MAX_TOKENS_KEY, + commons_enums.UserInputTypes.INT, + default_config[self.MAX_TOKENS_KEY], + inputs, + min_val=100, + max_val=10000, + title="Maximum tokens for LLM response.", + ) + self.temperature = self.UI.user_input( + self.TEMPERATURE_KEY, + commons_enums.UserInputTypes.FLOAT, + default_config[self.TEMPERATURE_KEY], + inputs, + min_val=0.0, + max_val=1.0, + title="Temperature for LLM randomness (0.0 = deterministic, 1.0 = very random).", + ) + self.evaluator_types = self.UI.user_input( + self.EVALUATOR_TYPES_KEY, + commons_enums.UserInputTypes.MULTIPLE_OPTIONS, + default_config[self.EVALUATOR_TYPES_KEY], + inputs, + options=[ + evaluators_enums.EvaluatorMatrixTypes.TA.value, + evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value, + evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value, + ], + title="Evaluator types to include in analysis.", + ) + self.output_format = self.UI.user_input( + self.OUTPUT_FORMAT_KEY, + commons_enums.UserInputTypes.OPTIONS, + default_config[self.OUTPUT_FORMAT_KEY], + inputs, + options=["standard", "with_confidence"], + title="Output format: standard (eval_note and description) or with_confidence (includes confidence level).", + ) + + @classmethod + def get_default_config(cls, time_frames: typing.Optional[list[str]] = None) -> dict: + return { + cls.PROMPT_KEY: "", + cls.MODEL_KEY: None, + cls.MAX_TOKENS_KEY: None, + cls.TEMPERATURE_KEY: None, + cls.EVALUATOR_TYPES_KEY: [ + evaluators_enums.EvaluatorMatrixTypes.TA.value, + evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value, + evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value, + ], + cls.OUTPUT_FORMAT_KEY: "standard", + } + + def get_full_cycle_evaluator_types(self) -> tuple: + # returns a tuple as it is faster to create than a list + return tuple(self.evaluator_types) + + async def _get_llm_service(self): + """Get the LLM service instance.""" + try: + gpt_service_class = tentacles.Services.Services_bases.LLMService + except (AttributeError, ImportError): + self.logger.error("LLMService not available, cannot perform LLM analysis") + return None + + gpt_service = await services_api.get_service( + gpt_service_class, self._is_in_backtesting(), self.services_config + ) + if not gpt_service: + self.logger.error("LLMService not available, cannot perform LLM analysis") + return None + return gpt_service + + async def _run_agents_analysis( + self, + aggregated_data: dict, + missing_data_types: list, + gpt_service, + ) -> tuple[float, str]: + """ + Run strategy agents on aggregated data and return eval_note and description. + """ + # Create strategy agents for each evaluator type + strategy_agents = [] + for eval_type in self.evaluator_types: + if eval_type in aggregated_data: + agent = AgentFactory.create_agent_for_evaluator_type( + eval_type, + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + ) + # Store the evaluator type on the agent for data access + agent.evaluator_type = eval_type + strategy_agents.append(agent) + + if not strategy_agents: + self.logger.error("No valid strategy agents could be created") + return 0, "Error: No valid strategy agents" + + # Run agents in parallel + agent_results = await asyncio.gather( + *[ + agent.execute(aggregated_data[agent.evaluator_type], gpt_service) + for agent in strategy_agents + ], + return_exceptions=True, + ) + + # Filter out exceptions and get valid results + valid_results = [] + for i, result in enumerate(agent_results): + if isinstance(result, Exception): + self.logger.error(f"Strategy agent {i} failed: {result}") + else: + valid_results.append(result) + + if not valid_results: + self.logger.error("All strategy agents failed") + return 0, "Error: All strategy agents failed" + + # Use summarization agent to combine results + summarization_agent = AgentFactory.create_summarization_agent( + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + ) + # Pass information about missing data types to help with more appropriate analysis + context_info = { + "missing_data_types": missing_data_types, + "available_data_types": list(aggregated_data.keys()), + "total_expected_types": self.evaluator_types, + } + eval_note, eval_note_description = await summarization_agent.execute( + valid_results, gpt_service, context_info + ) + + # Apply output format if needed + if self.output_format == "with_confidence" and valid_results: + # Use average confidence from agents + avg_confidence = sum(r.get("confidence", 0) for r in valid_results) / len( + valid_results + ) + eval_note_description += f" (Confidence: {avg_confidence:.0f}%)" + + return eval_note, eval_note_description + + +class CryptoLLMAIStrategyEvaluator(BaseLLMAIStrategyEvaluator): + """ + LLM AI Strategy Evaluator for cryptocurrency-specific evaluations. + Evaluates individual cryptocurrencies (symbol=wildcard, cryptocurrency is specific). + Matrix evaluations are published with cryptocurrency set (not None). + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._pending_evaluations = {} # {cryptocurrency: {(symbol, timeframe): data}} + self._expected_symbols_timeframes = {} # {cryptocurrency: set of (symbol, timeframe)} + self._completed_cryptocurrencies = set() # Track which cryptocurrencies have been evaluated + + async def matrix_callback( + self, + matrix_id, + evaluator_name, + evaluator_type, + eval_note, + eval_note_type, + eval_note_description, + eval_note_metadata, + exchange_name, + cryptocurrency, + symbol, + time_frame, + **kwargs, + ): + if evaluator_type not in self.evaluator_types: + return + + # Skip global evaluations (cryptocurrency=None) - those are for GlobalLLMAIStrategyEvaluator + if cryptocurrency is None: + return + + # Check if we have already completed evaluation for this cryptocurrency + if cryptocurrency in self._completed_cryptocurrencies: + return + + # Initialize pending evaluations for this cryptocurrency if needed + if cryptocurrency not in self._pending_evaluations: + self._pending_evaluations[cryptocurrency] = {} + self._expected_symbols_timeframes[cryptocurrency] = set() + + # Track this symbol/timeframe combination + eval_key = (symbol, time_frame) + self._expected_symbols_timeframes[cryptocurrency].add(eval_key) + + # Check if we have sufficient data for this symbol/timeframe + available_eval_types = [] + for eval_type in self.evaluator_types: + if self._are_every_evaluation_valid_and_up_to_date( + matrix_id, + evaluator_name, + eval_type, + exchange_name, + cryptocurrency, + symbol, + time_frame, + ): + available_eval_types.append(eval_type) + + # Require at least one evaluator type to have data + if not available_eval_types: + return + + # Store this evaluation data + self._pending_evaluations[cryptocurrency][eval_key] = { + 'matrix_id': matrix_id, + 'exchange_name': exchange_name, + 'symbol': symbol, + 'time_frame': time_frame, + 'available_eval_types': available_eval_types, + } + + # Check if we have data for all expected symbols/timeframes + # Evaluate when we have at least one complete set + if len(self._pending_evaluations[cryptocurrency]) < 1: + return + + # Trigger evaluation + await self._evaluate_for_cryptocurrency( + matrix_id=matrix_id, + exchange_name=exchange_name, + cryptocurrency=cryptocurrency, + ) + + async def _evaluate_for_cryptocurrency( + self, + matrix_id: str, + exchange_name: str, + cryptocurrency: str, + ): + """ + Perform evaluation for a specific cryptocurrency. + """ + # Aggregate data by evaluator type across all symbols and timeframes + aggregated_data = {} + missing_data_types = [] + all_eval_types = set() + + # Collect all available eval types from all pending evaluations + for eval_data in self._pending_evaluations[cryptocurrency].values(): + all_eval_types.update(eval_data['available_eval_types']) + + for eval_type in self.evaluator_types: + if eval_type in all_eval_types: + all_evaluations = {} + + # Collect evaluations from all symbols and timeframes + for eval_key, eval_data in self._pending_evaluations[cryptocurrency].items(): + if eval_type not in eval_data['available_eval_types']: + continue + + symbol_tf = eval_key[0] # symbol + time_frame_tf = eval_key[1] # timeframe + matrix_id_tf = eval_data['matrix_id'] + exchange_name_tf = eval_data['exchange_name'] + + if eval_type == evaluators_enums.EvaluatorMatrixTypes.TA.value: + # TA evaluators need time_frame parameter + evaluations = matrix.get_evaluations_by_evaluator( + matrix_id_tf, + exchange_name_tf, + eval_type, + cryptocurrency, + symbol_tf, + time_frame_tf, + ) + elif eval_type == evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value: + # Social evaluators - get those for the same cryptocurrency and symbol + evaluations = matrix.get_evaluations_by_evaluator( + matrix_id_tf, exchange_name_tf, eval_type, cryptocurrency, symbol_tf + ) + # Also get social evaluators by cryptocurrency only + evaluations.update( + matrix.get_evaluations_by_evaluator( + matrix_id_tf, exchange_name_tf, eval_type, cryptocurrency + ) + ) + elif eval_type == evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value: + # Real-time evaluators need time_frame parameter + evaluations = matrix.get_evaluations_by_evaluator( + matrix_id_tf, + exchange_name_tf, + eval_type, + cryptocurrency, + symbol_tf, + time_frame_tf, + ) + else: + # Fallback for any other evaluator types + evaluations = matrix.get_evaluations_by_evaluator( + matrix_id_tf, + exchange_name_tf, + eval_type, + cryptocurrency, + symbol_tf, + time_frame_tf, + ) + + all_evaluations.update(evaluations) + + valid_evaluations = [ + { + "eval_note": ev.node_value, + "eval_note_description": ev.node_description or "", + } + for ev in all_evaluations.values() + if evaluators_util.check_valid_eval_note(ev.node_value) + ] + if valid_evaluations: + aggregated_data[eval_type] = valid_evaluations + else: + missing_data_types.append(eval_type) + else: + missing_data_types.append(eval_type) + + if not aggregated_data: + return + + gpt_service = await self._get_llm_service() + if not gpt_service: + self.eval_note = 0 + final_eval_note_description = "Error: LLMService not available" + self._completed_cryptocurrencies.add(cryptocurrency) + await self.evaluation_completed( + cryptocurrency=cryptocurrency, + symbol=None, + time_frame=None, + eval_note=self.eval_note, + eval_note_description=final_eval_note_description, + eval_time=0, + notify=True, + origin_consumer=self.consumer_instance, + ) + return + + self.eval_note, final_eval_note_description = await self._run_agents_analysis( + aggregated_data, missing_data_types, gpt_service + ) + + # Mark this cryptocurrency as completed + self._completed_cryptocurrencies.add(cryptocurrency) + + # Publish evaluation on cryptocurrency level (no symbol, no timeframe) + await self.evaluation_completed( + cryptocurrency=cryptocurrency, + symbol=None, + time_frame=None, + eval_note=self.eval_note, + eval_note_description=final_eval_note_description, + eval_time=0, + notify=True, + origin_consumer=self.consumer_instance, + ) + + # Clean up pending evaluations for this cryptocurrency + if cryptocurrency in self._pending_evaluations: + del self._pending_evaluations[cryptocurrency] + if cryptocurrency in self._expected_symbols_timeframes: + del self._expected_symbols_timeframes[cryptocurrency] + + +class GlobalLLMAIStrategyEvaluator(BaseLLMAIStrategyEvaluator): + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._is_evaluating = False + + + @classmethod + def get_is_cryptocurrencies_wildcard(cls) -> bool: + """ + :return: True if the evaluator is not cryptocurrency dependant else False + """ + return False + + async def matrix_callback( + self, + matrix_id, + evaluator_name, + evaluator_type, + eval_note, + eval_note_type, + eval_note_description, + eval_note_metadata, + exchange_name, + cryptocurrency, + symbol, + time_frame, + **kwargs, + ): + if evaluator_type not in self.evaluator_types: + return + + if cryptocurrency is None or (self.eval_note == common_constants.START_PENDING_EVAL_NOTE and not self._is_evaluating): + self._is_evaluating = True + # Only evaluate if it's a global evaluation or if we haven't evaluated yet + await self._evaluate_global( + matrix_id=matrix_id, + exchange_name=exchange_name, + ) + self._is_evaluating = False + + async def _evaluate_global( + self, + matrix_id: str, + exchange_name: str, + ): + """ + Perform global market evaluation across all cryptocurrencies. + """ + aggregated_data = {} + missing_data_types = [] + + for eval_type in self.evaluator_types: + # Fetch global evaluators (no cryptocurrency, no symbol, no timeframe) + evaluations = matrix.get_evaluations_by_evaluator( + matrix_id, exchange_name, eval_type + ) + + valid_evaluations = [ + { + "eval_note": ev.node_value, + "eval_note_description": ev.node_description or "", + } + for ev in evaluations.values() + if evaluators_util.check_valid_eval_note(ev.node_value) + ] + if valid_evaluations: + aggregated_data[eval_type] = valid_evaluations + else: + missing_data_types.append(eval_type) + + if not aggregated_data: + return + + gpt_service = await self._get_llm_service() + if not gpt_service: + self.eval_note = 0 + final_eval_note_description = "Error: LLMService not available" + self._has_evaluated = True + await self.evaluation_completed( + cryptocurrency=None, + symbol=None, + time_frame=None, + eval_note=self.eval_note, + eval_note_description=final_eval_note_description, + eval_time=0, + notify=True, + origin_consumer=self.consumer_instance, + ) + return + + self.eval_note, final_eval_note_description = await self._run_agents_analysis( + aggregated_data, missing_data_types, gpt_service + ) + + # Publish evaluation at global level + await self.evaluation_completed( + cryptocurrency=None, + symbol=None, + time_frame=None, + eval_note=self.eval_note, + eval_note_description=final_eval_note_description, + eval_time=0, + notify=True, + origin_consumer=self.consumer_instance, + ) diff --git a/Evaluator/Strategies/ai_strategies_evaluator/config/CryptoLLMAIStrategyEvaluator.json b/Evaluator/Strategies/ai_strategies_evaluator/config/CryptoLLMAIStrategyEvaluator.json new file mode 100644 index 000000000..a9526a2d6 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/config/CryptoLLMAIStrategyEvaluator.json @@ -0,0 +1,18 @@ +{ + "default_config": [ + "MACDMomentumEvaluator", + "RSIMomentumEvaluator", + "EMADivergenceTrendEvaluator", + "DoubleMovingAverageTrendEvaluator", + "BBMomentumEvaluator", + "ADXMomentumEvaluator", + "SocialScoreEvaluator" + ], + "required_evaluators": ["*"], + "required_time_frames": [ + "1h", + "4h", + "1d" + ], + "required_candles_count": 1000 +} diff --git a/Evaluator/Strategies/ai_strategies_evaluator/config/GlobalLLMAIStrategyEvaluator.json b/Evaluator/Strategies/ai_strategies_evaluator/config/GlobalLLMAIStrategyEvaluator.json new file mode 100644 index 000000000..65fa18900 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/config/GlobalLLMAIStrategyEvaluator.json @@ -0,0 +1,14 @@ +{ + "default_config": [ + "FearAndGreedIndexEvaluator", + "MarketCapEvaluator", + "CryptoNewsEvaluator" + ], + "required_evaluators": [ + "FearAndGreedIndexEvaluator", + "MarketCapEvaluator", + "CryptoNewsEvaluator" + ], + "required_time_frames": ["1d"] +} + diff --git a/Evaluator/Strategies/ai_strategies_evaluator/metadata.json b/Evaluator/Strategies/ai_strategies_evaluator/metadata.json new file mode 100644 index 000000000..4897f3bd4 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/metadata.json @@ -0,0 +1,9 @@ +{ + "version": "1.3.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": [ + "CryptoLLMAIStrategyEvaluator", + "GlobalLLMAIStrategyEvaluator" + ], + "tentacles-requirements": ["gpt_service"] +} \ No newline at end of file diff --git a/Evaluator/Strategies/ai_strategies_evaluator/resources/LLMAIStrategyEvaluator.md b/Evaluator/Strategies/ai_strategies_evaluator/resources/LLMAIStrategyEvaluator.md new file mode 100644 index 000000000..a0ff3c6db --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/resources/LLMAIStrategyEvaluator.md @@ -0,0 +1,106 @@ +# LLMAIStrategyEvaluator + +The LLMAIStrategyEvaluator is an advanced strategy evaluator that leverages Large Language Models (LLMs) to analyze and synthesize signals from Technical Analysis (TA), Social sentiment, and Real-Time evaluators. It provides intelligent trading recommendations by combining multiple evaluator inputs with AI-driven reasoning through parallel sub-agent processing. + +## How it works + +1. **Signal Aggregation**: Collects evaluation notes and descriptions from configured TA, Social, and Real-Time evaluators +2. **Parallel Sub-Agent Analysis**: Uses specialized StrategyAgents to analyze each evaluator type independently +3. **AI Synthesis**: Leverages Large Language Model reasoning in each sub-agent for specialized analysis +4. **Summarization**: Combines all sub-agent results through a SummarizationAgent for final evaluation +5. **Output Generation**: Produces eval_note (-1 to 1) and descriptive reasoning + +## File Structure + +The LLMAIStrategyEvaluator is organized in a modular architecture: + +``` +ai_strategies_evaluator/ +├── ai_strategies.py # Main evaluator implementation +├── agents/ # Agent-based architecture +│ ├── __init__.py # Agent module exports +│ ├── base_llm_agent.py # Abstract base agent class +│ ├── summarization_agent.py # Final result synthesis +│ ├── technical_analysis_agent.py # TA signal analysis +│ ├── sentiment_analysis_agent.py # Social sentiment analysis +│ └── real_time_analysis_agent.py # Real-time market analysis +│ └── factory.py # Agent creation factory +├── config/ # Configuration files +│ └── LLMAIStrategyEvaluator.json # Evaluator configuration +├── resources/ # Documentation and metadata +│ ├── LLMAIStrategyEvaluator.md # This documentation +│ └── metadata.json # Tentacle metadata +├── tests/ # Test suite +│ └── test_llm_ai_strategy_evaluator.py # Unit tests +└── __init__.py # Package initialization +``` + +### User Inputs +- **Prompt**: Custom prompt for LLM analysis (leave empty to use default specialized prompts per evaluator type) +- **Model**: GPT model selection (uses GPTService defaults if not specified) +- **Max Tokens**: Maximum response length (uses GPTService defaults if not specified) +- **Temperature**: Randomness in LLM responses (uses GPTService defaults if not specified) +- **Evaluator Types**: Select TA, Social, Real-Time evaluators to include (all enabled by default) +- **Output Format**: Choose "standard" or "with_confidence" (includes average confidence level) + +### Default Behavior +- Evaluates on 1-hour, 4-hour, and 1-day timeframes +- Uses GPTService default model and parameters +- Includes TA, Social, and Real-Time evaluators by default +- Provides specialized analysis for each evaluator type +- Uses parallel processing for improved performance + +### Specialized Analysis Types + +#### Technical Analysis Agent +Focuses exclusively on technical indicators and price patterns: +- Analyzes RSI, MACD, moving averages, Bollinger Bands, ADX, etc. +- Assesses trend direction and indicator convergence +- Provides confidence based on signal strength and agreement + +#### Social Sentiment Agent +Focuses exclusively on social and sentiment signals: +- Analyzes social media, news, community discussions +- Assesses overall market mood and sentiment +- Provides confidence based on signal consistency and volume + +#### Real-Time Agent +Focuses on live market movements and instant fluctuations: +- Analyzes order book data and real-time price movements +- Assesses current buying/selling pressure +- Provides confidence based on signal volatility and recency + +## Requirements +- GPTService must be configured and activated +- At least one TA, Social, or Real-Time evaluator should be active for meaningful analysis +- Works in both live and backtesting modes + +## Use Cases +- Advanced signal synthesis from multiple evaluator types +- Parallel AI-powered market analysis for improved performance +- Specialized analysis combining technical, social, and real-time signals +- Automated trading decisions with multi-faceted AI reasoning +- Backtesting complex multi-signal strategies + +## Architecture Benefits + +### Parallel Processing +- Each evaluator type is analyzed by a dedicated agent running in parallel +- Improved performance and reduced latency compared to sequential processing +- Better resource utilization of LLM API calls + +### Specialized Analysis +- Each sub-agent focuses on its domain expertise +- More accurate analysis through domain-specific prompts and reasoning +- Consistent evaluation methodology across different signal types + +### Intelligent Summarization +- Final evaluation considers all sub-agent results +- Weights signals based on confidence and consistency +- Provides comprehensive reasoning across all analysis domains + +## Warning +- LLM responses may vary due to temperature settings +- Requires OpenAI API access through GPTService +- Parallel processing increases API usage and costs +- Performance depends on quality of input evaluator signals \ No newline at end of file diff --git a/Evaluator/Strategies/ai_strategies_evaluator/tests/__init__.py b/Evaluator/Strategies/ai_strategies_evaluator/tests/__init__.py new file mode 100644 index 000000000..974dd1623 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/tests/__init__.py @@ -0,0 +1,15 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. diff --git a/Evaluator/Strategies/ai_strategies_evaluator/tests/test_llm_ai_strategy_evaluator.py b/Evaluator/Strategies/ai_strategies_evaluator/tests/test_llm_ai_strategy_evaluator.py new file mode 100644 index 000000000..01a29048a --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/tests/test_llm_ai_strategy_evaluator.py @@ -0,0 +1,94 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import pytest +from unittest.mock import AsyncMock, patch + +import tentacles.Evaluator.Strategies as Strategies +import tentacles.Trading.Mode as Mode +import octobot_services.api.services as services_api +from octobot_services.services.gpt_service import GPTService +from octobot_evaluators.enums import EvaluatorMatrixTypes + +import tests.functional_tests.strategy_evaluators_tests.abstract_strategy_test as abstract_strategy_test + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def strategy_tester(): + """Create a strategy tester instance.""" + tester = LLMAIStrategyEvaluatorTest() + tester.initialize(Strategies.LLMAIStrategyEvaluator, Mode.DailyTradingMode) + return tester + + +class LLMAIStrategyEvaluatorTest(abstract_strategy_test.AbstractStrategyTest): + """Test suite for LLMAIStrategyEvaluator.""" + + async def test_agents_execute(self): + """Test that agents execute and return results.""" + evaluator = Strategies.LLMAIStrategyEvaluator(self.tentacles_setup_config) + mock_llm_service = AsyncMock(spec=GPTService) + + with patch.object(services_api, "get_service", new_callable=AsyncMock) as mock_get_service: + mock_get_service.return_value = mock_llm_service + + # Mock agents + mock_agent = AsyncMock() + mock_agent.execute.return_value = { + "eval_note": 0.7, + "eval_note_description": "Test analysis", + "confidence": 85, + } + + with ( + patch("ai_strategies.AgentFactory.create_agent_for_evaluator_type") as mock_create, + patch("ai_strategies.AgentFactory.create_summarization_agent") as mock_summary, + ): + mock_create.return_value = mock_agent + mock_summary.return_value = mock_agent + + await evaluator.matrix_callback( + matrix_id="test", + evaluator_name="test", + evaluator_type=EvaluatorMatrixTypes.TA.value, + eval_note=0.7, + eval_note_type="", + eval_note_description="", + eval_note_metadata={}, + exchange_name="binance", + cryptocurrency="BTC", + symbol="BTC/USDT", + time_frame="1h", + ) + + assert mock_create.called + assert mock_agent.execute.called + + +async def test_bullish_evaluation(strategy_tester): + """Test bullish evaluation.""" + await strategy_tester.test_agents_execute() + + +async def test_bearish_evaluation(strategy_tester): + """Test bearish evaluation.""" + await strategy_tester.test_agents_execute() + + +async def test_neutral_evaluation(strategy_tester): + """Test neutral evaluation.""" + await strategy_tester.test_agents_execute() diff --git a/Services/Services_bases/gpt_service/__init__.py b/Services/Services_bases/gpt_service/__init__.py index 8b615eb98..e8c51e05d 100644 --- a/Services/Services_bases/gpt_service/__init__.py +++ b/Services/Services_bases/gpt_service/__init__.py @@ -1,3 +1,3 @@ import octobot_commons.constants as commons_constants if not commons_constants.USE_MINIMAL_LIBS: - from .gpt import GPTService \ No newline at end of file + from .gpt import LLMService, LLMSignalService, GPTService diff --git a/Services/Services_bases/gpt_service/gpt.py b/Services/Services_bases/gpt_service/gpt.py index 963f35da8..129138f99 100644 --- a/Services/Services_bases/gpt_service/gpt.py +++ b/Services/Services_bases/gpt_service/gpt.py @@ -41,90 +41,209 @@ "o1-mini", ] MINIMAL_PARAMS_SERIES_MODELS = [ - "o", # the whole o-series does not support temperature parameter + "o", # the whole o-series does not support temperature parameter ] MINIMAL_PARAMS_MODELS = [ - "gpt-5", # does not support temperature parameter + "gpt-5", # does not support temperature parameter ] SYSTEM = "system" USER = "user" -class GPTService(services.AbstractService): +class LLMService(services.AbstractService): BACKTESTING_ENABLED = True - DEFAULT_MODEL = "gpt-3.5-turbo" + DEFAULT_MODEL = "gpt-4o-mini" NO_TOKEN_LIMIT_VALUE = -1 def get_fields_description(self): if self._env_secret_key is None: return { - services_constants.CONIG_OPENAI_SECRET_KEY: "Your openai API secret key", - services_constants.CONIG_LLM_CUSTOM_BASE_URL: ( + services_constants.CONFIG_OPENAI_SECRET_KEY: "Your openai API secret key", + services_constants.CONFIG_LLM_CUSTOM_BASE_URL: ( "Custom LLM base url to use. Leave empty to use openai.com. For Ollama models, " "add /v1 to the url (such as: http://localhost:11434/v1)" ), + services_constants.CONFIG_LLM_MODEL: ( + f"LLM model to use (default: {self.DEFAULT_MODEL}). " + "Can be overridden by GPT_MODEL environment variable." + ), + services_constants.CONFIG_LLM_DAILY_TOKENS_LIMIT: ( + f"Daily token limit (default: {self.NO_TOKEN_LIMIT_VALUE} for no limit). " + "Can be overridden by GPT_DAILY_TOKEN_LIMIT environment variable." + ), } return {} def get_default_value(self): if self._env_secret_key is None: return { - services_constants.CONIG_OPENAI_SECRET_KEY: "", - services_constants.CONIG_LLM_CUSTOM_BASE_URL: "", + services_constants.CONFIG_OPENAI_SECRET_KEY: "", + services_constants.CONFIG_LLM_CUSTOM_BASE_URL: "", + services_constants.CONFIG_LLM_MODEL: self.DEFAULT_MODEL, + services_constants.CONFIG_LLM_DAILY_TOKENS_LIMIT: self.NO_TOKEN_LIMIT_VALUE, } return {} def __init__(self): super().__init__() logging.getLogger("openai").setLevel(logging.WARNING) - self._env_secret_key: str = os.getenv(services_constants.ENV_OPENAI_SECRET_KEY, None) or None - self.model: str = os.getenv(services_constants.ENV_GPT_MODEL, self.DEFAULT_MODEL) - self.stored_signals: tree.BaseTree = tree.BaseTree() + self._env_secret_key: typing.Optional[str] = ( + os.getenv(services_constants.ENV_OPENAI_SECRET_KEY, None) or None + ) + # Model priority: env var > config > default + env_model = os.getenv(services_constants.ENV_GPT_MODEL, None) + self.model: str = env_model or self.DEFAULT_MODEL + self.models: list[str] = [] - self._env_daily_token_limit: int = int(os.getenv( - services_constants.ENV_GPT_DAILY_TOKENS_LIMIT, - self.NO_TOKEN_LIMIT_VALUE) + + # Daily token limit priority: env var > config > default + env_daily_token_limit_str = os.getenv( + services_constants.ENV_GPT_DAILY_TOKENS_LIMIT, None ) + if env_daily_token_limit_str: + self._env_daily_token_limit: int = int(env_daily_token_limit_str) + else: + self._env_daily_token_limit: int = self.NO_TOKEN_LIMIT_VALUE + self._daily_tokens_limit: int = self._env_daily_token_limit self.consumed_daily_tokens: int = 1 - self.last_consumed_token_date: datetime.date = None + self.last_consumed_token_date: typing.Optional[datetime.date] = None + + def _load_model_from_config(self): + """Load model from config if not overridden by environment variable.""" + if os.getenv(services_constants.ENV_GPT_MODEL, None): + # Environment variable takes precedence + return + try: + config_model = self.config[services_constants.CONFIG_CATEGORY_SERVICES][ + self.get_type() + ].get(services_constants.CONFIG_LLM_MODEL) + if config_model and not fields_utils.has_invalid_default_config_value( + config_model + ): + self.model = config_model + except (KeyError, TypeError): + pass + + def _load_token_limit_from_config(self): + """Load daily token limit from config if not overridden by environment variable.""" + if os.getenv(services_constants.ENV_GPT_DAILY_TOKENS_LIMIT, None): + # Environment variable takes precedence + return + try: + config_limit = self.config[services_constants.CONFIG_CATEGORY_SERVICES][ + self.get_type() + ].get(services_constants.CONFIG_LLM_DAILY_TOKENS_LIMIT) + if ( + config_limit is not None + and not fields_utils.has_invalid_default_config_value(config_limit) + ): + if isinstance(config_limit, str): + self._daily_tokens_limit = int(config_limit) + else: + self._daily_tokens_limit = config_limit + except (KeyError, TypeError, ValueError): + pass @staticmethod - def create_message(role, content, model: str = None): + def create_message(role, content, model: typing.Optional[str] = None): if role == SYSTEM and model in NO_SYSTEM_PROMPT_MODELS: - commons_logging.get_logger(GPTService.__name__).debug( + commons_logging.get_logger(LLMService.__name__).debug( f"Overriding prompt to use {USER} instead of {SYSTEM} for {model}" ) return {"role": USER, "content": content} return {"role": role, "content": content} - async def get_chat_completion( + async def get_completion( self, messages, model=None, - max_tokens=3000, + max_tokens=10000, n=1, stop=None, temperature=0.5, - exchange: str = None, - symbol: str = None, - time_frame: str = None, - version: str = None, - candle_open_time: float = None, - use_stored_signals: bool = False, - ) -> str: - if use_stored_signals: - return self._get_signal_from_stored_signals(exchange, symbol, time_frame, version, candle_open_time) - if self.use_stored_signals_only(): - signal = await self._fetch_signal_from_stored_signals(exchange, symbol, time_frame, version, candle_open_time) - if not signal: - # should not happen - self.logger.error( - f"Missing ChatGPT signal from stored signals on {symbol} {time_frame} " - f"for timestamp: {candle_open_time} with version: {version}" + json_output=False, + response_schema=None, + ) -> typing.Optional[str]: + """Get a completion from the LLM. + + Args: + messages: List of message dicts + model: Model to use + max_tokens: Max tokens in response + n: Number of completions + stop: Stop sequences + temperature: Sampling temperature + json_output: Return JSON format + response_schema: Optional Pydantic model or JSON schema dict for structured output. + If provided with json_output=True, enforces the response to match schema. + """ + self._ensure_rate_limit() + try: + model = model or self.model + supports_params = not self._is_minimal_params_model(model) + if not supports_params: + self.logger.info( + f"The {model} model does not support every required parameter, results might not be as accurate " + f"as with other models." ) - return signal - return await self._get_signal_from_gpt(messages, model, max_tokens, n, stop, temperature) + # Prepare API call parameters + api_kwargs = { + "model": model, + "max_completion_tokens": max_tokens, + "n": n, + "stop": stop, + "temperature": temperature if supports_params else openai.NOT_GIVEN, + "messages": messages, + } + + if json_output: + if response_schema is not None: + schema = response_schema + if hasattr(response_schema, "model_json_schema"): + # It's a Pydantic model - extract and enhance schema + schema = response_schema.model_json_schema() + api_kwargs["response_format"] = { + "type": "json_schema", + "json_schema": { + "name": schema.get("title", "response"), + "schema": schema, + "strict": True, + } + } + else: + # Fallback to basic JSON object format + api_kwargs["response_format"] = {"type": "json_object"} + + completions = await self._get_client().chat.completions.create(**api_kwargs) + if completions.usage is not None: + self._update_token_usage(completions.usage.total_tokens) + return completions.choices[0].message.content + except ( + openai.BadRequestError, + openai.UnprocessableEntityError, # error in request + ) as err: + if "does not support 'system' with this model" in str(err): + desc = err.message + err_message = ( + f'The "{model}" model can\'t be used with {SYSTEM} prompts. ' + f"It should be added to NO_SYSTEM_PROMPT_MODELS: {desc}" + ) + else: + err_message = f"Error when running request with model {model} (invalid request): {err}" + raise errors.InvalidRequestError(err_message) from err + except openai.NotFoundError as err: + self.logger.error( + f"Model {model} not found: {err}. Available models: {', '.join(self.models)}" + ) + self.creation_error_message = str(err) + except openai.AuthenticationError as err: + self.logger.error(f"Invalid OpenAI api key: {err}") + self.creation_error_message = str(err) + except Exception as err: + raise errors.InvalidRequestError( + f"Unexpected error when running request with model {model}: {err}" + ) from err def _get_client(self) -> openai.AsyncOpenAI: return openai.AsyncOpenAI( @@ -151,56 +270,203 @@ def _is_minimal_params_model(self, model: str) -> bool: return True return False - async def _get_signal_from_gpt( - self, - messages, - model=None, - max_tokens=3000, - n=1, - stop=None, - temperature=0.5 - ): - self._ensure_rate_limit() + @staticmethod + def is_setup_correctly(config): + return True + + def allow_token_limit_update(self): + return self._env_daily_token_limit == self.NO_TOKEN_LIMIT_VALUE + + def apply_daily_token_limit_if_possible(self, updated_limit: int): + # do not allow updating daily_tokens_limit when set from environment variables + if self.allow_token_limit_update(): + self._daily_tokens_limit = updated_limit + + def _ensure_rate_limit(self): + if self.last_consumed_token_date != datetime.date.today(): + self.consumed_daily_tokens = 0 + self.last_consumed_token_date = datetime.date.today() + if self._daily_tokens_limit == self.NO_TOKEN_LIMIT_VALUE: + return + if self.consumed_daily_tokens >= self._daily_tokens_limit: + raise errors.RateLimitError( + f"Daily rate limit reached (used {self.consumed_daily_tokens} out of {self._daily_tokens_limit})" + ) + + def _update_token_usage(self, consumed_tokens): + self.consumed_daily_tokens += consumed_tokens + self.logger.debug( + f"Consumed {consumed_tokens} tokens. {self.consumed_daily_tokens} consumed tokens today." + ) + + def check_required_config(self, config): + if self._env_secret_key is not None or self._get_base_url(): + return True try: - model = model or self.model - supports_params = not self._is_minimal_params_model(model) - if not supports_params: - self.logger.info( - f"The {model} model does not support every required parameter, results might not be as accurate " - f"as with other models." - ) - completions = await self._get_client().chat.completions.create( - model=model, - max_completion_tokens=max_tokens, - n=n, - stop=stop, - temperature=temperature if supports_params else openai.NOT_GIVEN, - messages=messages + config_key = config[services_constants.CONIG_OPENAI_SECRET_KEY] + return ( + bool(config_key) + and config_key not in commons_constants.DEFAULT_CONFIG_VALUES ) - self._update_token_usage(completions.usage.total_tokens) - return completions.choices[0].message.content - except ( - openai.BadRequestError, openai.UnprocessableEntityError # error in request - )as err: - if "does not support 'system' with this model" in str(err): - desc = err.message - err_message = ( - f"The \"{model}\" model can't be used with {SYSTEM} prompts. " - f"It should be added to NO_SYSTEM_PROMPT_MODELS: {desc}" + except KeyError: + return False + + def has_required_configuration(self): + try: + return self.check_required_config( + self.config[services_constants.CONFIG_CATEGORY_SERVICES].get( + self.get_type(), {} + ) ) + except KeyError: + return False + + def get_required_config(self): + return ( + [] if self._env_secret_key else [services_constants.CONIG_OPENAI_SECRET_KEY] + ) + + @classmethod + def get_help_page(cls) -> str: + return f"{constants.OCTOBOT_DOCS_URL}/octobot-interfaces/chatgpt" + + def get_type(self) -> str: + return services_constants.CONFIG_GPT + + def get_website_url(self): + return "https://platform.openai.com/overview" + + def get_logo(self): + return "https://upload.wikimedia.org/wikipedia/commons/0/04/ChatGPT_logo.svg" + + def _get_api_key(self): + key = self._env_secret_key or self.config[ + services_constants.CONFIG_CATEGORY_SERVICES + ][self.get_type()].get(services_constants.CONFIG_OPENAI_SECRET_KEY, None) + if key and not fields_utils.has_invalid_default_config_value(key): + return key + if self._get_base_url(): + # no key and custom base url: use random key + return uuid.uuid4().hex + return key + + def _get_base_url(self): + value = self.config[services_constants.CONFIG_CATEGORY_SERVICES][ + self.get_type() + ].get(services_constants.CONFIG_LLM_CUSTOM_BASE_URL) + if fields_utils.has_invalid_default_config_value(value): + return None + return value or None + + async def prepare(self) -> None: + try: + # Load model and token limit from config (with env var precedence) + self._load_model_from_config() + self._load_token_limit_from_config() + + if self._get_base_url(): + self.logger.info(f"Using custom LLM url: {self._get_base_url()}") + fetched_models = await self._get_client().models.list() + if fetched_models.data: + self.logger.info(f"Fetched {len(fetched_models.data)} models") + self.models = [d.id for d in fetched_models.data] else: - err_message = f"Error when running request with model {model} (invalid request): {err}" - raise errors.InvalidRequestError(err_message) from err - except openai.NotFoundError as err: - self.logger.error(f"Model {model} not found: {err}. Available models: {', '.join(self.models)}") - self.creation_error_message = str(err) + self.logger.info("No fetched models") + self.models = [] + if self.model not in self.models: + if self._get_base_url(): + self.logger.info( + f"Custom LLM available models are: {self.models}. " + f"Please select one of those in your evaluator configuration." + ) + else: + self.logger.warning( + f"Warning: the default '{self.model}' model is not in available LLM models from the " + f"selected LLM provider. " + f"Available models are: {self.models}. Please select an available model when configuring your " + f"evaluators." + ) except openai.AuthenticationError as err: self.logger.error(f"Invalid OpenAI api key: {err}") self.creation_error_message = str(err) except Exception as err: - raise errors.InvalidRequestError( - f"Unexpected error when running request with model {model}: {err}" - ) from err + self.logger.exception( + err, True, f"Unexpected error when initializing LLM service: {err}" + ) + + def _is_healthy(self): + return self._get_api_key() and self.models + + def get_successful_startup_message(self): + return ( + f"LLM configured and ready. {len(self.models)} AI models are available. Using {self.models}.", + self._is_healthy(), + ) + + def use_stored_signals_only(self): + return not self.config + + async def stop(self): + pass + + +class LLMSignalService(LLMService): + """LLM service for managing signals generation and storage.""" + + def get_fields_description(self): + fields = super().get_fields_description() + # LLMSignalService uses the same config as LLMService for backward compatibility + return fields + + def get_default_value(self): + return super().get_default_value() + + def __init__(self): + super().__init__() + self.stored_signals: tree.BaseTree = tree.BaseTree() + + async def get_chat_completion( + self, + messages, + model=None, + max_tokens=3000, + n=1, + stop=None, + temperature=0.5, + exchange: typing.Optional[str] = None, + symbol: typing.Optional[str] = None, + time_frame: typing.Optional[str] = None, + version: typing.Optional[str] = None, + candle_open_time: typing.Optional[float] = None, + use_stored_signals: bool = False, + ) -> str: + """Get a signal from stored signals or GPT.""" + if use_stored_signals: + return self._get_signal_from_stored_signals( + exchange, symbol, time_frame, version, candle_open_time + ) + if self.use_stored_signals_only(): + signal = await self._fetch_signal_from_stored_signals( + exchange, symbol, time_frame, version, candle_open_time + ) + if not signal: + # should not happen + self.logger.error( + f"Missing ChatGPT signal from stored signals on {symbol} {time_frame} " + f"for timestamp: {candle_open_time} with version: {version}" + ) + return signal + return await self._get_signal_from_gpt( + messages, model, max_tokens, n, stop, temperature + ) + + async def _get_signal_from_gpt( + self, messages, model=None, max_tokens=3000, n=1, stop=None, temperature=0.5 + ): + """Get a signal from GPT.""" + return await self.get_completion( + messages, model, max_tokens, n, stop, temperature + ) def _get_signal_from_stored_signals( self, @@ -211,7 +477,9 @@ def _get_signal_from_stored_signals( candle_open_time: float, ) -> str: try: - return self.stored_signals.get_node([exchange, symbol, time_frame, version, candle_open_time]).node_value + return self.stored_signals.get_node( + [exchange, symbol, time_frame, version, candle_open_time] + ).node_value except tree.NodeExistsError: return "" @@ -226,7 +494,11 @@ async def _fetch_signal_from_stored_signals( authenticator = authentication.Authenticator.instance() try: return await authenticator.get_gpt_signal( - exchange, symbol, commons_enums.TimeFrames(time_frame), candle_open_time, version + exchange, + symbol, + commons_enums.TimeFrames(time_frame), + candle_open_time, + version, ) except Exception as err: self.logger.exception(err, True, f"Error when fetching gpt signal: {err}") @@ -242,9 +514,7 @@ def store_signal_history( tf = time_frame.value for candle_open_time, signal in signals_by_candle_open_time.items(): self.stored_signals.set_node_at_path( - signal, - str, - [exchange, symbol, tf, version, candle_open_time] + signal, str, [exchange, symbol, tf, version, candle_open_time] ) def has_signal_history( @@ -254,24 +524,40 @@ def has_signal_history( time_frame: commons_enums.TimeFrames, min_timestamp: float, max_timestamp: float, - version: str + version: str, ): for ts in (min_timestamp, max_timestamp): - if self._get_signal_from_stored_signals( - exchange, symbol, time_frame.value, version, time_frame_manager.get_last_timeframe_time(time_frame, ts) - ) == "": + if ( + self._get_signal_from_stored_signals( + exchange, + symbol, + time_frame.value, + version, + time_frame_manager.get_last_timeframe_time(time_frame, ts), + ) + == "" + ): return False return True async def _fetch_and_store_history( - self, authenticator, exchange_name, symbol, time_frame, version, min_timestamp: float, max_timestamp: float + self, + authenticator, + exchange_name, + symbol, + time_frame, + version, + min_timestamp: float, + max_timestamp: float, ): # no need to fetch a particular exchange signals_by_candle_open_time = await authenticator.get_gpt_signals_history( - None, symbol, time_frame, + None, + symbol, + time_frame, time_frame_manager.get_last_timeframe_time(time_frame, min_timestamp), time_frame_manager.get_last_timeframe_time(time_frame, max_timestamp), - version + version, ) if signals_by_candle_open_time: self.logger.info( @@ -287,22 +573,36 @@ async def _fetch_and_store_history( exchange_name, symbol, time_frame, version, signals_by_candle_open_time ) - @staticmethod - def is_setup_correctly(config): - return True - async def fetch_gpt_history( - self, exchange_name: str, symbols: list, time_frames: list, - version: str, start_timestamp: float, end_timestamp: float + self, + exchange_name: str, + symbols: list, + time_frames: list, + version: str, + start_timestamp: float, + end_timestamp: float, ): authenticator = authentication.Authenticator.instance() coros = [ self._fetch_and_store_history( - authenticator, exchange_name, symbol, time_frame, version, start_timestamp, end_timestamp + authenticator, + exchange_name, + symbol, + time_frame, + version, + start_timestamp, + end_timestamp, ) for symbol in symbols for time_frame in time_frames - if not self.has_signal_history(exchange_name, symbol, time_frame, start_timestamp, end_timestamp, version) + if not self.has_signal_history( + exchange_name, + symbol, + time_frame, + start_timestamp, + end_timestamp, + version, + ) ] if coros: await asyncio.gather(*coros) @@ -310,38 +610,22 @@ async def fetch_gpt_history( def clear_signal_history(self): self.stored_signals.clear() - def allow_token_limit_update(self): - return self._env_daily_token_limit == self.NO_TOKEN_LIMIT_VALUE - - def apply_daily_token_limit_if_possible(self, updated_limit: int): - # do not allow updating daily_tokens_limit when set from environment variables - if self.allow_token_limit_update(): - self._daily_tokens_limit = updated_limit - def _supported_history_url(self): return f"{community.IdentifiersProvider.COMMUNITY_URL}/features/chatgpt-trading" - def _ensure_rate_limit(self): - if self.last_consumed_token_date != datetime.date.today(): - self.consumed_daily_tokens = 0 - self.last_consumed_token_date = datetime.date.today() - if self._daily_tokens_limit == self.NO_TOKEN_LIMIT_VALUE: - return - if self.consumed_daily_tokens >= self._daily_tokens_limit: - raise errors.RateLimitError( - f"Daily rate limit reached (used {self.consumed_daily_tokens} out of {self._daily_tokens_limit})" - ) - - def _update_token_usage(self, consumed_tokens): - self.consumed_daily_tokens += consumed_tokens - self.logger.debug(f"Consumed {consumed_tokens} tokens. {self.consumed_daily_tokens} consumed tokens today.") - def check_required_config(self, config): - if self._env_secret_key is not None or self.use_stored_signals_only() or self._get_base_url(): + if ( + self._env_secret_key is not None + or self.use_stored_signals_only() + or self._get_base_url() + ): return True try: - config_key = config[services_constants.CONIG_OPENAI_SECRET_KEY] - return bool(config_key) and config_key not in commons_constants.DEFAULT_CONFIG_VALUES + config_key = config[services_constants.CONFIG_OPENAI_SECRET_KEY] + return ( + bool(config_key) + and config_key not in commons_constants.DEFAULT_CONFIG_VALUES + ) except KeyError: return False @@ -350,54 +634,34 @@ def has_required_configuration(self): if self.use_stored_signals_only(): return True return self.check_required_config( - self.config[services_constants.CONFIG_CATEGORY_SERVICES].get(services_constants.CONFIG_GPT, {}) + self.config[services_constants.CONFIG_CATEGORY_SERVICES].get( + services_constants.CONFIG_GPT, {} + ) ) except KeyError: return False - def get_required_config(self): - return [] if self._env_secret_key else [services_constants.CONIG_OPENAI_SECRET_KEY] - - @classmethod - def get_help_page(cls) -> str: - return f"{constants.OCTOBOT_DOCS_URL}/octobot-interfaces/chatgpt" - - def get_type(self) -> str: - return services_constants.CONFIG_GPT - - def get_website_url(self): - return "https://platform.openai.com/overview" - - def get_logo(self): - return "https://upload.wikimedia.org/wikipedia/commons/0/04/ChatGPT_logo.svg" - - def _get_api_key(self): - key = ( - self._env_secret_key or - self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_GPT].get( - services_constants.CONIG_OPENAI_SECRET_KEY, None - ) - ) - if key and not fields_utils.has_invalid_default_config_value(key): - return key - if self._get_base_url(): - # no key and custom base url: use random key - return uuid.uuid4().hex - return key + def _is_healthy(self): + return self.use_stored_signals_only() or (self._get_api_key() and self.models) - def _get_base_url(self): - value = self.config[services_constants.CONFIG_CATEGORY_SERVICES][services_constants.CONFIG_GPT].get( - services_constants.CONIG_LLM_CUSTOM_BASE_URL + def get_successful_startup_message(self): + return ( + f"GPT configured and ready. {len(self.models)} AI models are available. " + f"Using {'stored signals' if self.use_stored_signals_only() else self.models}.", + self._is_healthy(), ) - if fields_utils.has_invalid_default_config_value(value): - return None - return value or None async def prepare(self) -> None: try: if self.use_stored_signals_only(): - self.logger.info(f"Skipping GPT - OpenAI models fetch as self.use_stored_signals_only() is True") + self.logger.info( + f"Skipping GPT - OpenAI models fetch as self.use_stored_signals_only() is True" + ) return + + self._load_model_from_config() + self._load_token_limit_from_config() + if self._get_base_url(): self.logger.info(f"Using custom LLM url: {self._get_base_url()}") fetched_models = await self._get_client().models.list() @@ -424,18 +688,10 @@ async def prepare(self) -> None: self.logger.error(f"Invalid OpenAI api key: {err}") self.creation_error_message = str(err) except Exception as err: - self.logger.exception(err, True, f"Unexpected error when initializing GPT service: {err}") - - def _is_healthy(self): - return self.use_stored_signals_only() or (self._get_api_key() and self.models) + self.logger.exception( + err, True, f"Unexpected error when initializing GPT service: {err}" + ) - def get_successful_startup_message(self): - return f"GPT configured and ready. {len(self.models)} AI models are available. " \ - f"Using {'stored signals' if self.use_stored_signals_only() else self.models}.", \ - self._is_healthy() - def use_stored_signals_only(self): - return not self.config - - async def stop(self): - pass +# Backward compatibility: keep GPTService as an alias for LLMSignalService +GPTService = LLMSignalService diff --git a/Trading/Mode/ai_trading_mode/__init__.py b/Trading/Mode/ai_trading_mode/__init__.py new file mode 100644 index 000000000..37b6b98cc --- /dev/null +++ b/Trading/Mode/ai_trading_mode/__init__.py @@ -0,0 +1,18 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from .ai_index_trading import AIIndexTradingMode +from .ai_index_distribution import apply_ai_instructions diff --git a/Trading/Mode/ai_trading_mode/agents/__init__.py b/Trading/Mode/ai_trading_mode/agents/__init__.py new file mode 100644 index 000000000..167a4111b --- /dev/null +++ b/Trading/Mode/ai_trading_mode/agents/__init__.py @@ -0,0 +1,27 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +AI Trading Mode Agents package. +Contains signal, risk, and distribution agents for AI-driven portfolio management. +""" + +from tentacles.Trading.Mode.ai_trading_mode.agents.team import AIAgentTeam +from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState +from tentacles.Trading.Mode.ai_trading_mode.agents.base_agent import BaseAgent +from tentacles.Trading.Mode.ai_trading_mode.agents.signal_agent import SignalAgent +from tentacles.Trading.Mode.ai_trading_mode.agents.risk_agent import RiskAgent +from tentacles.Trading.Mode.ai_trading_mode.agents.distribution_agent import DistributionAgent diff --git a/Trading/Mode/ai_trading_mode/agents/base_agent.py b/Trading/Mode/ai_trading_mode/agents/base_agent.py new file mode 100644 index 000000000..6cf569343 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/agents/base_agent.py @@ -0,0 +1,152 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Base agent class for AI trading mode agents. +""" +import logging +import json +import typing +from abc import ABC, abstractmethod + +from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState + + +class BaseAgent(ABC): + """ + Abstract base class for AI trading mode agents. + Provides common LLM calling functionality - agents only define prompts. + """ + + AGENT_NAME: str = "BaseAgent" + AGENT_VERSION: str = "1.0.0" + DEFAULT_MODEL = None + DEFAULT_MAX_TOKENS = 10000 + DEFAULT_TEMPERATURE = 0.3 + MAX_RETRIES = 3 + + def __init__(self, + name: typing.Optional[str] = None, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None): + """ + Initialize the agent. + + Args: + name: Agent name for logging. + model: LLM model to use. + max_tokens: Maximum tokens for response. + temperature: Temperature for LLM randomness. + """ + self.name = name or self.AGENT_NAME + self.model = model or self.DEFAULT_MODEL + self.max_tokens = max_tokens or self.DEFAULT_MAX_TOKENS + self.temperature = temperature or self.DEFAULT_TEMPERATURE + self.logger = logging.getLogger(f"[AITrading][{self.name}]") + self._custom_prompt = None + + @property + def prompt(self) -> str: + """Get the agent's prompt, allowing override via config.""" + return self._custom_prompt or self._get_default_prompt() + + @prompt.setter + def prompt(self, value: str): + """Allow custom prompt override.""" + self._custom_prompt = value + + @abstractmethod + def _get_default_prompt(self) -> str: + """Return the default prompt for this agent type.""" + pass + + @abstractmethod + async def execute(self, input_data: typing.Any, llm_service) -> typing.Any: + """ + Execute the agent's primary function. + + Args: + input_data: The input data for the agent to process. + llm_service: The LLM service instance. + + Returns: + The agent's output. + """ + pass + + async def _call_llm(self, messages: list, llm_service, json_output: bool = True, response_schema=None) -> typing.Any: + """ + Common LLM calling method with error handling and automatic retries. + + Args: + messages: List of message dicts with 'role' and 'content'. + llm_service: The LLM service instance. + json_output: Whether to parse response as JSON. + response_schema: Optional Pydantic model or JSON schema for structured output. + + Returns: + Parsed JSON dict or raw string response. + + Raises: + Exception: If all retries are exhausted. + """ + last_exception = None + + for attempt in range(1, self.MAX_RETRIES + 1): + try: + response = await llm_service.get_completion( + messages=messages, + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + json_output=json_output, + response_schema=response_schema, + ) + if json_output: + return json.loads(response.strip()) + return response.strip() + except (json.JSONDecodeError, ValueError, KeyError, AttributeError) as e: + last_exception = e + if attempt < self.MAX_RETRIES: + self.logger.warning( + f"LLM call failed on attempt {attempt}/{self.MAX_RETRIES} for agent {self.name}: {str(e)}. Retrying..." + ) + else: + self.logger.error( + f"LLM call failed on final attempt {attempt}/{self.MAX_RETRIES} for agent {self.name}: {str(e)}" + ) + + # All retries exhausted + raise Exception(f"LLM call failed for agent {self.name} after {self.MAX_RETRIES} retries: {str(last_exception)}") + + def format_strategy_data(self, strategy_data: dict) -> str: + """Format strategy data for inclusion in prompts.""" + if not strategy_data: + return "No strategy data available." + return json.dumps(strategy_data, indent=2, default=str) + + def format_portfolio(self, portfolio: dict) -> str: + """Format portfolio data for inclusion in prompts.""" + if not portfolio: + return "No portfolio data available." + return json.dumps(portfolio, indent=2, default=str) + + def format_orders(self, orders: dict) -> str: + """Format orders data for inclusion in prompts.""" + if not orders: + return "No orders data available." + return json.dumps(orders, indent=2, default=str) diff --git a/Trading/Mode/ai_trading_mode/agents/distribution_agent.py b/Trading/Mode/ai_trading_mode/agents/distribution_agent.py new file mode 100644 index 000000000..0bf4499a4 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/agents/distribution_agent.py @@ -0,0 +1,236 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Distribution Agent. +Makes final portfolio distribution decisions based on synthesized signals and risk assessment. +Uses ai_index_distribution functions to apply changes. +""" +import json +import typing + +from pydantic import BaseModel + +from tentacles.Trading.Mode.ai_trading_mode.agents.base_agent import BaseAgent +from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState +from tentacles.Trading.Mode.ai_trading_mode.agents.models import ( + DistributionOutput, + RiskAssessmentOutput, + SignalSynthesisOutput, +) + + +class DistributionAgent(BaseAgent): + """ + Distribution agent that makes final portfolio allocation decisions. + Combines signal synthesis and risk assessment to determine target distribution. + """ + + AGENT_NAME = "DistributionAgent" + AGENT_VERSION = "1.0.0" + + def __init__(self, model=None, max_tokens=None, temperature=None): + """ + Initialize the distribution agent. + + Args: + model: LLM model to use. + max_tokens: Maximum tokens for response. + temperature: Temperature for LLM randomness. + """ + super().__init__( + name=self.AGENT_NAME, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + + def _get_default_prompt(self) -> str: + """Return the default system prompt.""" + return """ +You are a Portfolio Distribution Agent for cryptocurrency trading. +Your task is to make FINAL portfolio allocation decisions based on synthesized signals and risk assessment. + +## Your Role +- Determine target percentage allocation for each asset. +- Balance signal-driven opportunities with risk constraints. +- Decide on rebalancing urgency. + +## Important Constraints +- Total allocation MUST sum to exactly 100%. +- Respect maximum allocation limits from risk assessment. +- Maintain minimum cash reserve as recommended by risk agent. +- Consider current distribution to minimize unnecessary trades. + +## Allocation Actions +- "increase": Increase allocation from current level +- "decrease": Decrease allocation from current level +- "maintain": Keep current allocation +- "add": Add new asset to portfolio +- "remove": Remove asset from portfolio entirely + +## Rebalancing Urgency +- "immediate": Critical signals or risk levels require immediate action +- "soon": Moderate signals suggest rebalancing within the trading session +- "low": Minor adjustments that can wait +- "none": Current distribution is acceptable + +## Decision Framework +1. Start with current distribution as baseline +2. Apply signal synthesis recommendations (increase/decrease based on direction and strength) +3. Apply risk constraints (max allocation limits, min cash reserve) +4. Ensure total sums to 100% +5. Determine urgency based on signal strength and risk level + +## Output Requirements +Output a JSON object with: +- "allocations": Array of objects with "asset", "target_percentage", "action", "reasoning" +- "rebalance_urgency": One of "immediate", "soon", "low", "none" +- "reasoning": Summary explanation +""" + + def _build_user_prompt(self, state: AIAgentState) -> str: + """Build the user prompt with all decision inputs.""" + signal_synthesis = state.get("signal_synthesis") + risk_output = state.get("risk_output") + current_distribution = state.get("current_distribution", {}) + cryptocurrencies = state.get("cryptocurrencies", []) + reference_market = state.get("reference_market", "USD") + + # Format signal synthesis + synthesis_data = {} + if signal_synthesis: + if isinstance(signal_synthesis, SignalSynthesisOutput): + synthesis_data = { + "market_outlook": signal_synthesis.market_outlook, + "summary": signal_synthesis.summary, + "signals": [ + { + "asset": s.asset, + "direction": s.direction, + "strength": s.strength, + "consensus_level": s.consensus_level, + "trading_instruction": s.trading_instruction + } + for s in signal_synthesis.synthesized_signals + ] + } + elif isinstance(signal_synthesis, dict): + synthesis_data = signal_synthesis + + # Format risk output + risk_data = {} + if risk_output: + if isinstance(risk_output, RiskAssessmentOutput): + risk_data = { + "overall_risk_level": risk_output.metrics.overall_risk_level, + "concentration_risk": risk_output.metrics.concentration_risk, + "volatility_exposure": risk_output.metrics.volatility_exposure, + "liquidity_risk": risk_output.metrics.liquidity_risk, + "max_allocations": risk_output.max_allocation_per_asset, + "min_cash_reserve": risk_output.min_cash_reserve, + "recommendations": risk_output.recommendations, + "reasoning": risk_output.reasoning + } + elif isinstance(risk_output, dict): + risk_data = risk_output + + allowed_assets = cryptocurrencies + [reference_market] + + return f""" +# Determine Portfolio Distribution + +## Allowed Assets +{json.dumps(allowed_assets, indent=2)} + +## Current Distribution (percentages) +{json.dumps(current_distribution, indent=2)} + +## Signal Synthesis (from Signal Agent) +{json.dumps(synthesis_data, indent=2, default=str)} + +## Risk Assessment (from Risk Agent) +{json.dumps(risk_data, indent=2, default=str)} + +## Reference Market (Stablecoin/Cash) +{reference_market} + +## Task +Based on the synthesized signals and risk assessment: +1. Determine target allocation percentage for each asset +2. Specify the action (increase/decrease/maintain/add/remove) +3. Ensure total allocations sum to exactly 100% +4. Respect risk constraints (max allocations, min cash reserve) +5. Set rebalancing urgency +6. Provide reasoning for decisions + +Remember: +- Percentages must sum to 100% +- Only use allowed assets +- Balance opportunity with risk +""" + + async def execute(self, state: AIAgentState, llm_service) -> typing.Any: + """ + Execute distribution decision. + + Args: + state: The current agent state. + llm_service: The LLM service instance. + + Returns: + Dictionary with distribution_output. + """ + self.logger.info(f"Starting {self.name}...") + + try: + messages = [ + {"role": "system", "content": self.prompt}, + {"role": "user", "content": self._build_user_prompt(state)}, + ] + + response_data = await self._call_llm( + messages, + llm_service, + json_output=True, + response_schema=DistributionOutput, + ) + + # Parse into model + distribution_output = DistributionOutput(**response_data) + + self.logger.info(f"{self.name} completed successfully.") + + return {"distribution_output": distribution_output} + + except Exception as e: + self.logger.exception(f"Error in {self.name}: {e}") + return {} + + +async def run_distribution_agent(state: AIAgentState, llm_service) -> dict: + """ + Convenience function to run the distribution agent. + + Args: + state: The current agent state. + llm_service: The LLM service instance. + + Returns: + State updates from the agent. + """ + agent = DistributionAgent() + return await agent.execute(state, llm_service) diff --git a/Trading/Mode/ai_trading_mode/agents/models.py b/Trading/Mode/ai_trading_mode/agents/models.py new file mode 100644 index 000000000..cd33c5c3f --- /dev/null +++ b/Trading/Mode/ai_trading_mode/agents/models.py @@ -0,0 +1,240 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Pydantic models for AI agent outputs. +""" +from typing import Dict, List, Optional +from pydantic import BaseModel, Field, field_validator + + +class SignalRecommendation(BaseModel): + """A trading signal recommendation for an asset.""" + action: str = Field( + description="Trading action: 'buy', 'sell', 'hold', 'increase', 'decrease'." + ) + confidence: float = Field( + default=0.5, + ge=0.0, + le=1.0, + description="Confidence level of the signal (0 to 1)." + ) + reasoning: str = Field( + description="Explanation of why this signal was generated." + ) + + @field_validator("action") + def validate_action(cls, v: str) -> str: + allowed_actions = ["buy", "sell", "hold", "increase", "decrease"] + v_lower = v.lower() + if v_lower not in allowed_actions: + raise ValueError(f"Action must be one of {allowed_actions}") + return v_lower + + +class CryptoSignalOutput(BaseModel): + """Output from a cryptocurrency signal agent.""" + cryptocurrency: str = Field(description="The cryptocurrency being analyzed.") + signal: SignalRecommendation = Field(description="The trading signal for this cryptocurrency.") + market_context: str = Field(description="Brief description of current market context.") + key_factors: List[str] = Field( + default_factory=list, + description="Key factors influencing this signal." + ) + + +class RiskMetrics(BaseModel): + """Portfolio risk metrics.""" + overall_risk_level: str = Field( + description="Overall risk level: 'low', 'medium', 'high', 'critical'." + ) + concentration_risk: float = Field( + ge=0.0, + le=1.0, + description="Risk from over-concentration in few assets (0-1)." + ) + volatility_exposure: float = Field( + ge=0.0, + le=1.0, + description="Exposure to volatile assets (0-1)." + ) + liquidity_risk: float = Field( + ge=0.0, + le=1.0, + description="Risk from illiquid positions (0-1)." + ) + + @field_validator("overall_risk_level") + def validate_risk_level(cls, v: str) -> str: + allowed_levels = ["low", "medium", "high", "critical"] + v_lower = v.lower() + if v_lower not in allowed_levels: + raise ValueError(f"Risk level must be one of {allowed_levels}") + return v_lower + + +class RiskAssessmentOutput(BaseModel): + """Output from the risk assessment agent.""" + metrics: RiskMetrics = Field(description="Calculated risk metrics.") + recommendations: List[str] = Field( + description="Risk mitigation recommendations." + ) + max_allocation_per_asset: Dict[str, float] = Field( + default_factory=dict, + description="Maximum recommended allocation percentage per asset." + ) + min_cash_reserve: float = Field( + default=0.1, + ge=0.0, + le=1.0, + description="Minimum recommended cash/stablecoin reserve (0-1)." + ) + reasoning: str = Field(description="Explanation of the risk assessment.") + + +class SynthesizedSignal(BaseModel): + """A synthesized signal for an asset combining multiple signal sources.""" + asset: str = Field(description="The asset symbol.") + direction: str = Field( + description="Synthesized direction: 'bullish', 'bearish', 'neutral'." + ) + strength: float = Field( + ge=0.0, + le=1.0, + description="Signal strength (0-1)." + ) + consensus_level: str = Field( + description="Level of agreement between signals: 'strong', 'moderate', 'weak', 'conflicting'." + ) + trading_instruction: str = Field( + description="Clear trading instruction derived from signals." + ) + + @field_validator("direction") + def validate_direction(cls, v: str) -> str: + allowed_directions = ["bullish", "bearish", "neutral"] + v_lower = v.lower() + if v_lower not in allowed_directions: + raise ValueError(f"Direction must be one of {allowed_directions}") + return v_lower + + @field_validator("consensus_level") + def validate_consensus(cls, v: str) -> str: + allowed_levels = ["strong", "moderate", "weak", "conflicting"] + v_lower = v.lower() + if v_lower not in allowed_levels: + raise ValueError(f"Consensus level must be one of {allowed_levels}") + return v_lower + + +class SignalSynthesisOutput(BaseModel): + """Output from the signal manager agent - synthesizes all signals.""" + synthesized_signals: List[SynthesizedSignal] = Field( + description="List of synthesized signals per asset." + ) + market_outlook: str = Field( + description="Overall market outlook: 'bullish', 'bearish', 'neutral', 'mixed'." + ) + summary: str = Field( + description="Summary of the synthesized signals without making decisions." + ) + + +class AssetDistribution(BaseModel): + """Distribution allocation for a single asset.""" + asset: str = Field(description="Asset symbol.") + percentage: float = Field( + ge=0.0, + le=100.0, + description="Allocation percentage (0-100)." + ) + action: str = Field( + description="Action to take: 'increase', 'decrease', 'maintain', 'add', 'remove'." + ) + explanation: str = Field( + description="Explanation for this allocation." + ) + + @field_validator("action") + def validate_action(cls, v: str) -> str: + allowed_actions = ["increase", "decrease", "maintain", "add", "remove"] + v_lower = v.lower() + if v_lower not in allowed_actions: + raise ValueError(f"Action must be one of {allowed_actions}") + return v_lower + + +class DistributionOutput(BaseModel): + """Output from the distribution agent - final portfolio distribution.""" + distributions: List[AssetDistribution] = Field( + description="Target distribution for each asset." + ) + rebalance_urgency: str = Field( + description="Urgency of rebalancing: 'immediate', 'soon', 'low', 'none'." + ) + reasoning: str = Field( + description="Overall reasoning for the distribution decisions." + ) + + @field_validator("rebalance_urgency") + def validate_urgency(cls, v: str) -> str: + allowed_urgency = ["immediate", "soon", "low", "none"] + v_lower = v.lower() + if v_lower not in allowed_urgency: + raise ValueError(f"Urgency must be one of {allowed_urgency}") + return v_lower + + def get_distribution_dict(self) -> Dict[str, float]: + """Convert distributions to a simple dict format.""" + return {d.asset: d.percentage for d in self.distributions} + + def get_ai_instructions(self) -> List[dict]: + """Convert distributions to AI instruction format for ai_index_distribution.""" + from tentacles.Trading.Mode.ai_trading_mode import ai_index_distribution + + instructions = [] + for dist in self.distributions: + if dist.action == "increase": + instructions.append({ + ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_INCREASE_EXPOSURE, + ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, + ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, + }) + elif dist.action == "decrease": + instructions.append({ + ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_REDUCE_EXPOSURE, + ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, + ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, + }) + elif dist.action == "add": + instructions.append({ + ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_ADD_TO_DISTRIBUTION, + ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, + ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, + }) + elif dist.action == "remove": + instructions.append({ + ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_REMOVE_FROM_DISTRIBUTION, + ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, + }) + elif dist.action == "maintain": + instructions.append({ + ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_UPDATE_RATIO, + ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, + ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, + }) + + return instructions diff --git a/Trading/Mode/ai_trading_mode/agents/risk_agent.py b/Trading/Mode/ai_trading_mode/agents/risk_agent.py new file mode 100644 index 000000000..1bb0e66b8 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/agents/risk_agent.py @@ -0,0 +1,205 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Risk Assessment Agent. +Evaluates portfolio risk using trading API data. +""" +import json +import typing + +from pydantic import BaseModel + +from tentacles.Trading.Mode.ai_trading_mode.agents.base_agent import BaseAgent +from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState +from tentacles.Trading.Mode.ai_trading_mode.agents.models import RiskAssessmentOutput, CryptoSignalOutput + + +class RiskAgent(BaseAgent): + """ + Risk assessment agent that evaluates portfolio risk. + Uses portfolio data from trading API to assess concentration, volatility, and liquidity risks. + """ + + AGENT_NAME = "RiskAgent" + AGENT_VERSION = "1.0.0" + + def __init__(self, model=None, max_tokens=None, temperature=None): + """ + Initialize the risk agent. + + Args: + model: LLM model to use. + max_tokens: Maximum tokens for response. + temperature: Temperature for LLM randomness. + """ + super().__init__( + name=self.AGENT_NAME, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + + def _get_default_prompt(self) -> str: + """Return the default system prompt.""" + return """ +You are a Portfolio Risk Assessment Agent for cryptocurrency trading. +Your task is to evaluate the current portfolio risk and provide risk mitigation recommendations. + +## Your Role +- Analyze the current portfolio holdings and their distribution. +- Evaluate concentration risk (over-exposure to single assets). +- Assess volatility exposure based on asset types. +- Consider liquidity risk from position sizes. +- Incorporate signal outputs from individual cryptocurrency analysis. + +## Risk Levels +- "low": Portfolio is well-diversified with manageable risk +- "medium": Some concentration or volatility concerns +- "high": Significant risk factors present +- "critical": Immediate action recommended to reduce risk + +## Important Rules +- Base your analysis ONLY on the provided portfolio and signal data. +- Provide actionable recommendations. +- Set realistic maximum allocation limits per asset. +- Consider the reference market (stablecoin) as the safe haven. + +## Output Requirements - CRITICAL VALUE RANGES +Output a JSON object with: +- "overall_risk_level": EXACTLY one of "low", "medium", "high", "critical" +- "metrics": Object with: + - "overall_risk_level": EXACTLY one of "low", "medium", "high", "critical" + - "concentration_risk": Number between 0 and 1 (0=safe, 1=dangerous) + - "volatility_exposure": Number between 0 and 1 (0=stable, 1=volatile) + - "liquidity_risk": Number between 0 and 1 (0=liquid, 1=illiquid) +- "max_allocation_per_asset": Object mapping asset to max percentage (0-100 range, e.g., 25 means 25%) +- "min_cash_reserve": Minimum recommended cash reserve as DECIMAL between 0 and 1 (e.g., 0.1 means 10%, 0.2 means 20%) +- "recommendations": Array of risk mitigation recommendations +- "reasoning": Explanation of the risk assessment +""" + + def _build_user_prompt(self, state: AIAgentState) -> str: + """Build the user prompt with portfolio data.""" + portfolio = state.get("portfolio", {}) + orders = state.get("orders", {}) + signal_outputs = state.get("signal_outputs", {}).get("signals", {}) + current_distribution = state.get("current_distribution", {}) + cryptocurrencies = state.get("cryptocurrencies", []) + + # Format signal summaries + signal_summary = {} + for crypto, signal in signal_outputs.items(): + if isinstance(signal, CryptoSignalOutput): + signal_summary[crypto] = { + "action": signal.signal.action, + "confidence": signal.signal.confidence, + "reasoning": signal.signal.reasoning + } + elif isinstance(signal, dict): + signal_data = signal.get("signal", {}) + signal_summary[crypto] = { + "action": signal_data.get("action", "unknown"), + "confidence": signal_data.get("confidence", 0), + "reasoning": signal_data.get("reasoning", "") + } + + portfolio_str = json.dumps(portfolio, indent=2, default=str) if portfolio else "No portfolio data" + orders_str = json.dumps(orders, indent=2, default=str) if orders else "No orders" + + return f""" +# Evaluate Portfolio Risk + +## Portfolio Holdings +{portfolio_str} + +## Current Distribution (percentages) +{json.dumps(current_distribution, indent=2)} + +## Open Orders +{orders_str} + +## Tracked Cryptocurrencies +{json.dumps(cryptocurrencies, indent=2)} + +## Signal Outputs from Crypto Agents +{json.dumps(signal_summary, indent=2, default=str)} + +## Reference Market +{portfolio.get('reference_market', 'USD')} + +## Task +Evaluate the portfolio risk considering: +1. Concentration risk from any single asset dominating the portfolio +2. Volatility exposure based on the assets held +3. Liquidity risk from position sizes +4. Open orders that may affect risk profile +5. Signals suggesting increased volatility or directional moves + +Provide risk metrics, maximum allocation limits, and mitigation recommendations as JSON. +""" + + async def execute(self, state: AIAgentState, llm_service) -> typing.Any: + """ + Execute risk assessment. + + Args: + state: The current agent state. + llm_service: The LLM service instance. + + Returns: + Dictionary with risk_output. + """ + self.logger.info(f"Starting {self.name}...") + + try: + messages = [ + {"role": "system", "content": self.prompt}, + {"role": "user", "content": self._build_user_prompt(state)}, + ] + + response_data = await self._call_llm( + messages, + llm_service, + json_output=True, + response_schema=RiskAssessmentOutput, + ) + + # Parse into model + risk_output = RiskAssessmentOutput(**response_data) + + self.logger.info(f"{self.name} completed successfully.") + + return {"risk_output": risk_output} + + except Exception as e: + self.logger.exception(f"Error in {self.name}: {e}") + return {} + + +async def run_risk_agent(state: AIAgentState, llm_service) -> dict: + """ + Convenience function to run the risk agent. + + Args: + state: The current agent state. + llm_service: The LLM service instance. + + Returns: + State updates from the agent. + """ + agent = RiskAgent() + return await agent.execute(state, llm_service) diff --git a/Trading/Mode/ai_trading_mode/agents/signal_agent.py b/Trading/Mode/ai_trading_mode/agents/signal_agent.py new file mode 100644 index 000000000..cbeffbe9d --- /dev/null +++ b/Trading/Mode/ai_trading_mode/agents/signal_agent.py @@ -0,0 +1,242 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Signal Agent. +Analyzes all cryptocurrencies and generates both individual and synthesized signals. +Combines per-crypto analysis with overall market signal synthesis in a single agent. +""" +import json +import typing + +from pydantic import BaseModel + +from tentacles.Trading.Mode.ai_trading_mode.agents.base_agent import BaseAgent +from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState +from tentacles.Trading.Mode.ai_trading_mode.agents.models import CryptoSignalOutput, SignalSynthesisOutput + + +class SignalAgent(BaseAgent): + """ + Signal agent that analyzes all cryptocurrencies and synthesizes signals. + + This agent: + 1. Analyzes each cryptocurrency against all available data + 2. Generates individual signals with confidence levels + 3. Synthesizes signals across all cryptos to identify market consensus + """ + + AGENT_NAME = "SignalAgent" + AGENT_VERSION = "1.0.0" + + def __init__(self, model=None, max_tokens=None, temperature=None): + """ + Initialize the signal agent. + + Args: + model: LLM model to use. + max_tokens: Maximum tokens for response. + temperature: Temperature for LLM randomness. + """ + super().__init__( + name=self.AGENT_NAME, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + + def _get_default_prompt(self) -> str: + """Return the default system prompt.""" + return """ +You are a Comprehensive Signal Analysis Agent for cryptocurrency portfolio management. +Your task is to analyze all tracked cryptocurrencies and generate both individual trading signals and synthesized market signals. + +## Your Dual Role + +### Part 1: Per-Cryptocurrency Analysis +- Analyze each cryptocurrency provided +- Consider global market strategy data and crypto-specific data +- Consider current portfolio holdings and open orders +- Generate a clear trading signal with confidence level +- Identify key factors driving each signal + +### Part 2: Signal Synthesis (CRITICAL) +- Identify consensus across cryptocurrency signals +- Synthesize signals into clear trading instructions +- Provide overall market outlook +- Do NOT make allocation decisions - only synthesize + +## Per-Crypto Signal Actions (for "action" field only) +- "buy": Strong bullish signal, recommend increasing position +- "sell": Strong bearish signal, recommend decreasing position +- "hold": Neutral signal, recommend maintaining current position +- "increase": Moderate bullish, suggest gradual increase +- "decrease": Moderate bearish, suggest gradual decrease + +## CRITICAL: Synthesis Direction Values (for "direction" field ONLY) +When synthesizing signals, ALWAYS use ONLY these exact values for direction: +- "bullish": Positive market direction +- "bearish": Negative market direction +- "neutral": No clear direction + +DO NOT use "buy", "sell", "hold", "increase", or "decrease" in the direction field. ONLY use: bullish, bearish, or neutral. + +## Consensus Levels (for "consensus_level" field) +Must be EXACTLY one of: +- "strong": High agreement (>0.7 confidence) +- "moderate": Moderate agreement (0.5-0.7) +- "weak": Low agreement or mixed signals +- "conflicting": Opposing signals + +## Market Outlook (for "market_outlook" field) +Must be EXACTLY one of: +- "bullish": Majority positive signals +- "bearish": Majority negative signals +- "neutral": Balanced or low conviction +- "mixed": Strong conflicting signals + +Be precise, data-driven, and base all recommendations ONLY on provided data. +""" + + def _build_user_prompt(self, state: AIAgentState) -> str: + """Build the user prompt with all available data.""" + global_strategy = state.get("global_strategy_data", {}) + crypto_strategy = state.get("crypto_strategy_data", {}) + cryptocurrencies = state.get("cryptocurrencies", []) + portfolio = state.get("portfolio", {}) + orders = state.get("orders", {}) + current_distribution = state.get("current_distribution", {}) + + portfolio_str = json.dumps(portfolio, indent=2, default=str) if portfolio else "No portfolio data" + orders_str = json.dumps(orders, indent=2, default=str) if orders else "No orders" + + return f""" +# Analyze All Cryptocurrencies and Synthesize Signals + +## Global Strategy Data +{self.format_strategy_data(global_strategy)} + +## Per-Cryptocurrency Strategy Data +{self.format_strategy_data(crypto_strategy)} + +## Tracked Cryptocurrencies +{json.dumps(cryptocurrencies, indent=2)} + +## Current Portfolio Context +{portfolio_str} + +## Current Distribution +{json.dumps(current_distribution, indent=2)} + +## Open Orders +{orders_str} + +## Reference Market +{portfolio.get('reference_market', 'USD')} + +## Task + +1. **Generate Individual Signals**: For each cryptocurrency, analyze all available data and generate: + - Trading signal (buy/sell/hold/increase/decrease) + - Confidence level (0-1) + - Reasoning based on strategy data + - Market context + - Key factors (max 5) + +2. **Synthesize Signals**: After analyzing all cryptos, synthesize them into: + - Synthesized signal for each cryptocurrency with direction and strength + - Consensus level for each asset + - Clear trading instructions (without specific percentages) + - Overall market outlook + - Summary of the synthesis + +Output a JSON object with TWO sections: +- "per_crypto_signals": Array of individual signals +- "synthesis": Overall signal synthesis + +Remember: Base ONLY on the provided data. Do not make allocation decisions - only synthesize. +""" + + async def execute(self, state: AIAgentState, llm_service) -> typing.Any: + """ + Execute signal analysis and synthesis. + + Args: + state: The current agent state. + llm_service: The LLM service instance. + + Returns: + Dictionary with signal_outputs and signal_synthesis. + """ + self.logger.info(f"Starting {self.name}...") + + # Create wrapper model for strict schema validation + class SignalAgentOutput(BaseModel): + per_crypto_signals: list[CryptoSignalOutput] + synthesis: SignalSynthesisOutput + + try: + messages = [ + {"role": "system", "content": self.prompt}, + {"role": "user", "content": self._build_user_prompt(state)}, + ] + + response_data = await self._call_llm( + messages, + llm_service, + json_output=True, + response_schema=SignalAgentOutput, + ) + + # Process per-crypto signals + signal_outputs = {"signals": {}} + per_crypto = response_data.get("per_crypto_signals", []) + + for signal_data in per_crypto: + crypto = signal_data.get("cryptocurrency", "") + if crypto: + signal_output = CryptoSignalOutput(**signal_data) + signal_outputs["signals"][crypto] = signal_output + + # Process synthesis + synthesis_data = response_data.get("synthesis", {}) + synthesis_output = SignalSynthesisOutput(**synthesis_data) if synthesis_data else None + + self.logger.info(f"{self.name} completed successfully.") + + return { + "signal_outputs": signal_outputs, + "signal_synthesis": synthesis_output, + } + + except Exception as e: + self.logger.exception(f"Error in {self.name}: {e}") + return {} + + +async def run_signal_agent(state: AIAgentState, llm_service) -> dict: + """ + Convenience function to run the signal agent. + + Args: + state: The current agent state. + llm_service: The LLM service instance. + + Returns: + State updates from the agent. + """ + agent = SignalAgent() + return await agent.execute(state, llm_service) diff --git a/Trading/Mode/ai_trading_mode/agents/state.py b/Trading/Mode/ai_trading_mode/agents/state.py new file mode 100644 index 000000000..ef53f42b0 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/agents/state.py @@ -0,0 +1,97 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +AI Agent State definitions for the trading mode agents. +""" +import operator +from typing import Dict, List, Optional, Any +from typing_extensions import Annotated, TypedDict + +from tentacles.Trading.Mode.ai_trading_mode.agents.models import ( + CryptoSignalOutput, + RiskAssessmentOutput, + SignalSynthesisOutput, + DistributionOutput, +) + + +def merge_dicts(a: dict, b: dict) -> dict: + """Merge two dictionaries, with b overwriting a.""" + return {**a, **b} + + +def replace_value(a: Any, b: Any) -> Any: + """Replace value a with value b.""" + return b + + +class PortfolioState(TypedDict, total=False): + """Current portfolio state from trading API.""" + holdings: Dict[str, float] # {asset: amount} + holdings_value: Dict[str, float] # {asset: value_in_reference_market} + total_value: float + reference_market: str + available_balance: float + + +class OrdersState(TypedDict, total=False): + """Current orders state from trading API.""" + open_orders: List[Dict[str, Any]] + pending_orders: List[Dict[str, Any]] + recent_trades: List[Dict[str, Any]] + + +class StrategyData(TypedDict, total=False): + """Strategy evaluation data.""" + eval_note: float + description: str + metadata: Dict[str, Any] + cryptocurrency: Optional[str] + symbol: Optional[str] + evaluation_type: str + + +class SignalAgentsOutput(TypedDict, total=False): + """Output from all signal agents.""" + signals: Annotated[Dict[str, CryptoSignalOutput], merge_dicts] + + +class AIAgentState(TypedDict, total=False): + """ + Shared state for all AI trading agents. + Contains strategy data, portfolio info, and agent outputs. + """ + # Input data + global_strategy_data: Dict[str, List[StrategyData]] + crypto_strategy_data: Dict[str, Dict[str, List[StrategyData]]] # {cryptocurrency: strategy_data} + cryptocurrencies: List[str] + reference_market: str + + # Trading context + portfolio: Annotated[PortfolioState, merge_dicts] + orders: Annotated[OrdersState, merge_dicts] + current_distribution: Dict[str, float] # Current portfolio distribution percentages + + # Agent outputs + signal_outputs: Annotated[SignalAgentsOutput, merge_dicts] + risk_output: Annotated[Optional[RiskAssessmentOutput], replace_value] + signal_synthesis: Annotated[Optional[SignalSynthesisOutput], replace_value] + distribution_output: Annotated[Optional[DistributionOutput], replace_value] + + # Metadata + exchange_name: str + timestamp: str diff --git a/Trading/Mode/ai_trading_mode/agents/team.py b/Trading/Mode/ai_trading_mode/agents/team.py new file mode 100644 index 000000000..50df1a878 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/agents/team.py @@ -0,0 +1,310 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +AI Agent Team Orchestrator. +Coordinates the execution of all agents in the proper order: +1. Signal agent - analyzes all cryptocurrencies and synthesizes signals +2. Risk agent + Distribution agent (parallel) +3. Complete +""" +import asyncio +import logging +import os +from datetime import datetime +from typing import Dict, List, Any, Optional + +from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState +from tentacles.Trading.Mode.ai_trading_mode.agents.signal_agent import SignalAgent +from tentacles.Trading.Mode.ai_trading_mode.agents.risk_agent import RiskAgent +from tentacles.Trading.Mode.ai_trading_mode.agents.distribution_agent import DistributionAgent +from tentacles.Trading.Mode.ai_trading_mode.agents.models import DistributionOutput + + +class AIAgentTeam: + """ + Orchestrates the AI agent team for portfolio management. + + Execution flow: + 1. Signal agent analyzes all cryptocurrencies and synthesizes signals + 2. Risk agent and Distribution agent run in parallel (after signals complete) + 3. Complete + """ + + def __init__(self, llm_service): + """ + Initialize the agent team. + + Args: + llm_service: The LLM service instance for agent communication. + """ + self.llm_service = llm_service + self.logger = logging.getLogger("AITradingTeam") + + async def run( + self, + global_strategy_data: Dict[str, List[dict]], + crypto_strategy_data: Dict[str, Dict[str, List[dict]]], + cryptocurrencies: List[str], + portfolio: Dict[str, Any], + orders: Dict[str, Any], + current_distribution: Dict[str, float], + reference_market: str = "USD", + exchange_name: str = "unknown", + ) -> Optional[DistributionOutput]: + """ + Execute the full agent pipeline and return distribution decisions. + + Args: + global_strategy_data: Global strategy evaluation data. + crypto_strategy_data: Per-cryptocurrency strategy data. + cryptocurrencies: List of cryptocurrencies to analyze. + portfolio: Current portfolio state from trading API. + orders: Current orders state from trading API. + current_distribution: Current portfolio distribution percentages. + reference_market: The reference market/stablecoin. + exchange_name: Name of the exchange. + + Returns: + DistributionOutput with final portfolio distribution decisions, or None. + """ + self.logger.info("Starting AI agent team execution...") + + # Initialize state - use dict to avoid type issues with TypedDict + state: AIAgentState = {} # type: ignore + state["global_strategy_data"] = global_strategy_data # type: ignore + state["crypto_strategy_data"] = crypto_strategy_data # type: ignore + state["cryptocurrencies"] = cryptocurrencies + state["reference_market"] = reference_market + state["portfolio"] = portfolio # type: ignore + state["orders"] = orders # type: ignore + state["current_distribution"] = current_distribution + state["signal_outputs"] = {"signals": {}} + state["risk_output"] = None + state["signal_synthesis"] = None + state["distribution_output"] = None + state["exchange_name"] = exchange_name + state["timestamp"] = datetime.utcnow().isoformat() + + state = await self._run_signal_agent(state) + state = await self._run_risk_agent(state) + state = await self._run_distribution_agent(state) + + self.logger.info("AI agent team execution completed.") + + return state.get("distribution_output") + + async def _run_signal_agent(self, state: AIAgentState) -> AIAgentState: + """ + Run the unified signal agent that analyzes all cryptocurrencies at once. + + Args: + state: Current agent state. + + Returns: + Updated state with signal outputs and synthesis. + + Raises: + Exception: Re-raises any exception from signal agent execution. + """ + signal_agent = SignalAgent() + + try: + result = await signal_agent.execute(state, self.llm_service) + + # Extract signal outputs + if "signal_outputs" in result: + state["signal_outputs"] = result["signal_outputs"] # type: ignore + + # Extract signal synthesis + if "signal_synthesis" in result: + state["signal_synthesis"] = result["signal_synthesis"] + + self.logger.info("Signal agent completed successfully.") + except Exception as e: + self.logger.error(f"Signal agent failed: {e}") + # Critical failure - signal analysis is required for downstream agents + raise Exception(f"Signal agent execution failed, cannot proceed: {e}") from e + + return state + + async def _run_risk_agent(self, state: AIAgentState) -> AIAgentState: + """ + Run the risk assessment agent with signal outputs. + + Args: + state: Current agent state with signal outputs. + + Returns: + Updated state with risk output. + + Raises: + Exception: If risk agent fails. + """ + risk_agent = RiskAgent() + + try: + result = await risk_agent.execute(state, self.llm_service) + + # Extract risk output + if "risk_output" in result: + state["risk_output"] = result["risk_output"] + + self.logger.info("Risk agent completed successfully.") + except Exception as e: + self.logger.error(f"Risk agent failed: {e}") + raise Exception(f"Risk agent execution failed: {e}") from e + + return state + + async def _run_distribution_agent(self, state: AIAgentState) -> AIAgentState: + """ + Run the distribution decision agent with signal and risk outputs. + + Args: + state: Current agent state with signal and risk outputs. + + Returns: + Updated state with distribution output. + + Raises: + Exception: If distribution agent fails. + """ + distribution_agent = DistributionAgent() + + try: + result = await distribution_agent.execute(state, self.llm_service) + + # Extract distribution output + if "distribution_output" in result: + state["distribution_output"] = result["distribution_output"] + + self.logger.info("Distribution agent completed successfully.") + except Exception as e: + self.logger.error(f"Distribution agent failed: {e}") + raise Exception(f"Distribution agent execution failed: {e}") from e + + return state + + @staticmethod + def build_portfolio_state( + exchange_manager, + ) -> Dict[str, Any]: + """ + Build portfolio state from exchange manager. + + Args: + exchange_manager: The OctoBot exchange manager. + + Returns: + Portfolio state dictionary. + """ + import octobot_trading.api as trading_api + + portfolio = trading_api.get_portfolio(exchange_manager) + reference_market = trading_api.get_portfolio_reference_market(exchange_manager) + + holdings = {} + holdings_value = {} + total_value = 0 + + for asset, amount in portfolio.items(): + if hasattr(amount, 'total'): + holdings[asset] = float(amount.total) + elif isinstance(amount, dict): + holdings[asset] = float(amount.get('total', 0)) + else: + holdings[asset] = float(amount) + + # Get portfolio value + try: + portfolio_value_holder = exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder + total_value = float(portfolio_value_holder.get_traded_assets_holdings_value(reference_market)) + except Exception: + total_value = 0 + + # Get available balance + try: + available_balance = float( + exchange_manager.exchange_personal_data.portfolio_manager.portfolio + .get_currency_portfolio(reference_market).available + ) + except Exception: + available_balance = 0 + + return { + "holdings": holdings, + "holdings_value": holdings_value, + "total_value": total_value, + "reference_market": reference_market, + "available_balance": available_balance, + } + + @staticmethod + def build_orders_state(exchange_manager) -> Dict[str, Any]: + """ + Build orders state from exchange manager. + + Args: + exchange_manager: The OctoBot exchange manager. + + Returns: + Orders state dictionary. + """ + import octobot_trading.api as trading_api + + try: + open_orders = trading_api.get_open_orders(exchange_manager) + orders_list = [ + { + "symbol": order.symbol, + "side": order.side.value if hasattr(order.side, 'value') else str(order.side), + "type": order.order_type.value if hasattr(order.order_type, 'value') else str(order.order_type), + "amount": float(order.origin_quantity), + "price": float(order.origin_price) if order.origin_price else None, + "status": order.status.value if hasattr(order.status, 'value') else str(order.status), + } + for order in open_orders + ] + except Exception: + orders_list = [] + + return { + "open_orders": orders_list, + "pending_orders": [], + "recent_trades": [], + } + + @staticmethod + def build_current_distribution(trading_mode) -> Dict[str, float]: + """ + Build current distribution from trading mode. + + Args: + trading_mode: The AI trading mode instance. + + Returns: + Dictionary of asset to percentage distribution. + """ + if not hasattr(trading_mode, 'ratio_per_asset') or not trading_mode.ratio_per_asset: + return {} + + from tentacles.Trading.Mode.index_trading_mode import index_distribution + + return { + asset: float(data.get(index_distribution.DISTRIBUTION_VALUE, 0)) + for asset, data in trading_mode.ratio_per_asset.items() + } diff --git a/Trading/Mode/ai_trading_mode/ai_index_distribution.py b/Trading/Mode/ai_trading_mode/ai_index_distribution.py new file mode 100644 index 000000000..82ee2a028 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/ai_index_distribution.py @@ -0,0 +1,113 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +import decimal + +import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution + +# Instruction constants +INSTRUCTION_ACTION = "action" +INSTRUCTION_SYMBOL = "symbol" +INSTRUCTION_AMOUNT = "amount" +INSTRUCTION_WEIGHT = "weight" + +# Action types +ACTION_REDUCE_EXPOSURE = "reduce_exposure" +ACTION_INCREASE_EXPOSURE = "increase_exposure" +ACTION_ADD_TO_DISTRIBUTION = "add_to_distribution" +ACTION_REMOVE_FROM_DISTRIBUTION = "remove_from_distribution" +ACTION_UPDATE_RATIO = "update_ratio" +ACTION_INCREASE_FIAT_RATIO = "increase_fiat_ratio" +ACTION_DECREASE_FIAT_RATIO = "decrease_fiat_ratio" + + +def apply_ai_instructions(trading_mode, instructions: list): + """ + Apply AI-generated instructions to update the portfolio distribution. + """ + try: + current_distribution = {} + total_weight = decimal.Decimal(0) + + # Start with current distribution + if trading_mode.ratio_per_asset: + for asset, item in trading_mode.ratio_per_asset.items(): + current_distribution[asset] = item[ + index_distribution.DISTRIBUTION_VALUE + ] + total_weight += decimal.Decimal(str(item[index_distribution.DISTRIBUTION_VALUE])) + + # Apply instructions + for instruction in instructions: + action = instruction.get(INSTRUCTION_ACTION) + symbol = instruction.get(INSTRUCTION_SYMBOL) + amount = instruction.get( + INSTRUCTION_AMOUNT, instruction.get(INSTRUCTION_WEIGHT, 0) + ) + + if action == ACTION_REDUCE_EXPOSURE and symbol: + if symbol in current_distribution: + current_distribution[symbol] = max( + 0, current_distribution[symbol] - amount + ) + elif action == ACTION_INCREASE_EXPOSURE and symbol: + if symbol in current_distribution: + current_distribution[symbol] += amount + else: + current_distribution[symbol] = amount + elif action == ACTION_ADD_TO_DISTRIBUTION and symbol: + current_distribution[symbol] = amount + elif action == ACTION_REMOVE_FROM_DISTRIBUTION and symbol: + current_distribution.pop(symbol, None) + elif action == ACTION_UPDATE_RATIO and symbol: + current_distribution[symbol] = amount + elif action == ACTION_INCREASE_FIAT_RATIO: + # Assume USD or ref market + ref_market = trading_mode.exchange_manager.exchange_personal_data.portfolio_manager.reference_market + if ref_market in current_distribution: + current_distribution[ref_market] += amount + else: + current_distribution[ref_market] = amount + elif action == ACTION_DECREASE_FIAT_RATIO: + ref_market = trading_mode.exchange_manager.exchange_personal_data.portfolio_manager.reference_market + if ref_market in current_distribution: + current_distribution[ref_market] = max( + 0, current_distribution[ref_market] - amount + ) + + # Normalize to 100% + total = sum(current_distribution.values()) + if total > 0: + for asset in current_distribution: + current_distribution[asset] = ( + current_distribution[asset] / total + ) * 100 + + # Update trading_mode.ratio_per_asset + trading_mode.ratio_per_asset = { + asset: { + index_distribution.DISTRIBUTION_NAME: asset, + index_distribution.DISTRIBUTION_VALUE: weight, + } + for asset, weight in current_distribution.items() + } + trading_mode.total_ratio_per_asset = decimal.Decimal(100) + + # Update indexed_coins + trading_mode.indexed_coins = list(current_distribution.keys()) + + except Exception as e: + trading_mode.logger.exception(f"Error applying AI instructions: {e}") diff --git a/Trading/Mode/ai_trading_mode/ai_index_trading.py b/Trading/Mode/ai_trading_mode/ai_index_trading.py new file mode 100644 index 000000000..baa5f28f6 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/ai_index_trading.py @@ -0,0 +1,404 @@ +# Drakkar-Software OctoBot +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +AI Index Trading Mode module for OctoBot. +Handles AI-driven portfolio rebalancing based on strategy evaluations and external agent instructions. +""" +import os +import typing + +import octobot_commons.enums as commons_enums +import octobot_commons.constants as commons_constants +import octobot_evaluators.enums as evaluators_enums +from octobot_evaluators import matrix +import octobot_evaluators.api as evaluators_api +import octobot_commons.evaluators_util as evaluators_util +import octobot_evaluators.constants as evaluators_constants +import octobot_trading.enums as trading_enums +import octobot_trading.modes as trading_modes +import octobot_trading.constants as trading_constants +import octobot_services.api.services as services_api +import tentacles.Services.Services_bases + +from tentacles.Trading.Mode.ai_trading_mode import ai_index_distribution +from tentacles.Trading.Mode.index_trading_mode import index_trading +from tentacles.Trading.Mode.ai_trading_mode.agents.team import AIAgentTeam + +# Data keys +STRATEGY_DATA_KEY = "strategy_data" +CRYPTO_STRATEGY_DATA_KEY = "crypto_strategy_data" +GLOBAL_STRATEGY_DATA_KEY = "global_strategy_data" +AI_INSTRUCTIONS_KEY = "ai_instructions" + + +class AIIndexTradingModeProducer(index_trading.IndexTradingModeProducer): + def __init__(self, channel, config, trading_mode, exchange_manager): + super().__init__(channel, config, trading_mode, exchange_manager) + # Track global strategy data separately from crypto-specific data + self._global_strategy_data = {} + self._crypto_strategy_data = {} # {cryptocurrency: strategy_data} + self.services_config = None + + async def set_final_eval( + self, + matrix_id: str, + cryptocurrency: typing.Optional[str], + symbol: typing.Optional[str], + time_frame, + trigger_source: str, + ) -> None: + """ + Collect all strategy evaluations and trigger crypto-specific agent analysis. + Only triggers analysis when both global and crypto-specific data are available. + """ + if cryptocurrency is None: + # This is a global evaluation + global_strategy_data = self._collect_global_strategy_data(matrix_id) + if global_strategy_data: + self._global_strategy_data = global_strategy_data + else: + # This is a cryptocurrency-specific evaluation + crypto_strategy_data = self._collect_crypto_strategy_data( + matrix_id, cryptocurrency, symbol, time_frame + ) + if crypto_strategy_data: + self._crypto_strategy_data[cryptocurrency] = crypto_strategy_data + await self._trigger_crypto_analysis( + crypto_strategy_data, cryptocurrency, symbol, time_frame + ) + + def _collect_global_strategy_data(self, matrix_id: str) -> dict: + """ + Collect strategy data from global evaluations (cryptocurrency=None). + These come from GlobalLLMAIStrategyEvaluator. + """ + strategy_data = {} + strategy_type = evaluators_enums.EvaluatorMatrixTypes.STRATEGIES.value + tentacle_nodes = matrix.get_tentacle_nodes( + matrix_id=matrix_id, + exchange_name=self.exchange_name, + tentacle_type=strategy_type, + ) + # Get global strategy nodes (cryptocurrency=None) + for evaluated_strategy_node in matrix.get_tentacles_value_nodes( + matrix_id, + tentacle_nodes, + cryptocurrency=None, + symbol=None, + time_frame=None, + ): + if evaluators_util.check_valid_eval_note( + evaluators_api.get_value(evaluated_strategy_node), + evaluators_api.get_type(evaluated_strategy_node), + evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE, + ): + eval_note = evaluators_api.get_value(evaluated_strategy_node) + note_description = evaluators_api.get_description(evaluated_strategy_node) + note_metadata = evaluators_api.get_metadata(evaluated_strategy_node) + + if strategy_type not in strategy_data: + strategy_data[strategy_type] = [] + + strategy_data[strategy_type].append( + { + "eval_note": eval_note, + "description": note_description, + "metadata": note_metadata, + "cryptocurrency": None, + "symbol": None, + "evaluation_type": "global", + } + ) + + return strategy_data + + def _collect_crypto_strategy_data( + self, + matrix_id: str, + cryptocurrency: str, + symbol: typing.Optional[str], + time_frame=None, + ) -> dict: + """ + Collect strategy data from cryptocurrency-specific evaluations. + These come from CryptoLLMAIStrategyEvaluator. + """ + strategy_data = {} + strategy_type = evaluators_enums.EvaluatorMatrixTypes.STRATEGIES.value + tentacle_nodes = matrix.get_tentacle_nodes( + matrix_id=matrix_id, + exchange_name=self.exchange_name, + tentacle_type=strategy_type, + ) + for evaluated_strategy_node in matrix.get_tentacles_value_nodes( + matrix_id, + tentacle_nodes, + cryptocurrency=cryptocurrency, + symbol=symbol, + time_frame=time_frame, + ): + if evaluators_util.check_valid_eval_note( + evaluators_api.get_value(evaluated_strategy_node), + evaluators_api.get_type(evaluated_strategy_node), + evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE, + ): + eval_note = evaluators_api.get_value(evaluated_strategy_node) + note_description = evaluators_api.get_description(evaluated_strategy_node) + note_metadata = evaluators_api.get_metadata(evaluated_strategy_node) + + if strategy_type not in strategy_data: + strategy_data[strategy_type] = [] + + strategy_data[strategy_type].append( + { + "eval_note": eval_note, + "description": note_description, + "metadata": note_metadata, + "cryptocurrency": cryptocurrency, + "symbol": symbol, + "evaluation_type": "crypto_specific", + } + ) + + return strategy_data + + def _collect_all_strategy_data( + self, matrix_id: str, cryptocurrency: str, symbol: str, time_frame=None + ) -> dict: + """ + Legacy method: Collect all strategy data (both global and crypto-specific). + Kept for backwards compatibility. + """ + strategy_data = {} + strategy_type = evaluators_enums.EvaluatorMatrixTypes.STRATEGIES.value + tentacle_nodes = matrix.get_tentacle_nodes( + matrix_id=matrix_id, + exchange_name=self.exchange_name, + tentacle_type=strategy_type, + ) + for evaluated_strategy_node in matrix.get_tentacles_value_nodes( + matrix_id, + tentacle_nodes, + cryptocurrency=cryptocurrency, + symbol=symbol, + time_frame=time_frame, + ): + if evaluators_util.check_valid_eval_note( + evaluators_api.get_value(evaluated_strategy_node), + evaluators_api.get_type(evaluated_strategy_node), + evaluators_constants.EVALUATOR_EVAL_DEFAULT_TYPE, + ): + eval_note = evaluators_api.get_value(evaluated_strategy_node) + note_description = evaluators_api.get_description(evaluated_strategy_node) + note_metadata = evaluators_api.get_metadata(evaluated_strategy_node) + + if strategy_type not in strategy_data: + strategy_data[strategy_type] = [] + + strategy_data[strategy_type].append( + { + "eval_note": eval_note, + "description": note_description, + "metadata": note_metadata, + "cryptocurrency": cryptocurrency, + "symbol": symbol, + } + ) + + return strategy_data + + async def _trigger_crypto_analysis( + self, + crypto_strategy_data: dict, + cryptocurrency: str, + symbol: typing.Optional[str], + time_frame, + ): + """ + Handle cryptocurrency-specific strategy analysis from CryptoLLMAIStrategyEvaluator. + This is only triggered when both global and crypto-specific data are available. + Runs the AI agent team to generate portfolio distribution decisions. + """ + if not self._global_strategy_data or cryptocurrency not in self._crypto_strategy_data: + self.logger.debug( + f"Skipping crypto analysis for {cryptocurrency} as global strategy data is not available." + ) + return + + # Check if all cryptocurrencies have been analyzed + # Only run the full agent team when we have data for all tracked coins + indexed_coins = getattr(self.trading_mode, 'indexed_coins', []) + if not indexed_coins: + self.logger.debug("No indexed coins configured, skipping agent analysis.") + return + + # Check if we have crypto strategy data for all indexed coins + all_coins_ready = all( + coin in self._crypto_strategy_data + for coin in indexed_coins + ) + + if not all_coins_ready: + self.logger.debug( + f"Waiting for all crypto strategy data. " + f"Have: {list(self._crypto_strategy_data.keys())}, " + f"Need: {indexed_coins}" + ) + return + + self.logger.info("All strategy data collected. Running AI agent team...") + + try: + await self._run_agent_team() + except Exception as e: + self.logger.exception(f"Error running AI agent team: {e}") + + async def _run_agent_team(self): + """ + Run the AI agent team to analyze portfolio and generate distribution decisions. + """ + # Get LLM service + llm_service = await self._get_llm_service() + if llm_service is None: + self.logger.error("Failed to create LLM service. Check AI configuration.") + return + + # Build agent team + agent_team = AIAgentTeam(llm_service=llm_service) + + # Get portfolio and orders state + portfolio_state = AIAgentTeam.build_portfolio_state(self.exchange_manager) + orders_state = AIAgentTeam.build_orders_state(self.exchange_manager) + current_distribution = AIAgentTeam.build_current_distribution(self.trading_mode) + + # Get cryptocurrencies + cryptocurrencies = list(self._crypto_strategy_data.keys()) + reference_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market + + # Run agent team + self.logger.info("Running AI agent team for portfolio distribution analysis...") + distribution_output = await agent_team.run( + global_strategy_data=self._global_strategy_data, + crypto_strategy_data=self._crypto_strategy_data, + cryptocurrencies=cryptocurrencies, + portfolio=portfolio_state, + orders=orders_state, + current_distribution=current_distribution, + reference_market=reference_market, + exchange_name=self.exchange_name, + ) + + if distribution_output is None: + self.logger.warning("Agent team returned no distribution output.") + return + + self.logger.info( + f"Agent team completed. Urgency: {distribution_output.rebalance_urgency}" + ) + + # Convert to AI instructions and trigger rebalance + ai_instructions = distribution_output.get_ai_instructions() + + if ai_instructions and distribution_output.rebalance_urgency != "none": + await self._submit_trading_evaluation(ai_instructions) + + async def _get_llm_service(self): + """Get the LLM service instance.""" + try: + gpt_service_class = tentacles.Services.Services_bases.LLMService + except (AttributeError, ImportError): + self.logger.error("LLMService not available, cannot perform LLM analysis") + return None + + gpt_service = await services_api.get_service( + gpt_service_class, self.exchange_manager.is_backtesting, self.services_config + ) + if not gpt_service: + self.logger.error("LLMService not available, cannot perform LLM analysis") + return None + return gpt_service + + async def _submit_trading_evaluation(self, ai_instructions: list): + """ + Submit AI instructions to trigger portfolio rebalancing. + """ + ai_index_distribution.apply_ai_instructions( + self.trading_mode, ai_instructions + ) + await self.ensure_index() + + +class AIIndexTradingModeConsumer(index_trading.IndexTradingModeConsumer): + @classmethod + def get_should_cancel_loaded_orders(cls): + return True + + +class AIIndexTradingMode(index_trading.IndexTradingMode): + """ + AI-driven Index Trading Mode that uses GPT to generate dynamic portfolio distributions + based on strategy evaluation descriptions, inheriting rebalancing logic from IndexTradingMode. + """ + + MODE_PRODUCER_CLASSES = [AIIndexTradingModeProducer] + MODE_CONSUMER_CLASSES = [AIIndexTradingModeConsumer] + + # AI-specific config keys + MODEL_KEY = "model" + TEMPERATURE_KEY = "temperature" + MAX_TOKENS_KEY = "max_tokens" + + async def single_exchange_process_health_check(self, chained_orders, tickers): + return [] + + def __init__(self, config, exchange_manager): + super().__init__(config, exchange_manager) + + def init_user_inputs(self, inputs: dict) -> None: + """ + Initialize user inputs for AI configuration. + """ + super().init_user_inputs(inputs) + + # AI Model Configuration + self.UI.user_input( + self.MODEL_KEY, + commons_enums.UserInputTypes.TEXT, + inputs.get(self.MODEL_KEY), + inputs, + title="LLM model to use for AI strategy.", + ) + + self.UI.user_input( + self.TEMPERATURE_KEY, + commons_enums.UserInputTypes.FLOAT, + inputs.get(self.TEMPERATURE_KEY), + inputs, + min_val=0.0, + max_val=1.0, + title="Temperature for AI randomness (0.0 = deterministic, 1.0 = creative).", + ) + + self.UI.user_input( + self.MAX_TOKENS_KEY, + commons_enums.UserInputTypes.INT, + inputs.get(self.MAX_TOKENS_KEY), + inputs, + min_val=500, + max_val=4000, + title="Maximum tokens for AI response.", + ) diff --git a/Trading/Mode/ai_trading_mode/config/AIIndexTradingMode.json b/Trading/Mode/ai_trading_mode/config/AIIndexTradingMode.json new file mode 100644 index 000000000..88c685b48 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/config/AIIndexTradingMode.json @@ -0,0 +1,12 @@ +{ + "default_config": [ + "CryptoLLMAIStrategyEvaluator", + "GlobalLLMAIStrategyEvaluator" + ], + "required_strategies": [ + "CryptoLLMAIStrategyEvaluator", + "GlobalLLMAIStrategyEvaluator" + ], + "refresh_interval": 1, + "rebalance_trigger_min_percent": 5 +} \ No newline at end of file diff --git a/Trading/Mode/ai_trading_mode/metadata.json b/Trading/Mode/ai_trading_mode/metadata.json new file mode 100644 index 000000000..9ca6a1f91 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["AIIndexTradingMode"], + "tentacles-requirements": ["GPTService"] +} diff --git a/Trading/Mode/ai_trading_mode/resources/AIIndexTradingMode.md b/Trading/Mode/ai_trading_mode/resources/AIIndexTradingMode.md new file mode 100644 index 000000000..5533ef645 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/resources/AIIndexTradingMode.md @@ -0,0 +1,152 @@ +# AI Index Trading Mode + +## Overview + +The **AI Index Trading Mode** is an advanced trading mode that inherits from `IndexTradingMode` and uses external AI agents to dynamically generate portfolio distributions based on strategy evaluation data. This mode combines the robust rebalancing infrastructure of index trading with AI-driven decision making for optimal asset allocation, where AI logic is handled by separate agents rather than embedded in the mode. + +## Key Features + +- **Agent-Driven Allocations**: Uses external AI agents to analyze strategy signals and generate optimal portfolio weights +- **Strategy Integration**: Collects data from TA, Social, and Real-time evaluator signals on matrix callbacks +- **Decoupled AI**: AI processing happens in separate agents, allowing for scalability and modularity +- **Detailed Instructions**: Agents provide actionable rebalance instructions with explanations +- **Inherits IndexTradingMode**: Full rebalancing, order management, and portfolio optimization capabilities +- **Configurable Parameters**: Model selection, temperature, token limits for agents + +## Configuration + +### AI Configuration Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `model` | string | "gpt-4" | GPT model used by agents (gpt-3.5-turbo, gpt-4, gpt-4-turbo) | +| `temperature` | float | 0.3 | AI creativity for agents (0.0 = deterministic, 1.0 = very creative) | +| `max_tokens` | int | 2000 | Maximum tokens for agent AI responses | + +### Index Trading Parameters (inherited) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `refresh_interval` | int | 1 | Days between rebalance checks | +| `rebalance_trigger_min_percent` | int | 5 | Minimum % deviation before rebalance | + +## How It Works + +### 1. Strategy Signal Collection +The AI Index Trading Mode monitors matrix callbacks from strategy evaluators: +- Technical Analysis (TA) signals +- Social sentiment signals +- Real-time evaluation signals + +### 2. Data Submission +When strategies update, the mode: +1. Collects current strategy evaluation data +2. Submits neutral evaluations with `{"strategy_data": data}` to trigger agent processing +3. Agents listen for these submissions and process the data + +### 3. Agent Processing +External AI agents: +1. Receive strategy data from mode submissions +2. Analyze signals using configured GPT models +3. Generate rebalance instructions +4. Provide instructions back to the mode + +### 4. Instruction Application +The mode receives agent-generated instructions: +- Applies distribution changes via `ai_index_distribution.apply_ai_instructions` +- Validates instructions for consistency +- Executes rebalancing using IndexTradingMode logic + +## Agent Architecture + +### Data Flow +``` +Strategy Callbacks → Mode Producer → Submit {"strategy_data": data} + ↓ + Agents Process → Generate {"ai_instructions": instructions} + ↓ +Mode Consumer → Apply Instructions → Rebalance Portfolio +``` + +### Instruction Format +Agents provide instructions as lists of actions: +```json +[ + {"action": "reduce_exposure", "symbol": "BTC", "amount": 10, "explanation": "Overbought signals"}, + {"action": "increase_exposure", "symbol": "ETH", "amount": 15, "explanation": "Strong momentum"} +] +``` + +### Agent Responsibilities +- **Signal Analysis**: Process strategy data with AI models +- **Instruction Generation**: Create validated rebalance actions +- **Error Handling**: Handle API failures and provide safe instructions + +## Usage Examples + +### Basic Configuration +```json +{ + "trading_mode": "AIIndexTradingMode", + "model": "gpt-4", + "temperature": 0.3, + "max_tokens": 2000, + "refresh_interval": 1, + "rebalance_trigger_min_percent": 5 +} +``` + +### Conservative Configuration +```json +{ + "trading_mode": "AIIndexTradingMode", + "model": "gpt-3.5-turbo", + "temperature": 0.1, + "max_tokens": 1500, + "refresh_interval": 2 +} +``` + +## Testing + +Use the tentacles-agent tools to test: +```bash +# Test with strategy evaluators +python tentacle_trading_mode_tester.py --mode AIIndexTradingMode --evaluators strategy_evaluators.json --symbol BTC/USDT --duration 120 + +# Test basic functionality +python tentacle_configuration_tester.py --config ai_index_config.json --validate +``` + +## Dependencies + +- **AI Agents**: Required for instruction generation +- **Strategy Evaluators**: Any evaluators providing TA/Social/Real-time signals +- **IndexTradingMode**: Inherited rebalancing functionality + +## Troubleshooting + +### Common Issues + +**No rebalancing occurs** +- Verify strategy evaluators are active and triggering callbacks +- Check that agents are running and processing submissions +- Ensure sufficient portfolio balance for rebalancing + +**Agents not responding** +- Check agent configuration and connectivity +- Verify AI service availability +- Review agent logs for processing errors + +### Logs to Check +- Strategy data submissions: `"Submitting strategy data for AI processing"` +- Instruction application: `"Applied AI instructions: {...}"` +- Validation errors: `"Invalid AI instructions received"` + +## Future Enhancements + +- **Agent Marketplace**: Multiple competing AI agents +- **Historical Learning**: Agents incorporate backtesting results +- **Real-time Adaptation**: Agents adjust to live market conditions +- **Multi-Asset Correlation**: Advanced portfolio optimization +- **Custom Agent Development**: Framework for user-defined agents \ No newline at end of file diff --git a/Trading/Mode/ai_trading_mode/tests/__init__.py b/Trading/Mode/ai_trading_mode/tests/__init__.py new file mode 100644 index 000000000..1d9b4542d --- /dev/null +++ b/Trading/Mode/ai_trading_mode/tests/__init__.py @@ -0,0 +1 @@ +# Drakkar-Software OctoBot-Tentacles diff --git a/Trading/Mode/ai_trading_mode/tests/test_ai_index_trading_mode.py b/Trading/Mode/ai_trading_mode/tests/test_ai_index_trading_mode.py new file mode 100644 index 000000000..03774fd72 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/tests/test_ai_index_trading_mode.py @@ -0,0 +1,80 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General +# License along with this library. +import pytest +from unittest import mock + +import octobot_trading.enums as trading_enums +import tentacles.Trading.Mode.ai_trading_mode.ai_index_trading as ai_index_trading +import tentacles.Trading.Mode.ai_trading_mode.ai_index_distribution as ai_index_distribution + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def mode(): + """Create a minimal AIIndexTradingMode instance.""" + mock_exchange_manager = mock.Mock() + mock_exchange_manager.is_backtesting = True + mode = ai_index_trading.AIIndexTradingMode({"fallback_distribution": []}, mock_exchange_manager) + return mode + + +@pytest.fixture +def consumer(mode): + """Create a consumer instance.""" + return ai_index_trading.AIIndexTradingModeConsumer(mode) + + +async def test_mode_has_producers_and_consumers(mode): + """Test AIIndexTradingMode has required classes.""" + assert ai_index_trading.AIIndexTradingModeProducer in mode.MODE_PRODUCER_CLASSES + assert ai_index_trading.AIIndexTradingModeConsumer in mode.MODE_CONSUMER_CLASSES + + +async def test_consumer_processes_ai_instructions(consumer): + """Test consumer applies AI instructions before rebalancing.""" + consumer.trading_mode = mock.Mock() + consumer.trading_mode._rebalance_portfolio = mock.AsyncMock(return_value=[]) + consumer.trading_mode.is_processing_rebalance = False + + with mock.patch.object(ai_index_distribution, "apply_ai_instructions") as mock_apply: + await consumer.create_new_orders( + symbol="BTC/USDT", + final_note=0.0, + state=trading_enums.EvaluatorStates.NEUTRAL.value, + **{"data": {"ai_instructions": [{"action": "test"}]}}, + ) + + mock_apply.assert_called_once_with(consumer.trading_mode, [{"action": "test"}]) + consumer.trading_mode._rebalance_portfolio.assert_called_once() + + +async def test_consumer_handles_missing_ai_instructions(consumer): + """Test consumer handles missing AI instructions gracefully.""" + consumer.trading_mode = mock.Mock() + consumer.trading_mode._rebalance_portfolio = mock.AsyncMock(return_value=[]) + consumer.trading_mode.is_processing_rebalance = False + + with mock.patch.object(ai_index_distribution, "apply_ai_instructions") as mock_apply: + await consumer.create_new_orders( + symbol="BTC/USDT", + final_note=0.0, + state=trading_enums.EvaluatorStates.NEUTRAL.value, + **{"data": {}}, + ) + + mock_apply.assert_not_called() + consumer.trading_mode._rebalance_portfolio.assert_called_once() diff --git a/Trading/Mode/index_trading_mode/config/AIIndexTradingMode.json b/Trading/Mode/index_trading_mode/config/AIIndexTradingMode.json new file mode 100644 index 000000000..ddf3247e8 --- /dev/null +++ b/Trading/Mode/index_trading_mode/config/AIIndexTradingMode.json @@ -0,0 +1,7 @@ +{ + "required_strategies": ["CryptoLLMAIStrategyEvaluator", "GlobalLLMAIStrategyEvaluator"], + "refresh_interval": 1, + "rebalance_trigger_min_percent": 5, + "strategy_types": ["TA", "SOCIAL", "REAL_TIME"], + "fallback_distribution": [] +} \ No newline at end of file diff --git a/Trading/Mode/index_trading_mode/resources/AIIndexTradingMode.md b/Trading/Mode/index_trading_mode/resources/AIIndexTradingMode.md new file mode 100644 index 000000000..971509534 --- /dev/null +++ b/Trading/Mode/index_trading_mode/resources/AIIndexTradingMode.md @@ -0,0 +1,219 @@ +# AI Index Trading Mode + +## Overview + +The **AI Index Trading Mode** is an advanced trading mode that inherits from `IndexTradingMode` and uses artificial intelligence (GPT) to dynamically generate portfolio distributions based on strategy evaluation descriptions. This mode combines the robust rebalancing infrastructure of index trading with AI-driven decision making for optimal asset allocation. + +## Key Features + +- **AI-Driven Allocations**: Uses GPT models to analyze strategy signals and generate optimal portfolio weights +- **Strategy Integration**: Incorporates TA, Social, and Real-time evaluator signals +- **Detailed Explanations**: AI provides reasoning for each allocation decision +- **Risk Management**: Optional USD allocation for conservative positioning +- **Fallback Mechanisms**: Gracefully handles AI failures with configurable fallback distributions +- **Inherits IndexTradingMode**: Full rebalancing, order management, and portfolio optimization capabilities +- **Configurable AI Parameters**: Model selection, temperature, token limits, and strategy types + +## Configuration + +### Required Strategies +The mode requires AI strategy evaluators to function optimally: +- `LLMAIStrategyEvaluator`: Unified AI analysis combining TA, Social, and Real-Time signals through parallel sub-agents + +### AI Configuration Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `model` | string | "gpt-4" | GPT model to use (gpt-3.5-turbo, gpt-4, gpt-4-turbo) | +| `temperature` | float | 0.3 | AI creativity (0.0 = deterministic, 1.0 = very creative) | +| `max_tokens` | int | 2000 | Maximum tokens for AI response | +| `strategy_types` | array | ["TA", "SOCIAL"] | Evaluator types to include in analysis | +| `risk_management` | boolean | true | Enable USD allocation for conservative risk management | + +### Index Trading Parameters (inherited) + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `refresh_interval` | int | 1 | Days between rebalance checks | +| `rebalance_trigger_min_percent` | int | 5 | Minimum % deviation before rebalance | +| `fallback_distribution` | array | [] | Static distribution if AI fails (empty = even distribution) | + +## How It Works + +### 1. Strategy Signal Aggregation +The AI Index Trading Mode collects evaluation descriptions from configured strategy evaluators: +- Technical Analysis (TA) signals +- Social sentiment signals +- Real-time evaluation signals + +### 2. AI Analysis +When a rebalance is triggered, the mode: +1. Prepares current portfolio distribution context +2. Aggregates strategy evaluation descriptions +3. Calls GPT service with specialized prompts +4. Parses AI-generated allocation recommendations + +### 3. Distribution Generation +The AI generates allocations based on: +- **Signal Strength**: Prioritizes high-confidence signals +- **Consensus**: Favors agreements across strategies +- **Risk Management**: When enabled, may allocate to USD during uncertain market conditions +- **Diversification**: Maintains balanced exposure across assets +- **Market Context**: Considers current portfolio positions + +### 4. Rebalancing Execution +Uses IndexTradingMode's proven rebalancing logic: +- Calculates required trades +- Manages sell/buy order sequencing +- Handles exchange constraints +- Ensures portfolio matches target allocations + +## AI Decision Making + +### Input Data +The AI receives: +- Current portfolio allocation percentages +- Available tradable assets +- Strategy evaluation descriptions with confidence levels +- Historical performance context (when available) + +### Output Format +AI returns JSON with asset allocations and explanations: +```json +[ + {"name": "BTC", "percentage": 35.5, "explanation": "Strong bullish signals from technical analysis with high confidence"}, + {"name": "ETH", "percentage": 25.2, "explanation": "Moderate positive sentiment from social signals"}, + {"name": "ADA", "percentage": 15.1, "explanation": "Balanced allocation for diversification"}, + {"name": "DOT", "percentage": 24.2, "explanation": "Positive momentum indicators suggest increased weighting"} +] +``` + +### Validation +All AI outputs are validated for: +- Total allocation summing to 100% +- Assets being tradable on configured exchanges +- Reasonable percentage ranges (0-100%) +- No invalid or unavailable assets + +## Fallback Mechanisms + +### Primary Fallback +If AI generation fails: +1. Uses configured `fallback_distribution` +2. If no fallback configured, uses even distribution across all assets + +### Error Scenarios +- **GPT Service Unavailable**: Falls back immediately +- **Invalid AI Response**: Logs error, uses fallback +- **No Strategy Signals**: Uses fallback distribution +- **Network/API Errors**: Retries once, then falls back + +## Risk Management + +### USD Allocation +When `risk_management` is enabled, the AI may allocate portions to USD (or the reference market currency) during: +- Uncertain market conditions +- Bearish signal consensus +- High volatility periods +- When strategy signals conflict significantly + +### Conservative Positioning +The AI considers risk factors such as: +- Portfolio concentration limits +- Market volatility indicators +- Signal confidence levels +- Historical drawdown patterns (when available) + +### Configuration Impact +- **Enabled**: AI may allocate 0-50% to USD for safety +- **Disabled**: AI focuses purely on signal-based allocations without risk hedging + +## Usage Examples + +### Basic Configuration +```json +{ + "trading_mode": "AIIndexTradingMode", + "model": "gpt-4", + "temperature": 0.3, + "strategy_types": ["TA", "SOCIAL"], + "risk_management": true, + "refresh_interval": 1, + "rebalance_trigger_min_percent": 5 +} +``` + +### Conservative Configuration +```json +{ + "trading_mode": "AIIndexTradingMode", + "model": "gpt-3.5-turbo", + "temperature": 0.1, + "strategy_types": ["TA"], + "risk_management": true, + "max_tokens": 1500, + "refresh_interval": 2 +} +``` + +### With Custom Fallback +```json +{ + "trading_mode": "AIIndexTradingMode", + "fallback_distribution": [ + {"name": "BTC", "percentage": 40}, + {"name": "ETH", "percentage": 30}, + {"name": "ADA", "percentage": 20}, + {"name": "DOT", "percentage": 10} + ] +} +``` + +## Testing + +Use the tentacles-agent tools to test: +```bash +# Test with AI evaluators +python tentacle_trading_mode_tester.py --mode AIIndexTradingMode --evaluators ai_evaluators.json --symbol BTC/USDT --duration 120 + +# Test basic functionality +python tentacle_configuration_tester.py --config ai_index_config.json --validate +``` + +## Dependencies + +- **GPTService**: Required for AI functionality +- **Strategy Evaluators**: AI evaluators recommended for optimal performance +- **IndexTradingMode**: Inherited functionality + +## Troubleshooting + +### Common Issues + +**AI responses are invalid** +- Check GPT service configuration +- Verify API keys and connectivity +- Review AI parameter settings (lower temperature) + +**No rebalancing occurs** +- Verify strategy evaluators are active +- Check rebalance trigger thresholds +- Ensure sufficient portfolio balance + +**High API costs** +- Increase refresh_interval +- Use gpt-3.5-turbo instead of gpt-4 +- Reduce max_tokens + +### Logs to Check +- AI decision logs: `"AI generated distribution: {...}"` +- Fallback activations: `"using fallback distribution"` +- Validation errors: `"Distribution total X% is not close to 100%"` + +## Future Enhancements + +- **Historical Performance**: Include backtesting results in AI prompts +- **Risk Metrics**: Add volatility and correlation analysis +- **Multi-Timeframe**: Consider different timeframe signals +- **Custom Prompts**: Allow user-defined AI prompts +- **Ensemble Methods**: Combine multiple AI models \ No newline at end of file diff --git a/Trading/Mode/index_trading_mode/tests/test_ai_index_trading_mode.py b/Trading/Mode/index_trading_mode/tests/test_ai_index_trading_mode.py new file mode 100644 index 000000000..7ec474548 --- /dev/null +++ b/Trading/Mode/index_trading_mode/tests/test_ai_index_trading_mode.py @@ -0,0 +1,197 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General +# License along with this library. +import json +import pytest +import mock + +import octobot_evaluators.enums as evaluators_enums + +import tentacles.Trading.Mode.index_trading_mode.ai_index_trading as ai_index_trading +import tentacles.Trading.Mode.index_trading_mode.index_distribution as index_distribution + +# All test coroutines will be treated as marked. +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def mode(): + """Create a minimal AIIndexTradingMode instance for testing.""" + # Mock the required dependencies + mock_exchange_manager = mock.Mock() + mock_exchange_manager.is_backtesting = True + mock_exchange_manager.services_config = None + + # Create mode instance with minimal config + config = {} + mode = ai_index_trading.AIIndexTradingMode(config, mock_exchange_manager) + + # Set up basic attributes + mode.trading_config = {} + mode.fallback_distribution = None + mode.risk_management = True + mode.indexed_coins = ["BTC", "ETH"] + + return mode + + +@pytest.mark.parametrize( + "response,expected_valid", + [ + ( + """[{"name": "BTC", "percentage": 60.0, "explanation": "Good signals"}]""", + True, + ), + ( + """[{"name": "BTC", "percentage": 150.0, "explanation": "Invalid"}]""", + False, + ), # Invalid total + ( + """[{"name": "INVALID", "percentage": 50.0, "explanation": "Bad asset"}]""", + False, + ), # Invalid asset + ("""[{"name": "BTC", "percentage": 50.0}]""", False), # Missing explanation + ], +) +async def test_parse_ai_response_validation(mode, response, expected_valid): + """Test AI response parsing with different scenarios.""" + result = mode._parse_ai_response(response) + if expected_valid: + assert result is not None + assert len(result) > 0 + assert "explanation" in result[0] + else: + assert result is None + + +@pytest.mark.parametrize( + "distribution,available_assets,expected_valid", + [ + ([{"name": "BTC", "value": 60.0, "explanation": "Good"}], ["BTC"], True), + ([{"name": "BTC", "value": 60.0}], ["BTC"], False), # Missing explanation + ( + [{"name": "INVALID", "value": 100.0, "explanation": "Bad"}], + ["BTC"], + False, + ), # Invalid asset + ( + [{"name": "BTC", "value": -10.0, "explanation": "Bad"}], + ["BTC"], + False, + ), # Invalid percentage + ], +) +async def test_validate_distribution( + mode, distribution, available_assets, expected_valid +): + """Test distribution validation with various inputs.""" + is_valid = mode._validate_distribution(distribution, available_assets) + assert is_valid == expected_valid + + +async def test_generate_ai_distribution_success(mode): + """Test successful AI distribution generation.""" + mock_gpt_service = mock.Mock() + mock_gpt_service.create_message.return_value = {"role": "user", "content": "test"} + mock_gpt_service.get_completion.return_value = """[ + {"name": "BTC", "percentage": 60.0, "explanation": "Strong signals"}, + {"name": "ETH", "percentage": 40.0, "explanation": "Balanced position"} + ]""" + + with mock.patch( + "octobot_services.api.services.get_service", + mock.AsyncMock(return_value=mock_gpt_service), + ): + with mock.patch.object( + mode, "_prepare_strategy_descriptions", return_value={"test": "data"} + ): + with mock.patch.object( + mode, "_get_current_portfolio_distribution", return_value={"BTC": 50.0} + ): + with mock.patch.object( + mode, "_get_available_assets", return_value=["BTC", "ETH"] + ): + distribution = await mode._generate_ai_distribution() + + assert len(distribution) == 2 + assert distribution[0]["name"] == "BTC" + assert distribution[0]["percentage"] == 60.0 + assert "explanation" in distribution[0] + + +async def test_generate_ai_distribution_fallback(mode): + """Test fallback when GPT service unavailable.""" + with mock.patch( + "octobot_services.api.services.get_service", mock.AsyncMock(return_value=None) + ): + with mock.patch.object( + mode, "_get_fallback_distribution", return_value=[{"test": "fallback"}] + ): + distribution = await mode._generate_ai_distribution() + assert distribution == [{"test": "fallback"}] + + +async def test_prepare_strategy_descriptions(mode): + """Test strategy data preparation from evaluator matrix.""" + mock_evaluation = mock.Mock() + mock_evaluation.eval_note = 0.8 + mock_evaluation.eval_note_description = "Strong bullish signal" + mock_evaluation.cryptocurrency = "BTC" + mock_evaluation.symbol = "BTC/USDT" + + with mock.patch( + "octobot_evaluators.matrix.get_evaluations_by_evaluator_type", + return_value={"TestEvaluator": {"BTC": mock_evaluation}}, + ): + strategy_data = mode._prepare_strategy_descriptions() + + assert evaluators_enums.EvaluatorMatrixTypes.TA.value in strategy_data + assert ( + "TestEvaluator" + in strategy_data[evaluators_enums.EvaluatorMatrixTypes.TA.value] + ) + + +@pytest.mark.parametrize( + "fallback_config,indexed_coins,expected_count", + [ + ([{"name": "BTC", "value": 100}], ["BTC"], 1), # Configured fallback + (None, ["BTC", "ETH"], 2), # Even distribution fallback + (None, [], 0), # No assets + ], +) +async def test_get_fallback_distribution( + mode, fallback_config, indexed_coins, expected_count +): + """Test fallback distribution generation.""" + mode.fallback_distribution = fallback_config + mode.indexed_coins = indexed_coins + + fallback = mode._get_fallback_distribution() + if expected_count > 0: + assert len(fallback) == expected_count + else: + assert fallback is None + + +async def test_get_ai_system_prompt_risk_management(mode): + """Test AI system prompt includes risk management when enabled.""" + mode.risk_management = True + prompt = mode._get_ai_system_prompt() + assert "USD" in prompt + + mode.risk_management = False + prompt = mode._get_ai_system_prompt() + assert "USD" not in prompt diff --git a/profiles/ai_trading/profile.json b/profiles/ai_trading/profile.json new file mode 100644 index 000000000..a40597f37 --- /dev/null +++ b/profiles/ai_trading/profile.json @@ -0,0 +1,55 @@ +{ + "config": { + "crypto-currencies": { + "Bitcoin": { + "enabled": true, + "pairs": [ + "BTC/USDT" + ] + }, + "Ethereum": { + "enabled": true, + "pairs": [ + "ETH/USDT" + ] + } + }, + "exchanges": { + "binance": { + "enabled": true + } + }, + "trader": { + "enabled": false, + "load-trade-history": false + }, + "trader-simulator": { + "enabled": true, + "fees": { + "maker": 0.1, + "taker": 0.1 + }, + "starting-portfolio": { + "BTC": 1, + "ETH": 10, + "USDT": 10000 + } + }, + "trading": { + "reference-market": "USDT", + "risk": 0.3 + }, + "services": { + "gpt_service": { + "enabled": true + } + } + }, + "profile": { + "avatar": "default_profile.png", + "description": "AI trading profile.", + "id": "ai_trading", + "name": "AI Trading", + "read_only": false + } +} \ No newline at end of file diff --git a/profiles/ai_trading/specific_config/AIIndexTradingMode.json b/profiles/ai_trading/specific_config/AIIndexTradingMode.json new file mode 100644 index 000000000..88c685b48 --- /dev/null +++ b/profiles/ai_trading/specific_config/AIIndexTradingMode.json @@ -0,0 +1,12 @@ +{ + "default_config": [ + "CryptoLLMAIStrategyEvaluator", + "GlobalLLMAIStrategyEvaluator" + ], + "required_strategies": [ + "CryptoLLMAIStrategyEvaluator", + "GlobalLLMAIStrategyEvaluator" + ], + "refresh_interval": 1, + "rebalance_trigger_min_percent": 5 +} \ No newline at end of file diff --git a/profiles/ai_trading/specific_config/CryptoLLMAIStrategyEvaluator.json b/profiles/ai_trading/specific_config/CryptoLLMAIStrategyEvaluator.json new file mode 100644 index 000000000..a9526a2d6 --- /dev/null +++ b/profiles/ai_trading/specific_config/CryptoLLMAIStrategyEvaluator.json @@ -0,0 +1,18 @@ +{ + "default_config": [ + "MACDMomentumEvaluator", + "RSIMomentumEvaluator", + "EMADivergenceTrendEvaluator", + "DoubleMovingAverageTrendEvaluator", + "BBMomentumEvaluator", + "ADXMomentumEvaluator", + "SocialScoreEvaluator" + ], + "required_evaluators": ["*"], + "required_time_frames": [ + "1h", + "4h", + "1d" + ], + "required_candles_count": 1000 +} diff --git a/profiles/ai_trading/specific_config/GlobalLLMAIStrategyEvaluator.json b/profiles/ai_trading/specific_config/GlobalLLMAIStrategyEvaluator.json new file mode 100644 index 000000000..65fa18900 --- /dev/null +++ b/profiles/ai_trading/specific_config/GlobalLLMAIStrategyEvaluator.json @@ -0,0 +1,14 @@ +{ + "default_config": [ + "FearAndGreedIndexEvaluator", + "MarketCapEvaluator", + "CryptoNewsEvaluator" + ], + "required_evaluators": [ + "FearAndGreedIndexEvaluator", + "MarketCapEvaluator", + "CryptoNewsEvaluator" + ], + "required_time_frames": ["1d"] +} + diff --git a/profiles/ai_trading/tentacles_config.json b/profiles/ai_trading/tentacles_config.json new file mode 100644 index 000000000..1a5e962c6 --- /dev/null +++ b/profiles/ai_trading/tentacles_config.json @@ -0,0 +1,25 @@ +{ + "tentacle_activation": { + "Evaluator": { + "Strategies": { + "CryptoLLMAIStrategyEvaluator": true, + "GlobalLLMAIStrategyEvaluator": true, + "MACDMomentumEvaluator": true, + "RSIMomentumEvaluator": true, + "EMADivergenceTrendEvaluator": true, + "DoubleMovingAverageTrendEvaluator": true, + "BBMomentumEvaluator": true, + "ADXMomentumEvaluator": true, + "MarketCapEvaluator": true, + "SocialScoreEvaluator": true, + "FearAndGreedIndexEvaluator": true, + "CryptoNewsEvaluator": true + } + }, + "Trading": { + "Mode": { + "AIIndexTradingMode": true + } + } + } +} \ No newline at end of file From c15bea0c5a2fe6d9b06e4ed42b664174e24ab53b Mon Sep 17 00:00:00 2001 From: Herklos Date: Sat, 24 Jan 2026 00:39:40 +0100 Subject: [PATCH 4/4] Refactor agents structure Signed-off-by: Herklos --- Agent/Evaluators/__init__.py | 20 ++ .../real_time_analysis_agent}/__init__.py | 23 +- .../real_time_analysis_agent/metadata.json | 6 + .../real_time_analysis_agent/models.py | 94 ++++++ .../real_time_analysis_agent.py | 37 ++- .../sentiment_analysis_agent/__init__.py | 29 ++ .../sentiment_analysis_agent/metadata.json | 6 + .../sentiment_analysis_agent/models.py | 72 ++++ .../sentiment_analysis_agent.py | 37 ++- .../summarization_agent/__init__.py | 29 ++ .../summarization_agent/metadata.json | 6 + .../Evaluators/summarization_agent/models.py | 37 +++ .../summarization_agent.py | 259 +++++++++++++++ .../technical_analysis_agent/__init__.py | 29 ++ .../technical_analysis_agent/metadata.json | 6 + .../technical_analysis_agent/models.py | 68 ++++ .../technical_analysis_agent.py | 37 ++- Agent/Teams/__init__.py | 1 + .../__init__.py | 5 + .../metadata.json | 11 + .../simple_ai_evaluator_agents_team.py | 196 +++++++++++ Agent/Trading/__init__.py | 19 ++ Agent/Trading/distribution_agent/__init__.py | 32 ++ .../distribution_agent}/distribution_agent.py | 65 ++-- .../Trading/distribution_agent/metadata.json | 6 + Agent/Trading/distribution_agent/models.py | 108 ++++++ Agent/Trading/risk_agent/__init__.py | 32 ++ Agent/Trading/risk_agent/metadata.json | 6 + Agent/Trading/risk_agent/models.py | 70 ++++ .../Trading/risk_agent}/risk_agent.py | 60 ++-- Agent/Trading/signal_agent/__init__.py | 41 +++ Agent/Trading/signal_agent/metadata.json | 6 + Agent/Trading/signal_agent/models.py | 81 +++++ .../Trading/signal_agent}/signal_agent.py | 92 ++++-- .../Trading/signal_agent}/state.py | 31 +- Agent/__init__.py | 3 + .../ai_strategies_evaluator/__init__.py | 8 - .../agents/base_agent.py | 102 ------ .../ai_strategies_evaluator/agents/factory.py | 56 ---- .../ai_strategies_evaluator/agents/models.py | 209 ------------ .../agents/summarization_agent.py | 134 -------- .../ai_strategies_evaluator/ai_strategies.py | 96 ++---- Services/Services_bases/gpt_service/gpt.py | 279 +++++++++++++++- .../Mode/ai_trading_mode/agents/__init__.py | 35 +- .../Mode/ai_trading_mode/agents/base_agent.py | 152 --------- Trading/Mode/ai_trading_mode/agents/models.py | 240 -------------- Trading/Mode/ai_trading_mode/agents/team.py | 310 ------------------ .../Mode/ai_trading_mode/ai_index_trading.py | 188 +++++++++-- Trading/Mode/ai_trading_mode/team.py | 164 +++++++++ 49 files changed, 2156 insertions(+), 1477 deletions(-) create mode 100644 Agent/Evaluators/__init__.py rename {Evaluator/Strategies/ai_strategies_evaluator/agents => Agent/Evaluators/real_time_analysis_agent}/__init__.py (61%) create mode 100644 Agent/Evaluators/real_time_analysis_agent/metadata.json create mode 100644 Agent/Evaluators/real_time_analysis_agent/models.py rename {Evaluator/Strategies/ai_strategies_evaluator/agents => Agent/Evaluators/real_time_analysis_agent}/real_time_analysis_agent.py (82%) create mode 100644 Agent/Evaluators/sentiment_analysis_agent/__init__.py create mode 100644 Agent/Evaluators/sentiment_analysis_agent/metadata.json create mode 100644 Agent/Evaluators/sentiment_analysis_agent/models.py rename {Evaluator/Strategies/ai_strategies_evaluator/agents => Agent/Evaluators/sentiment_analysis_agent}/sentiment_analysis_agent.py (82%) create mode 100644 Agent/Evaluators/summarization_agent/__init__.py create mode 100644 Agent/Evaluators/summarization_agent/metadata.json create mode 100644 Agent/Evaluators/summarization_agent/models.py create mode 100644 Agent/Evaluators/summarization_agent/summarization_agent.py create mode 100644 Agent/Evaluators/technical_analysis_agent/__init__.py create mode 100644 Agent/Evaluators/technical_analysis_agent/metadata.json create mode 100644 Agent/Evaluators/technical_analysis_agent/models.py rename {Evaluator/Strategies/ai_strategies_evaluator/agents => Agent/Evaluators/technical_analysis_agent}/technical_analysis_agent.py (82%) create mode 100644 Agent/Teams/__init__.py create mode 100644 Agent/Teams/simple_ai_evaluator_agents_team/__init__.py create mode 100644 Agent/Teams/simple_ai_evaluator_agents_team/metadata.json create mode 100644 Agent/Teams/simple_ai_evaluator_agents_team/simple_ai_evaluator_agents_team.py create mode 100644 Agent/Trading/__init__.py create mode 100644 Agent/Trading/distribution_agent/__init__.py rename {Trading/Mode/ai_trading_mode/agents => Agent/Trading/distribution_agent}/distribution_agent.py (78%) create mode 100644 Agent/Trading/distribution_agent/metadata.json create mode 100644 Agent/Trading/distribution_agent/models.py create mode 100644 Agent/Trading/risk_agent/__init__.py create mode 100644 Agent/Trading/risk_agent/metadata.json create mode 100644 Agent/Trading/risk_agent/models.py rename {Trading/Mode/ai_trading_mode/agents => Agent/Trading/risk_agent}/risk_agent.py (77%) create mode 100644 Agent/Trading/signal_agent/__init__.py create mode 100644 Agent/Trading/signal_agent/metadata.json create mode 100644 Agent/Trading/signal_agent/models.py rename {Trading/Mode/ai_trading_mode/agents => Agent/Trading/signal_agent}/signal_agent.py (72%) rename {Trading/Mode/ai_trading_mode/agents => Agent/Trading/signal_agent}/state.py (70%) create mode 100644 Agent/__init__.py delete mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/base_agent.py delete mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/factory.py delete mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/models.py delete mode 100644 Evaluator/Strategies/ai_strategies_evaluator/agents/summarization_agent.py delete mode 100644 Trading/Mode/ai_trading_mode/agents/base_agent.py delete mode 100644 Trading/Mode/ai_trading_mode/agents/models.py delete mode 100644 Trading/Mode/ai_trading_mode/agents/team.py create mode 100644 Trading/Mode/ai_trading_mode/team.py diff --git a/Agent/Evaluators/__init__.py b/Agent/Evaluators/__init__.py new file mode 100644 index 000000000..ec08c89e3 --- /dev/null +++ b/Agent/Evaluators/__init__.py @@ -0,0 +1,20 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from .technical_analysis_agent import * +from .sentiment_analysis_agent import * +from .real_time_analysis_agent import * +from .summarization_agent import * diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/__init__.py b/Agent/Evaluators/real_time_analysis_agent/__init__.py similarity index 61% rename from Evaluator/Strategies/ai_strategies_evaluator/agents/__init__.py rename to Agent/Evaluators/real_time_analysis_agent/__init__.py index 05725ef8d..ff3d13828 100644 --- a/Evaluator/Strategies/ai_strategies_evaluator/agents/__init__.py +++ b/Agent/Evaluators/real_time_analysis_agent/__init__.py @@ -13,18 +13,17 @@ # # You should have received a copy of the GNU Lesser General Public # License along with this library. -from .base_agent import BaseAgent -from .summarization_agent import SummarizationAgent -from .technical_analysis_agent import TechnicalAnalysisAgent -from .sentiment_analysis_agent import SentimentAnalysisAgent -from .real_time_analysis_agent import RealTimeAnalysisAgent -from .factory import AgentFactory + +from .real_time_analysis_agent import ( + RealTimeAnalysisAIAgentChannel, + RealTimeAnalysisAIAgentConsumer, + RealTimeAnalysisAIAgentProducer, +) +from .models import RealTimeAnalysisOutput __all__ = [ - "BaseAgent", - "SummarizationAgent", - "TechnicalAnalysisAgent", - "SentimentAnalysisAgent", - "RealTimeAnalysisAgent", - "AgentFactory", + "RealTimeAnalysisAIAgentChannel", + "RealTimeAnalysisAIAgentConsumer", + "RealTimeAnalysisAIAgentProducer", + "RealTimeAnalysisOutput", ] diff --git a/Agent/Evaluators/real_time_analysis_agent/metadata.json b/Agent/Evaluators/real_time_analysis_agent/metadata.json new file mode 100644 index 000000000..0f910610f --- /dev/null +++ b/Agent/Evaluators/real_time_analysis_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["RealTimeAnalysisAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Evaluators/real_time_analysis_agent/models.py b/Agent/Evaluators/real_time_analysis_agent/models.py new file mode 100644 index 000000000..ea0c60ba5 --- /dev/null +++ b/Agent/Evaluators/real_time_analysis_agent/models.py @@ -0,0 +1,94 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Pydantic models for real-time analysis agent outputs. +""" +from typing import List, Optional +from pydantic import BaseModel, Field, field_validator + + +class RealTimeAnalysisOutput(BaseModel): + """Output from the real-time analysis agent.""" + eval_note: float = Field( + ge=-1.0, + le=1.0, + description="Evaluation score from -1 (strong selling pressure) to 1 (strong buying pressure)." + ) + confidence: float = Field( + ge=0.0, + le=1.0, + description="Confidence level of the real-time analysis (0-1)." + ) + description: str = Field( + description="Summary description of the real-time market analysis." + ) + price_momentum: Optional[float] = Field( + default=None, + ge=-1.0, + le=1.0, + description="Price momentum indicator from -1 (strong downward) to 1 (strong upward). Leave empty if no momentum." + ) + current_status: Optional[str] = Field( + default=None, + description="Current market status: 'bullish', 'bearish', 'neutral', 'volatile'. Leave empty if unclear." + ) + volume_signal: Optional[str] = Field( + default=None, + description="Volume analysis: 'high', 'normal', 'low'. Leave empty if no volume data." + ) + urgency_level: Optional[str] = Field( + default=None, + description="Action urgency: 'immediate', 'high', 'medium', 'low', 'none'. Leave empty if no urgency." + ) + critical_events: Optional[List[str]] = Field( + default=None, + description="Any critical events or catalysts detected. Leave empty if none." + ) + recommendations: Optional[List[str]] = Field( + default=None, + description="Real-time trading recommendations. Leave empty if none." + ) + + @field_validator("current_status") + def validate_status(cls, v: str) -> str: + if v is None: + return None + allowed_statuses = ["bullish", "bearish", "neutral", "volatile"] + v_lower = v.lower() + if v_lower not in allowed_statuses: + raise ValueError(f"Status must be one of {allowed_statuses}") + return v_lower + + @field_validator("volume_signal") + def validate_volume(cls, v: str) -> str: + if v is None: + return None + allowed_volumes = ["high", "normal", "low"] + v_lower = v.lower() + if v_lower not in allowed_volumes: + raise ValueError(f"Volume signal must be one of {allowed_volumes}") + return v_lower + + @field_validator("urgency_level") + def validate_urgency(cls, v: str) -> str: + if v is None: + return None + allowed_urgencies = ["immediate", "high", "medium", "low", "none"] + v_lower = v.lower() + if v_lower not in allowed_urgencies: + raise ValueError(f"Urgency level must be one of {allowed_urgencies}") + return v_lower diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/real_time_analysis_agent.py b/Agent/Evaluators/real_time_analysis_agent/real_time_analysis_agent.py similarity index 82% rename from Evaluator/Strategies/ai_strategies_evaluator/agents/real_time_analysis_agent.py rename to Agent/Evaluators/real_time_analysis_agent/real_time_analysis_agent.py index 9b26ad8f2..f4e178033 100644 --- a/Evaluator/Strategies/ai_strategies_evaluator/agents/real_time_analysis_agent.py +++ b/Agent/Evaluators/real_time_analysis_agent/real_time_analysis_agent.py @@ -14,15 +14,32 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. import json -from .base_agent import BaseAgent + +import octobot_agents as agent + from .models import RealTimeAnalysisOutput -class RealTimeAnalysisAgent(BaseAgent): - """Agent specialized in real-time market analysis.""" +class RealTimeAnalysisAIAgentChannel(agent.AbstractAgentChannel): + """Channel for RealTimeAnalysisAIAgentProducer.""" + OUTPUT_SCHEMA = RealTimeAnalysisOutput + + +class RealTimeAnalysisAIAgentConsumer(agent.AbstractAIAgentChannelConsumer): + """Consumer for RealTimeAnalysisAIAgentProducer.""" + pass + + +class RealTimeAnalysisAIAgentProducer(agent.AbstractAIAgentChannelProducer): + """Producer specialized in real-time market analysis.""" + + AGENT_NAME = "RealTimeAnalysisAgent" + AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = RealTimeAnalysisAIAgentChannel + AGENT_CONSUMER = RealTimeAnalysisAIAgentConsumer - def __init__(self, **kwargs): - super().__init__("real_time_analysis", **kwargs) + def __init__(self, channel, **kwargs): + super().__init__(channel, **kwargs) def _get_default_prompt(self) -> str: return ( @@ -49,7 +66,7 @@ def _get_default_prompt(self) -> str: "Output only valid JSON matching the RealTimeAnalysisOutput schema." ) - async def execute(self, input_data, llm_service) -> dict: + async def execute(self, input_data, ai_service) -> dict: """Evaluate aggregated real-time market data.""" aggregated_data = input_data if not aggregated_data: @@ -62,8 +79,8 @@ async def execute(self, input_data, llm_service) -> dict: data_str = json.dumps(aggregated_data, indent=2) messages = [ - llm_service.create_message("system", self.prompt), - llm_service.create_message( + ai_service.create_message("system", self.prompt), + ai_service.create_message( "user", f"Real-time market data:\n{data_str}\n\n" "Provide evaluation as JSON matching the RealTimeAnalysisOutput schema. " @@ -73,11 +90,11 @@ async def execute(self, input_data, llm_service) -> dict: ] try: + # Uses RealTimeAnalysisAIAgentChannel.OUTPUT_SCHEMA by default parsed = await self._call_llm( messages, - llm_service, + ai_service, json_output=True, - response_schema=RealTimeAnalysisOutput, ) eval_note = float(parsed.get("eval_note", 0)) eval_note_description = parsed.get("description", "Real-time analysis") diff --git a/Agent/Evaluators/sentiment_analysis_agent/__init__.py b/Agent/Evaluators/sentiment_analysis_agent/__init__.py new file mode 100644 index 000000000..692d3c142 --- /dev/null +++ b/Agent/Evaluators/sentiment_analysis_agent/__init__.py @@ -0,0 +1,29 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from .sentiment_analysis_agent import ( + SentimentAnalysisAIAgentChannel, + SentimentAnalysisAIAgentConsumer, + SentimentAnalysisAIAgentProducer, +) +from .models import SentimentAnalysisOutput + +__all__ = [ + "SentimentAnalysisAIAgentChannel", + "SentimentAnalysisAIAgentConsumer", + "SentimentAnalysisAIAgentProducer", + "SentimentAnalysisOutput", +] diff --git a/Agent/Evaluators/sentiment_analysis_agent/metadata.json b/Agent/Evaluators/sentiment_analysis_agent/metadata.json new file mode 100644 index 000000000..5a26f79bc --- /dev/null +++ b/Agent/Evaluators/sentiment_analysis_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["SentimentAnalysisAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Evaluators/sentiment_analysis_agent/models.py b/Agent/Evaluators/sentiment_analysis_agent/models.py new file mode 100644 index 000000000..7c811c4d7 --- /dev/null +++ b/Agent/Evaluators/sentiment_analysis_agent/models.py @@ -0,0 +1,72 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Pydantic models for sentiment analysis agent outputs. +""" +from typing import List, Optional +from pydantic import BaseModel, Field, field_validator + + +class SentimentMetrics(BaseModel): + """Sentiment analysis metrics.""" + sentiment_score: float = Field( + ge=-1.0, + le=1.0, + description="Sentiment score from -1 (very negative) to 1 (very positive)." + ) + + @field_validator("sentiment_score") + def validate_score(cls, v: float) -> float: + return max(-1.0, min(1.0, v)) + + +class SentimentAnalysisOutput(BaseModel): + """Output from the sentiment analysis agent.""" + eval_note: float = Field( + ge=-1.0, + le=1.0, + description="Evaluation score from -1 (very negative) to 1 (very positive)." + ) + confidence: float = Field( + ge=0.0, + le=1.0, + description="Confidence level of the sentiment analysis (0-1)." + ) + description: str = Field( + description="Summary description of the sentiment analysis." + ) + sentiment_score: float = Field( + ge=-1.0, + le=1.0, + description="Detailed sentiment score from -1 to 1. Same as eval_note." + ) + sources_analyzed: Optional[List[str]] = Field( + default=None, + description="Data sources used for sentiment analysis. Leave empty if none identified." + ) + key_mentions: Optional[List[str]] = Field( + default=None, + description="Key mentions or topics driving sentiment. Leave empty if none." + ) + market_implications: Optional[str] = Field( + default=None, + description="Implications of sentiment for market direction. Leave empty if unclear." + ) + recommendations: Optional[List[str]] = Field( + default=None, + description="Trading recommendations based on sentiment. Leave empty if none." + ) diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/sentiment_analysis_agent.py b/Agent/Evaluators/sentiment_analysis_agent/sentiment_analysis_agent.py similarity index 82% rename from Evaluator/Strategies/ai_strategies_evaluator/agents/sentiment_analysis_agent.py rename to Agent/Evaluators/sentiment_analysis_agent/sentiment_analysis_agent.py index d1b3d5f81..f9ab2cb15 100644 --- a/Evaluator/Strategies/ai_strategies_evaluator/agents/sentiment_analysis_agent.py +++ b/Agent/Evaluators/sentiment_analysis_agent/sentiment_analysis_agent.py @@ -14,15 +14,32 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. import json -from .base_agent import BaseAgent + +import octobot_agents as agent + from .models import SentimentAnalysisOutput -class SentimentAnalysisAgent(BaseAgent): - """Agent specialized in sentiment analysis evaluation.""" +class SentimentAnalysisAIAgentChannel(agent.AbstractAgentChannel): + """Channel for SentimentAnalysisAIAgentProducer.""" + OUTPUT_SCHEMA = SentimentAnalysisOutput + + +class SentimentAnalysisAIAgentConsumer(agent.AbstractAIAgentChannelConsumer): + """Consumer for SentimentAnalysisAIAgentProducer.""" + pass + + +class SentimentAnalysisAIAgentProducer(agent.AbstractAIAgentChannelProducer): + """Producer specialized in sentiment analysis evaluation.""" + + AGENT_NAME = "SentimentAnalysisAgent" + AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = SentimentAnalysisAIAgentChannel + AGENT_CONSUMER = SentimentAnalysisAIAgentConsumer - def __init__(self, **kwargs): - super().__init__("sentiment_analysis", **kwargs) + def __init__(self, channel, **kwargs): + super().__init__(channel, **kwargs) def _get_default_prompt(self) -> str: return ( @@ -51,7 +68,7 @@ def _get_default_prompt(self) -> str: "Output only valid JSON matching the SentimentAnalysisOutput schema." ) - async def execute(self, input_data, llm_service) -> dict: + async def execute(self, input_data, ai_service) -> dict: """Evaluate aggregated sentiment analysis data.""" aggregated_data = input_data if not aggregated_data: @@ -64,8 +81,8 @@ async def execute(self, input_data, llm_service) -> dict: data_str = json.dumps(aggregated_data, indent=2) messages = [ - llm_service.create_message("system", self.prompt), - llm_service.create_message( + ai_service.create_message("system", self.prompt), + ai_service.create_message( "user", f"Sentiment analysis data:\n{data_str}\n\n" "Provide evaluation as JSON matching the SentimentAnalysisOutput schema. " @@ -75,11 +92,11 @@ async def execute(self, input_data, llm_service) -> dict: ] try: + # Uses SentimentAnalysisAIAgentChannel.OUTPUT_SCHEMA by default parsed = await self._call_llm( messages, - llm_service, + ai_service, json_output=True, - response_schema=SentimentAnalysisOutput, ) eval_note = float(parsed.get("eval_note", 0)) eval_note_description = parsed.get("description", "Sentiment analysis") diff --git a/Agent/Evaluators/summarization_agent/__init__.py b/Agent/Evaluators/summarization_agent/__init__.py new file mode 100644 index 000000000..8034b21d9 --- /dev/null +++ b/Agent/Evaluators/summarization_agent/__init__.py @@ -0,0 +1,29 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from .summarization_agent import ( + SummarizationAIAgentChannel, + SummarizationAIAgentConsumer, + SummarizationAIAgentProducer, +) +from .models import SummarizationOutput + +__all__ = [ + "SummarizationAIAgentChannel", + "SummarizationAIAgentConsumer", + "SummarizationAIAgentProducer", + "SummarizationOutput", +] diff --git a/Agent/Evaluators/summarization_agent/metadata.json b/Agent/Evaluators/summarization_agent/metadata.json new file mode 100644 index 000000000..d9e7c3814 --- /dev/null +++ b/Agent/Evaluators/summarization_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["SummarizationAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Evaluators/summarization_agent/models.py b/Agent/Evaluators/summarization_agent/models.py new file mode 100644 index 000000000..82d44e768 --- /dev/null +++ b/Agent/Evaluators/summarization_agent/models.py @@ -0,0 +1,37 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Pydantic models for summarization agent outputs. +""" +from pydantic import BaseModel, Field + + +class SummarizationOutput(BaseModel): + """Output from the summarization agent.""" + eval_note: float = Field( + ge=-1.0, + le=1.0, + description="Final evaluation score from -1 (strong sell) to 1 (strong buy)." + ) + confidence: float = Field( + ge=0.0, + le=1.0, + description="Confidence level of the final analysis (0-1)." + ) + description: str = Field( + description="Comprehensive summary of market analysis including key consensus points, overall outlook (bullish/bearish/mixed), key recommendations, and identified risks." + ) diff --git a/Agent/Evaluators/summarization_agent/summarization_agent.py b/Agent/Evaluators/summarization_agent/summarization_agent.py new file mode 100644 index 000000000..cd085cea7 --- /dev/null +++ b/Agent/Evaluators/summarization_agent/summarization_agent.py @@ -0,0 +1,259 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. +import json +import typing + +import octobot_commons.constants as common_constants + +import octobot_agents as agent + +from .models import SummarizationOutput + + +class AgentResult(typing.TypedDict, total=False): + """Type definition for agent evaluation results.""" + eval_note: float | str | None + eval_note_description: str + confidence: float + error: str + agent_name: str + agent_id: str + result: dict # Nested result from team execution + + +# Input can be either a dict mapping agent names to results, or a list of results +AgentResultsDict = dict[str, AgentResult] +AgentResultsList = list[AgentResult] +AgentResultsInput = AgentResultsDict | AgentResultsList + + +class SummarizationAIAgentChannel(agent.AbstractAgentChannel): + """Channel for SummarizationAIAgentProducer.""" + OUTPUT_SCHEMA = SummarizationOutput + + +class SummarizationAIAgentConsumer(agent.AbstractAIAgentChannelConsumer): + """Consumer for SummarizationAIAgentProducer.""" + pass + + +class SummarizationAIAgentProducer(agent.AbstractAIAgentChannelProducer): + """Producer specialized in combining multiple evaluations into a final recommendation.""" + + AGENT_NAME = "SummarizationAgent" + AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = SummarizationAIAgentChannel + AGENT_CONSUMER = SummarizationAIAgentConsumer + DEFAULT_CONFIDENCE = 50 # Default confidence value (0-100 scale) + + def __init__(self, channel, synthesis_method: str = "weighted", **kwargs): + super().__init__(channel, **kwargs) + self.synthesis_method = synthesis_method + + def _get_default_prompt(self) -> str: + return ( + "You are a Market Analysis Synthesis AI expert. Your task is to combine multiple specialized AI agent evaluations " + "into a single, coherent, and actionable trading recommendation.\n\n" + "Follow these steps:\n" + "1. Review all agent evaluations comprehensively: technical, sentiment, and real-time analysis\n" + "2. Assess overall market direction: Determine if signals indicate strong bullish, bearish, neutral, or mixed conditions\n" + "3. Consider different perspectives: Weight technical signals, social sentiment, and real-time momentum appropriately\n" + "4. Evaluate signal convergence/divergence: Look for confirmation across agents vs. conflicting signals\n" + "5. Calculate balanced final eval_note: Use the full range from -1 (strong sell) to 1 (strong buy), but most syntheses result in neutral to mildly bullish/bearish recommendations\n" + "6. Consider confidence levels (0-1): Higher confidence for consistent signals; lower for conflicting or weak data\n" + "7. Provide detailed reasoning in description: Explain key consensus points, overall outlook, recommendations, and identified risks\n\n" + "Important: Markets are rarely extremely bullish or bearish. Use extreme values (-1/1) only for very strong, consistent signals across all agents.\n\n" + "MANDATORY FIELDS:\n" + "- eval_note: final score from -1 (strong sell) to 1 (strong buy)\n" + "- confidence: confidence level (0-1)\n" + "- description: comprehensive summary including key points, outlook (bullish/bearish/mixed), recommendations, and risks\n\n" + "Output only valid JSON matching the SummarizationOutput schema with these three fields." + ) + + def _collect_failure_details( + self, + agent_results_dict: AgentResultsDict | None, + agent_results_list: AgentResultsList, + ) -> str: + """Collect detailed information about why agent evaluations failed.""" + failure_reasons: list[str] = [] + + # Use dict with agent names if available, otherwise use list + if agent_results_dict is not None: + for agent_name, result in agent_results_dict.items(): + reason = self._get_failure_reason(result, agent_name) + if reason: + failure_reasons.append(reason) + else: + for i, result in enumerate(agent_results_list): + agent_name = result.get("agent_name", f"Agent_{i}") + reason = self._get_failure_reason(result, agent_name) + if reason: + failure_reasons.append(reason) + + if not failure_reasons: + return f"Received {len(agent_results_list)} result(s) but all had eval_note=None" + + return "Failures: " + "; ".join(failure_reasons) + + def _get_failure_reason(self, result: AgentResult, agent_name: str) -> str: + """Extract the failure reason from a single agent result.""" + # Check for explicit error field + if error := result.get("error"): + return f"{agent_name}: {error}" + + # Check for error in description + if result.get("eval_note") is None: + if desc := result.get("eval_note_description"): + # Truncate long descriptions + if len(desc) > 100: + desc = desc[:100] + "..." + return f"{agent_name}: {desc}" + + # Try to find any useful info in the result + available_keys = [k for k in result.keys() if result[k] is not None] + if available_keys: + return f"{agent_name}: eval_note=None (available: {', '.join(available_keys)})" + return f"{agent_name}: eval_note=None (empty result)" + + return "" + + def _unwrap_agent_result(self, result: AgentResult) -> AgentResult: + """ + Unwrap nested result from team execution. + + Team passes results as: {"agent_name": ..., "agent_id": ..., "result": {...}} + We need to extract the inner result dict which contains eval_note, etc. + """ + result_key = agent.AbstractAgentChannel.RESULT_KEY + agent_name_key = agent.AbstractAgentChannel.AGENT_NAME_KEY + + if result_key in result and isinstance(result.get(result_key), dict): + inner_result = result[result_key] + # Preserve agent_name if available + if agent_name_key not in inner_result and agent_name_key in result: + inner_result[agent_name_key] = result[agent_name_key] + return typing.cast(AgentResult, inner_result) + return result + + async def execute( + self, + input_data: AgentResultsInput, + ai_service, + context_info: dict | None = None, + ) -> tuple[float | str, str]: + """Combine multiple agent results into final evaluation.""" + if not input_data: + return common_constants.START_PENDING_EVAL_NOTE, "No agent results available" + + # Convert input to list, preserving dict for failure details if needed + agent_results_dict: AgentResultsDict | None = None + agent_results_list: AgentResultsList + if typing.TYPE_CHECKING or hasattr(input_data, "values"): + # Dict input from team (aggregated inputs) + agent_results_dict = typing.cast(AgentResultsDict, input_data) + agent_results_list = list(agent_results_dict.values()) + else: + agent_results_list = typing.cast(AgentResultsList, input_data) + + # Unwrap nested results from team execution + agent_results_list = [self._unwrap_agent_result(r) for r in agent_results_list] + + # Filter out empty/error results + valid_results = [r for r in agent_results_list if r.get("eval_note") is not None] + + if not valid_results: + # Collect failure details for better debugging + failure_details = self._collect_failure_details(agent_results_dict, agent_results_list) + return common_constants.START_PENDING_EVAL_NOTE, f"All agent evaluations failed. {failure_details}" + + # If only one result, use it directly + if len(valid_results) == 1: + result = valid_results[0] + # eval_note is guaranteed non-None due to valid_results filter + eval_note = result.get("eval_note") or common_constants.INIT_EVAL_NOTE + return float(eval_note), result.get("eval_note_description", "") + + # Prepare summarization data + summary_data = {} + for i, result in enumerate(valid_results): + summary_data[f"agent_{i}"] = { + "eval_note": result.get("eval_note", common_constants.INIT_EVAL_NOTE), + "description": result.get("eval_note_description", ""), + "confidence": result.get("confidence", self.DEFAULT_CONFIDENCE), + } + + # Add context about data completeness + context_str = "" + if context_info: + missing = context_info.get("missing_data_types", []) + available = context_info.get("available_data_types", []) + total = context_info.get("total_expected_types", []) + if missing: + context_str = f"\n\nNote: Analysis is based on incomplete data. Missing evaluator types: {missing}. Available: {available}. Expected total: {total}." + + messages = [ + ai_service.create_message("system", self.prompt), + ai_service.create_message( + "user", + f"Agent evaluations to synthesize:\n{json.dumps(summary_data, indent=2)}{context_str}\n\n" + "Provide final evaluation as JSON matching the SummarizationOutput schema with three fields: eval_note, confidence, and description.", + ), + ] + + try: + # Uses SummarizationAIAgentChannel.OUTPUT_SCHEMA by default + parsed = await self._call_llm( + messages, + ai_service, + json_output=True, + ) + final_eval_note = float(parsed.get("eval_note", common_constants.INIT_EVAL_NOTE)) + final_eval_note_description = parsed.get("description", "AI synthesis") + confidence = float(parsed.get("confidence", self.DEFAULT_CONFIDENCE)) + + # Clamp eval_note + final_eval_note = max(-1, min(1, final_eval_note)) + + # Include confidence in description + final_eval_note_description = f"{final_eval_note_description} (Confidence: {confidence:.1%})" + + return final_eval_note, final_eval_note_description + except RuntimeError: + # Fallback: weighted average of agent results + total_weight = 0.0 + weighted_sum = 0.0 + descriptions: list[str] = [] + + for result in valid_results: + confidence = float(result.get("confidence", self.DEFAULT_CONFIDENCE)) / 100.0 # Normalize to 0-1 + eval_note = float(result.get("eval_note") or common_constants.INIT_EVAL_NOTE) + weighted_sum += eval_note * confidence + total_weight += confidence + descriptions.append(result.get("eval_note_description", "")) + + if total_weight > 0: + final_eval_note = weighted_sum / total_weight + else: + final_eval_note = sum( + float(r.get("eval_note") or common_constants.INIT_EVAL_NOTE) for r in valid_results + ) / len(valid_results) + + final_eval_note_description = ( + " | ".join(descriptions) if descriptions else "Fallback synthesis" + ) + + return max(-1, min(1, final_eval_note)), final_eval_note_description diff --git a/Agent/Evaluators/technical_analysis_agent/__init__.py b/Agent/Evaluators/technical_analysis_agent/__init__.py new file mode 100644 index 000000000..adc10ede4 --- /dev/null +++ b/Agent/Evaluators/technical_analysis_agent/__init__.py @@ -0,0 +1,29 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from .technical_analysis_agent import ( + TechnicalAnalysisAIAgentChannel, + TechnicalAnalysisAIAgentConsumer, + TechnicalAnalysisAIAgentProducer, +) +from .models import TechnicalAnalysisOutput + +__all__ = [ + "TechnicalAnalysisAIAgentChannel", + "TechnicalAnalysisAIAgentConsumer", + "TechnicalAnalysisAIAgentProducer", + "TechnicalAnalysisOutput", +] diff --git a/Agent/Evaluators/technical_analysis_agent/metadata.json b/Agent/Evaluators/technical_analysis_agent/metadata.json new file mode 100644 index 000000000..7f072ecd3 --- /dev/null +++ b/Agent/Evaluators/technical_analysis_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["TechnicalAnalysisAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Evaluators/technical_analysis_agent/models.py b/Agent/Evaluators/technical_analysis_agent/models.py new file mode 100644 index 000000000..14cf1ee0e --- /dev/null +++ b/Agent/Evaluators/technical_analysis_agent/models.py @@ -0,0 +1,68 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Pydantic models for technical analysis agent outputs. +""" +from typing import List, Optional +from pydantic import BaseModel, Field, field_validator + + +class TechnicalAnalysisOutput(BaseModel): + """Output from the technical analysis agent.""" + eval_note: float = Field( + ge=-1.0, + le=1.0, + description="Evaluation score from -1 (strong sell) to 1 (strong buy)." + ) + confidence: float = Field( + ge=0.0, + le=1.0, + description="Confidence level of the analysis (0-1)." + ) + description: str = Field( + description="Summary description of the technical analysis." + ) + trend: Optional[str] = Field( + default=None, + description="Current market trend: 'uptrend', 'downtrend', 'sideways'. Leave empty if unclear." + ) + support_level: Optional[float] = Field( + default=None, + description="Identified support level price. Leave empty if no clear support identified." + ) + resistance_level: Optional[float] = Field( + default=None, + description="Identified resistance level price. Leave empty if no clear resistance identified." + ) + key_indicators: Optional[List[str]] = Field( + default=None, + description="Key technical indicators analyzed. Leave empty if no relevant indicators." + ) + recommendations: Optional[List[str]] = Field( + default=None, + description="Trading recommendations based on technical analysis. Leave empty if no specific recommendations." + ) + + @field_validator("trend") + def validate_trend(cls, v: str) -> str: + if v is None: + return None + allowed_trends = ["uptrend", "downtrend", "sideways"] + v_lower = v.lower() + if v_lower not in allowed_trends: + raise ValueError(f"Trend must be one of {allowed_trends}") + return v_lower diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/technical_analysis_agent.py b/Agent/Evaluators/technical_analysis_agent/technical_analysis_agent.py similarity index 82% rename from Evaluator/Strategies/ai_strategies_evaluator/agents/technical_analysis_agent.py rename to Agent/Evaluators/technical_analysis_agent/technical_analysis_agent.py index 821a4aac2..db384edcc 100644 --- a/Evaluator/Strategies/ai_strategies_evaluator/agents/technical_analysis_agent.py +++ b/Agent/Evaluators/technical_analysis_agent/technical_analysis_agent.py @@ -14,15 +14,32 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. import json -from .base_agent import BaseAgent + +import octobot_agents as agent + from .models import TechnicalAnalysisOutput -class TechnicalAnalysisAgent(BaseAgent): - """Agent specialized in technical analysis evaluation.""" +class TechnicalAnalysisAIAgentChannel(agent.AbstractAgentChannel): + """Channel for TechnicalAnalysisAIAgentProducer.""" + OUTPUT_SCHEMA = TechnicalAnalysisOutput + + +class TechnicalAnalysisAIAgentConsumer(agent.AbstractAIAgentChannelConsumer): + """Consumer for TechnicalAnalysisAIAgentProducer.""" + pass + + +class TechnicalAnalysisAIAgentProducer(agent.AbstractAIAgentChannelProducer): + """Producer specialized in technical analysis evaluation.""" + + AGENT_NAME = "TechnicalAnalysisAgent" + AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = TechnicalAnalysisAIAgentChannel + AGENT_CONSUMER = TechnicalAnalysisAIAgentConsumer - def __init__(self, **kwargs): - super().__init__("technical_analysis", **kwargs) + def __init__(self, channel, **kwargs): + super().__init__(channel, **kwargs) def _get_default_prompt(self) -> str: return ( @@ -49,7 +66,7 @@ def _get_default_prompt(self) -> str: "Output only valid JSON matching the TechnicalAnalysisOutput schema." ) - async def execute(self, input_data, llm_service) -> dict: + async def execute(self, input_data, ai_service) -> dict: """Evaluate aggregated technical analysis data.""" aggregated_data = input_data if not aggregated_data: @@ -62,8 +79,8 @@ async def execute(self, input_data, llm_service) -> dict: data_str = json.dumps(aggregated_data, indent=2) messages = [ - llm_service.create_message("system", self.prompt), - llm_service.create_message( + ai_service.create_message("system", self.prompt), + ai_service.create_message( "user", f"Technical analysis data:\n{data_str}\n\n" "Provide evaluation as JSON matching the TechnicalAnalysisOutput schema. " @@ -73,11 +90,11 @@ async def execute(self, input_data, llm_service) -> dict: ] try: + # Uses TechnicalAnalysisAIAgentChannel.OUTPUT_SCHEMA by default parsed = await self._call_llm( messages, - llm_service, + ai_service, json_output=True, - response_schema=TechnicalAnalysisOutput, ) eval_note = float(parsed.get("eval_note", 0)) eval_note_description = parsed.get("description", "Technical analysis") diff --git a/Agent/Teams/__init__.py b/Agent/Teams/__init__.py new file mode 100644 index 000000000..7b955cf5e --- /dev/null +++ b/Agent/Teams/__init__.py @@ -0,0 +1 @@ +from .simple_ai_evaluator_agents_team import * diff --git a/Agent/Teams/simple_ai_evaluator_agents_team/__init__.py b/Agent/Teams/simple_ai_evaluator_agents_team/__init__.py new file mode 100644 index 000000000..724523afe --- /dev/null +++ b/Agent/Teams/simple_ai_evaluator_agents_team/__init__.py @@ -0,0 +1,5 @@ +from .simple_ai_evaluator_agents_team import ( + SimpleAIEvaluatorAgentsTeamChannel, + SimpleAIEvaluatorAgentsTeamConsumer, + SimpleAIEvaluatorAgentsTeam, +) diff --git a/Agent/Teams/simple_ai_evaluator_agents_team/metadata.json b/Agent/Teams/simple_ai_evaluator_agents_team/metadata.json new file mode 100644 index 000000000..87282d3a1 --- /dev/null +++ b/Agent/Teams/simple_ai_evaluator_agents_team/metadata.json @@ -0,0 +1,11 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["SimpleAIEvaluatorAgentsTeam"], + "tentacles-requirements": [ + "TechnicalAnalysisAgent", + "SentimentAnalysisAgent", + "RealTimeAnalysisAgent", + "SummarizationAgent" + ] +} diff --git a/Agent/Teams/simple_ai_evaluator_agents_team/simple_ai_evaluator_agents_team.py b/Agent/Teams/simple_ai_evaluator_agents_team/simple_ai_evaluator_agents_team.py new file mode 100644 index 000000000..c91a0f9ea --- /dev/null +++ b/Agent/Teams/simple_ai_evaluator_agents_team/simple_ai_evaluator_agents_team.py @@ -0,0 +1,196 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Simple AI Evaluator Agent Team. +Orchestrates TA, Sentiment, and RealTime agents feeding into a Summarization agent. + +DAG Structure: + TechnicalAnalysis ──┐ + SentimentAnalysis ──┼──> Summarization + RealTimeAnalysis ───┘ +""" +import typing + +import octobot_commons.constants as common_constants + +import octobot_agents as agent + +from tentacles.Agent.Evaluators.technical_analysis_agent import ( + TechnicalAnalysisAIAgentChannel, + TechnicalAnalysisAIAgentProducer, +) +from tentacles.Agent.Evaluators.sentiment_analysis_agent import ( + SentimentAnalysisAIAgentChannel, + SentimentAnalysisAIAgentProducer, +) +from tentacles.Agent.Evaluators.real_time_analysis_agent import ( + RealTimeAnalysisAIAgentChannel, + RealTimeAnalysisAIAgentProducer, +) +from tentacles.Agent.Evaluators.summarization_agent import ( + SummarizationAIAgentChannel, + SummarizationAIAgentProducer, +) + + +class SimpleAIEvaluatorAgentsTeamChannel(agent.AbstractAgentTeamChannel): + """Channel for SimpleAIEvaluatorAgentsTeam outputs.""" + pass + + +class SimpleAIEvaluatorAgentsTeamConsumer(agent.AbstractAgentTeamChannelConsumer): + """Consumer for SimpleAIEvaluatorAgentsTeam outputs.""" + pass + + +class SimpleAIEvaluatorAgentsTeam(agent.AbstractSyncAgentTeamChannelProducer): + """ + Sync team that orchestrates evaluator agents. + + Execution flow: + 1. TechnicalAnalysis, SentimentAnalysis, RealTimeAnalysis run in parallel + 2. Their outputs feed into Summarization + 3. Summarization produces final eval_note and description + + Usage: + team = SimpleAIEvaluatorAgentsTeam(ai_service=llm_service) + results = await team.run(aggregated_data) + # results["SummarizationAgent"] contains the final output + """ + + TEAM_NAME = "SimpleAIEvaluatorAgentsTeam" + TEAM_CHANNEL = SimpleAIEvaluatorAgentsTeamChannel + TEAM_CONSUMER = SimpleAIEvaluatorAgentsTeamConsumer + + def __init__( + self, + ai_service: typing.Any, + model: str | None = None, + max_tokens: int | None = None, + temperature: float | None = None, + channel: SimpleAIEvaluatorAgentsTeamChannel | None = None, + team_id: str | None = None, + include_ta: bool = True, + include_sentiment: bool = True, + include_realtime: bool = True, + ): + """ + Initialize the evaluator agent team. + + Args: + ai_service: The LLM service instance. + model: LLM model to use for all agents. + max_tokens: Maximum tokens for LLM responses. + temperature: Temperature for LLM randomness. + channel: Optional output channel for team results. + team_id: Unique identifier for this team instance. + include_ta: Whether to include TechnicalAnalysis agent. + include_sentiment: Whether to include SentimentAnalysis agent. + include_realtime: Whether to include RealTimeAnalysis agent. + """ + # Create agent producers + agents = [] + relations = [] + + if include_ta: + ta_producer = TechnicalAnalysisAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + agents.append(ta_producer) + relations.append((TechnicalAnalysisAIAgentChannel, SummarizationAIAgentChannel)) + + if include_sentiment: + sentiment_producer = SentimentAnalysisAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + agents.append(sentiment_producer) + relations.append((SentimentAnalysisAIAgentChannel, SummarizationAIAgentChannel)) + + if include_realtime: + realtime_producer = RealTimeAnalysisAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + agents.append(realtime_producer) + relations.append((RealTimeAnalysisAIAgentChannel, SummarizationAIAgentChannel)) + + # Always include summarization as the terminal agent + summarization_producer = SummarizationAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + agents.append(summarization_producer) + + super().__init__( + channel=channel, + agents=agents, + relations=relations, + ai_service=ai_service, + team_name=self.TEAM_NAME, + team_id=team_id, + ) + + async def run_with_data( + self, + aggregated_data: dict, + missing_data_types: list | None = None, + ) -> tuple[float | str, str]: + """ + Convenience method to run the team with aggregated evaluator data. + + Args: + aggregated_data: Dict mapping evaluator type to list of evaluations. + missing_data_types: Optional list of missing evaluator types. + + Returns: + Tuple of (eval_note, eval_note_description). + """ + # Build input data for entry agents based on their type + initial_data = { + "aggregated_data": aggregated_data, + "missing_data_types": missing_data_types or [], + } + + # Run the team + results = await self.run(initial_data) + + # Extract summarization result + summarization_result = results.get("SummarizationAgent") + if summarization_result is None: + return common_constants.START_PENDING_EVAL_NOTE, "Error: Summarization agent did not produce output" + + # Handle tuple result from SummarizationAIAgentProducer + if isinstance(summarization_result, tuple): + return summarization_result + + # Handle dict result + if isinstance(summarization_result, dict): + eval_note = summarization_result.get("eval_note", common_constants.START_PENDING_EVAL_NOTE) + description = summarization_result.get("eval_note_description", "") + return eval_note, description + + return common_constants.START_PENDING_EVAL_NOTE, "Error: Unexpected result format from summarization agent" diff --git a/Agent/Trading/__init__.py b/Agent/Trading/__init__.py new file mode 100644 index 000000000..0779c368c --- /dev/null +++ b/Agent/Trading/__init__.py @@ -0,0 +1,19 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from .signal_agent import * +from .risk_agent import * +from .distribution_agent import * diff --git a/Agent/Trading/distribution_agent/__init__.py b/Agent/Trading/distribution_agent/__init__.py new file mode 100644 index 000000000..daaa38e2d --- /dev/null +++ b/Agent/Trading/distribution_agent/__init__.py @@ -0,0 +1,32 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from .distribution_agent import ( + DistributionAIAgentChannel, + DistributionAIAgentConsumer, + DistributionAIAgentProducer, + run_distribution_agent, +) +from .models import AssetDistribution, DistributionOutput + +__all__ = [ + "DistributionAIAgentChannel", + "DistributionAIAgentConsumer", + "DistributionAIAgentProducer", + "run_distribution_agent", + "AssetDistribution", + "DistributionOutput", +] diff --git a/Trading/Mode/ai_trading_mode/agents/distribution_agent.py b/Agent/Trading/distribution_agent/distribution_agent.py similarity index 78% rename from Trading/Mode/ai_trading_mode/agents/distribution_agent.py rename to Agent/Trading/distribution_agent/distribution_agent.py index 0bf4499a4..96d90be0a 100644 --- a/Trading/Mode/ai_trading_mode/agents/distribution_agent.py +++ b/Agent/Trading/distribution_agent/distribution_agent.py @@ -1,4 +1,4 @@ -# Drakkar-Software OctoBot +# Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or @@ -22,40 +22,51 @@ import json import typing -from pydantic import BaseModel +import octobot_agents as agent -from tentacles.Trading.Mode.ai_trading_mode.agents.base_agent import BaseAgent -from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState -from tentacles.Trading.Mode.ai_trading_mode.agents.models import ( - DistributionOutput, - RiskAssessmentOutput, - SignalSynthesisOutput, -) +from tentacles.Agent.Trading.signal_agent.state import AIAgentState +from tentacles.Agent.Trading.signal_agent.models import SignalSynthesisOutput +from tentacles.Agent.Trading.risk_agent.models import RiskAssessmentOutput +from .models import DistributionOutput -class DistributionAgent(BaseAgent): +class DistributionAIAgentChannel(agent.AbstractAgentChannel): + """Channel for DistributionAIAgentProducer.""" + OUTPUT_SCHEMA = DistributionOutput + + +class DistributionAIAgentConsumer(agent.AbstractAIAgentChannelConsumer): + """Consumer for DistributionAIAgentProducer.""" + pass + + +class DistributionAIAgentProducer(agent.AbstractAIAgentChannelProducer): """ - Distribution agent that makes final portfolio allocation decisions. + Distribution agent producer that makes final portfolio allocation decisions. Combines signal synthesis and risk assessment to determine target distribution. """ AGENT_NAME = "DistributionAgent" AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = DistributionAIAgentChannel + AGENT_CONSUMER = DistributionAIAgentConsumer - def __init__(self, model=None, max_tokens=None, temperature=None): + def __init__(self, channel, model=None, max_tokens=None, temperature=None, **kwargs): """ - Initialize the distribution agent. + Initialize the distribution agent producer. Args: + channel: The channel this producer is registered to. model: LLM model to use. max_tokens: Maximum tokens for response. temperature: Temperature for LLM randomness. """ super().__init__( - name=self.AGENT_NAME, + channel=channel, model=model, max_tokens=max_tokens, temperature=temperature, + **kwargs, ) def _get_default_prompt(self) -> str: @@ -183,18 +194,19 @@ def _build_user_prompt(self, state: AIAgentState) -> str: - Balance opportunity with risk """ - async def execute(self, state: AIAgentState, llm_service) -> typing.Any: + async def execute(self, input_data: typing.Any, ai_service) -> typing.Any: """ Execute distribution decision. Args: - state: The current agent state. - llm_service: The LLM service instance. + input_data: The current agent state (AIAgentState). + ai_service: The AI service instance. Returns: Dictionary with distribution_output. """ - self.logger.info(f"Starting {self.name}...") + state = input_data + self.logger.debug(f"Starting {self.AGENT_NAME}...") try: messages = [ @@ -202,35 +214,36 @@ async def execute(self, state: AIAgentState, llm_service) -> typing.Any: {"role": "user", "content": self._build_user_prompt(state)}, ] + # Uses DistributionAIAgentChannel.OUTPUT_SCHEMA (DistributionOutput) by default response_data = await self._call_llm( messages, - llm_service, + ai_service, json_output=True, - response_schema=DistributionOutput, ) # Parse into model distribution_output = DistributionOutput(**response_data) - self.logger.info(f"{self.name} completed successfully.") + self.logger.debug(f"{self.AGENT_NAME} completed successfully.") return {"distribution_output": distribution_output} except Exception as e: - self.logger.exception(f"Error in {self.name}: {e}") + self.logger.exception(f"Error in {self.AGENT_NAME}: {e}") return {} -async def run_distribution_agent(state: AIAgentState, llm_service) -> dict: +async def run_distribution_agent(state: AIAgentState, ai_service, agent_id: str = "distribution-agent") -> dict: """ Convenience function to run the distribution agent. Args: state: The current agent state. - llm_service: The LLM service instance. + ai_service: The AI service instance. + agent_id: Unique identifier for the agent instance. Returns: State updates from the agent. """ - agent = DistributionAgent() - return await agent.execute(state, llm_service) + distribution_agent = DistributionAIAgentProducer(channel=None) + return await distribution_agent.execute(state, ai_service) diff --git a/Agent/Trading/distribution_agent/metadata.json b/Agent/Trading/distribution_agent/metadata.json new file mode 100644 index 000000000..72f5724ae --- /dev/null +++ b/Agent/Trading/distribution_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["DistributionAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Trading/distribution_agent/models.py b/Agent/Trading/distribution_agent/models.py new file mode 100644 index 000000000..83a5e4340 --- /dev/null +++ b/Agent/Trading/distribution_agent/models.py @@ -0,0 +1,108 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Pydantic models for distribution agent outputs. +""" +from typing import Dict, List +from pydantic import BaseModel, Field, field_validator + + +class AssetDistribution(BaseModel): + """Distribution allocation for a single asset.""" + asset: str = Field(description="Asset symbol.") + percentage: float = Field( + ge=0.0, + le=100.0, + description="Allocation percentage (0-100)." + ) + action: str = Field( + description="Action to take: 'increase', 'decrease', 'maintain', 'add', 'remove'." + ) + explanation: str = Field( + description="Explanation for this allocation." + ) + + @field_validator("action") + def validate_action(cls, v: str) -> str: + allowed_actions = ["increase", "decrease", "maintain", "add", "remove"] + v_lower = v.lower() + if v_lower not in allowed_actions: + raise ValueError(f"Action must be one of {allowed_actions}") + return v_lower + + +class DistributionOutput(BaseModel): + """Output from the distribution agent - final portfolio distribution.""" + distributions: List[AssetDistribution] = Field( + description="Target distribution for each asset." + ) + rebalance_urgency: str = Field( + description="Urgency of rebalancing: 'immediate', 'soon', 'low', 'none'." + ) + reasoning: str = Field( + description="Overall reasoning for the distribution decisions." + ) + + @field_validator("rebalance_urgency") + def validate_urgency(cls, v: str) -> str: + allowed_urgency = ["immediate", "soon", "low", "none"] + v_lower = v.lower() + if v_lower not in allowed_urgency: + raise ValueError(f"Urgency must be one of {allowed_urgency}") + return v_lower + + def get_distribution_dict(self) -> Dict[str, float]: + """Convert distributions to a simple dict format.""" + return {d.asset: d.percentage for d in self.distributions} + + def get_ai_instructions(self) -> List[dict]: + """Convert distributions to AI instruction format for ai_index_distribution.""" + from tentacles.Trading.Mode.ai_trading_mode import ai_index_distribution + + instructions = [] + for dist in self.distributions: + if dist.action == "increase": + instructions.append({ + ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_INCREASE_EXPOSURE, + ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, + ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, + }) + elif dist.action == "decrease": + instructions.append({ + ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_REDUCE_EXPOSURE, + ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, + ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, + }) + elif dist.action == "add": + instructions.append({ + ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_ADD_TO_DISTRIBUTION, + ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, + ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, + }) + elif dist.action == "remove": + instructions.append({ + ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_REMOVE_FROM_DISTRIBUTION, + ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, + }) + elif dist.action == "maintain": + instructions.append({ + ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_UPDATE_RATIO, + ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, + ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, + }) + + return instructions diff --git a/Agent/Trading/risk_agent/__init__.py b/Agent/Trading/risk_agent/__init__.py new file mode 100644 index 000000000..cc9d43014 --- /dev/null +++ b/Agent/Trading/risk_agent/__init__.py @@ -0,0 +1,32 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from .risk_agent import ( + RiskAIAgentChannel, + RiskAIAgentConsumer, + RiskAIAgentProducer, + run_risk_agent, +) +from .models import RiskMetrics, RiskAssessmentOutput + +__all__ = [ + "RiskAIAgentChannel", + "RiskAIAgentConsumer", + "RiskAIAgentProducer", + "run_risk_agent", + "RiskMetrics", + "RiskAssessmentOutput", +] diff --git a/Agent/Trading/risk_agent/metadata.json b/Agent/Trading/risk_agent/metadata.json new file mode 100644 index 000000000..0abaa508e --- /dev/null +++ b/Agent/Trading/risk_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["RiskAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Trading/risk_agent/models.py b/Agent/Trading/risk_agent/models.py new file mode 100644 index 000000000..bf7d46914 --- /dev/null +++ b/Agent/Trading/risk_agent/models.py @@ -0,0 +1,70 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Pydantic models for risk agent outputs. +""" +from typing import Dict, List, Optional +from pydantic import BaseModel, Field, field_validator + + +class RiskMetrics(BaseModel): + """Portfolio risk metrics.""" + overall_risk_level: str = Field( + description="Overall risk level: 'low', 'medium', 'high', 'critical'." + ) + concentration_risk: float = Field( + ge=0.0, + le=1.0, + description="Risk from over-concentration in few assets (0-1)." + ) + volatility_exposure: float = Field( + ge=0.0, + le=1.0, + description="Exposure to volatile assets (0-1)." + ) + liquidity_risk: float = Field( + ge=0.0, + le=1.0, + description="Risk from illiquid positions (0-1)." + ) + + @field_validator("overall_risk_level") + def validate_risk_level(cls, v: str) -> str: + allowed_levels = ["low", "medium", "high", "critical"] + v_lower = v.lower() + if v_lower not in allowed_levels: + raise ValueError(f"Risk level must be one of {allowed_levels}") + return v_lower + + +class RiskAssessmentOutput(BaseModel): + """Output from the risk assessment agent.""" + metrics: RiskMetrics = Field(description="Calculated risk metrics.") + recommendations: List[str] = Field( + description="Risk mitigation recommendations." + ) + max_allocation_per_asset: Optional[Dict[str, float]] = Field( + default_factory=dict, + description="Maximum recommended allocation percentage per asset." + ) + min_cash_reserve: Optional[float] = Field( + default=0.1, + ge=0.0, + le=1.0, + description="Minimum recommended cash/stablecoin reserve (0-1)." + ) + reasoning: str = Field(description="Explanation of the risk assessment.") diff --git a/Trading/Mode/ai_trading_mode/agents/risk_agent.py b/Agent/Trading/risk_agent/risk_agent.py similarity index 77% rename from Trading/Mode/ai_trading_mode/agents/risk_agent.py rename to Agent/Trading/risk_agent/risk_agent.py index 1bb0e66b8..4cdd4580b 100644 --- a/Trading/Mode/ai_trading_mode/agents/risk_agent.py +++ b/Agent/Trading/risk_agent/risk_agent.py @@ -1,4 +1,4 @@ -# Drakkar-Software OctoBot +# Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or @@ -21,36 +21,50 @@ import json import typing -from pydantic import BaseModel +import octobot_agents as agent -from tentacles.Trading.Mode.ai_trading_mode.agents.base_agent import BaseAgent -from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState -from tentacles.Trading.Mode.ai_trading_mode.agents.models import RiskAssessmentOutput, CryptoSignalOutput +from tentacles.Agent.Trading.signal_agent.state import AIAgentState +from tentacles.Agent.Trading.signal_agent.models import CryptoSignalOutput +from .models import RiskAssessmentOutput -class RiskAgent(BaseAgent): +class RiskAIAgentChannel(agent.AbstractAgentChannel): + """Channel for RiskAIAgentProducer.""" + OUTPUT_SCHEMA = RiskAssessmentOutput + + +class RiskAIAgentConsumer(agent.AbstractAIAgentChannelConsumer): + """Consumer for RiskAIAgentProducer.""" + pass + + +class RiskAIAgentProducer(agent.AbstractAIAgentChannelProducer): """ - Risk assessment agent that evaluates portfolio risk. + Risk assessment agent producer that evaluates portfolio risk. Uses portfolio data from trading API to assess concentration, volatility, and liquidity risks. """ AGENT_NAME = "RiskAgent" AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = RiskAIAgentChannel + AGENT_CONSUMER = RiskAIAgentConsumer - def __init__(self, model=None, max_tokens=None, temperature=None): + def __init__(self, channel, model=None, max_tokens=None, temperature=None, **kwargs): """ - Initialize the risk agent. + Initialize the risk agent producer. Args: + channel: The channel this producer is registered to. model: LLM model to use. max_tokens: Maximum tokens for response. temperature: Temperature for LLM randomness. """ super().__init__( - name=self.AGENT_NAME, + channel=channel, model=model, max_tokens=max_tokens, temperature=temperature, + **kwargs, ) def _get_default_prompt(self) -> str: @@ -152,18 +166,19 @@ def _build_user_prompt(self, state: AIAgentState) -> str: Provide risk metrics, maximum allocation limits, and mitigation recommendations as JSON. """ - async def execute(self, state: AIAgentState, llm_service) -> typing.Any: + async def execute(self, input_data: typing.Any, ai_service) -> typing.Any: """ Execute risk assessment. Args: - state: The current agent state. - llm_service: The LLM service instance. + input_data: The current agent state (AIAgentState). + ai_service: The AI service instance. Returns: Dictionary with risk_output. """ - self.logger.info(f"Starting {self.name}...") + state = input_data + self.logger.debug(f"Starting {self.AGENT_NAME}...") try: messages = [ @@ -171,35 +186,36 @@ async def execute(self, state: AIAgentState, llm_service) -> typing.Any: {"role": "user", "content": self._build_user_prompt(state)}, ] + # Uses RiskAIAgentChannel.OUTPUT_SCHEMA (RiskAssessmentOutput) by default response_data = await self._call_llm( messages, - llm_service, + ai_service, json_output=True, - response_schema=RiskAssessmentOutput, ) # Parse into model risk_output = RiskAssessmentOutput(**response_data) - self.logger.info(f"{self.name} completed successfully.") + self.logger.debug(f"{self.AGENT_NAME} completed successfully.") return {"risk_output": risk_output} except Exception as e: - self.logger.exception(f"Error in {self.name}: {e}") + self.logger.exception(f"Error in {self.AGENT_NAME}: {e}") return {} -async def run_risk_agent(state: AIAgentState, llm_service) -> dict: +async def run_risk_agent(state: AIAgentState, ai_service, agent_id: str = "risk-agent") -> dict: """ Convenience function to run the risk agent. Args: state: The current agent state. - llm_service: The LLM service instance. + ai_service: The AI service instance. + agent_id: Unique identifier for the agent instance. Returns: State updates from the agent. """ - agent = RiskAgent() - return await agent.execute(state, llm_service) + risk_agent = RiskAIAgentProducer(channel=None) + return await risk_agent.execute(state, ai_service) diff --git a/Agent/Trading/signal_agent/__init__.py b/Agent/Trading/signal_agent/__init__.py new file mode 100644 index 000000000..81553fa5c --- /dev/null +++ b/Agent/Trading/signal_agent/__init__.py @@ -0,0 +1,41 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +from .signal_agent import ( + SignalAIAgentChannel, + SignalAIAgentConsumer, + SignalAIAgentProducer, + run_signal_agent, +) +from .models import ( + SignalRecommendation, + CryptoSignalOutput, + SynthesizedSignal, + SignalSynthesisOutput, +) +from .state import AIAgentState + +__all__ = [ + "SignalAIAgentChannel", + "SignalAIAgentConsumer", + "SignalAIAgentProducer", + "run_signal_agent", + "SignalRecommendation", + "CryptoSignalOutput", + "SynthesizedSignal", + "SignalSynthesisOutput", + "AIAgentState", +] diff --git a/Agent/Trading/signal_agent/metadata.json b/Agent/Trading/signal_agent/metadata.json new file mode 100644 index 000000000..1d32889a9 --- /dev/null +++ b/Agent/Trading/signal_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["SignalAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Trading/signal_agent/models.py b/Agent/Trading/signal_agent/models.py new file mode 100644 index 000000000..738746ecc --- /dev/null +++ b/Agent/Trading/signal_agent/models.py @@ -0,0 +1,81 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +Pydantic models for signal agent outputs. +""" +from typing import List, Literal +from pydantic import BaseModel, Field + + +class SignalRecommendation(BaseModel): + """A trading signal recommendation for an asset.""" + action: Literal["buy", "sell", "hold", "increase", "decrease"] = Field( + description="Trading action: 'buy', 'sell', 'hold', 'increase', 'decrease'." + ) + confidence: float = Field( + default=0.5, + ge=0.0, + le=1.0, + description="Confidence level of the signal (0 to 1)." + ) + reasoning: str = Field( + description="Explanation of why this signal was generated." + ) + + +class CryptoSignalOutput(BaseModel): + """Output from a cryptocurrency signal agent.""" + cryptocurrency: str = Field(description="The cryptocurrency being analyzed.") + signal: SignalRecommendation = Field(description="The trading signal for this cryptocurrency.") + market_context: str = Field(description="Brief description of current market context.") + key_factors: List[str] = Field( + default_factory=list, + description="Key factors influencing this signal." + ) + + +class SynthesizedSignal(BaseModel): + """A synthesized signal for an asset combining multiple signal sources.""" + asset: str = Field(description="The asset symbol.") + direction: Literal["bullish", "bearish", "neutral"] = Field( + description="Synthesized direction: 'bullish', 'bearish', 'neutral'." + ) + strength: float = Field( + ge=0.0, + le=1.0, + description="Signal strength (0-1)." + ) + consensus_level: Literal["strong", "moderate", "weak", "conflicting"] = Field( + description="Level of agreement between signals: 'strong', 'moderate', 'weak', 'conflicting'. " + "DO NOT use 'neutral' - use 'weak' for low agreement instead." + ) + trading_instruction: str = Field( + description="Clear trading instruction derived from signals." + ) + + +class SignalSynthesisOutput(BaseModel): + """Output from the signal manager agent - synthesizes all signals.""" + synthesized_signals: List[SynthesizedSignal] = Field( + description="List of synthesized signals per asset." + ) + market_outlook: Literal["bullish", "bearish", "neutral", "mixed"] = Field( + description="Overall market outlook: 'bullish', 'bearish', 'neutral', 'mixed'." + ) + summary: str = Field( + description="Summary of the synthesized signals without making decisions." + ) diff --git a/Trading/Mode/ai_trading_mode/agents/signal_agent.py b/Agent/Trading/signal_agent/signal_agent.py similarity index 72% rename from Trading/Mode/ai_trading_mode/agents/signal_agent.py rename to Agent/Trading/signal_agent/signal_agent.py index cbeffbe9d..d7c615fdf 100644 --- a/Trading/Mode/ai_trading_mode/agents/signal_agent.py +++ b/Agent/Trading/signal_agent/signal_agent.py @@ -1,4 +1,4 @@ -# Drakkar-Software OctoBot +# Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or @@ -23,15 +23,33 @@ import typing from pydantic import BaseModel +from typing import List -from tentacles.Trading.Mode.ai_trading_mode.agents.base_agent import BaseAgent -from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState -from tentacles.Trading.Mode.ai_trading_mode.agents.models import CryptoSignalOutput, SignalSynthesisOutput +import octobot_agents as agent +from .state import AIAgentState +from .models import CryptoSignalOutput, SignalSynthesisOutput -class SignalAgent(BaseAgent): + +class SignalAgentOutput(BaseModel): + """Output schema for SignalAIAgentProducer.""" + per_crypto_signals: List[CryptoSignalOutput] + synthesis: SignalSynthesisOutput + + +class SignalAIAgentChannel(agent.AbstractAgentChannel): + """Channel for SignalAIAgentProducer.""" + OUTPUT_SCHEMA = SignalAgentOutput + + +class SignalAIAgentConsumer(agent.AbstractAIAgentChannelConsumer): + """Consumer for SignalAIAgentProducer.""" + pass + + +class SignalAIAgentProducer(agent.AbstractAIAgentChannelProducer): """ - Signal agent that analyzes all cryptocurrencies and synthesizes signals. + Signal agent producer that analyzes all cryptocurrencies and synthesizes signals. This agent: 1. Analyzes each cryptocurrency against all available data @@ -41,21 +59,25 @@ class SignalAgent(BaseAgent): AGENT_NAME = "SignalAgent" AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = SignalAIAgentChannel + AGENT_CONSUMER = SignalAIAgentConsumer - def __init__(self, model=None, max_tokens=None, temperature=None): + def __init__(self, channel, model=None, max_tokens=None, temperature=None, **kwargs): """ - Initialize the signal agent. + Initialize the signal agent producer. Args: + channel: The channel this producer is registered to. model: LLM model to use. max_tokens: Maximum tokens for response. temperature: Temperature for LLM randomness. """ super().__init__( - name=self.AGENT_NAME, + channel=channel, model=model, max_tokens=max_tokens, temperature=temperature, + **kwargs, ) def _get_default_prompt(self) -> str: @@ -94,13 +116,15 @@ def _get_default_prompt(self) -> str: DO NOT use "buy", "sell", "hold", "increase", or "decrease" in the direction field. ONLY use: bullish, bearish, or neutral. -## Consensus Levels (for "consensus_level" field) +## Consensus Levels (for "consensus_level" field ONLY) Must be EXACTLY one of: - "strong": High agreement (>0.7 confidence) - "moderate": Moderate agreement (0.5-0.7) - "weak": Low agreement or mixed signals - "conflicting": Opposing signals +⚠️ CRITICAL: "neutral" is NOT valid for consensus_level - use "weak" instead. + ## Market Outlook (for "market_outlook" field) Must be EXACTLY one of: - "bullish": Majority positive signals @@ -111,6 +135,12 @@ def _get_default_prompt(self) -> str: Be precise, data-driven, and base all recommendations ONLY on provided data. """ + def _format_strategy_data(self, data: dict) -> str: + """Format strategy data for the prompt.""" + if not data: + return "No data available" + return json.dumps(data, indent=2, default=str) + def _build_user_prompt(self, state: AIAgentState) -> str: """Build the user prompt with all available data.""" global_strategy = state.get("global_strategy_data", {}) @@ -127,10 +157,10 @@ def _build_user_prompt(self, state: AIAgentState) -> str: # Analyze All Cryptocurrencies and Synthesize Signals ## Global Strategy Data -{self.format_strategy_data(global_strategy)} +{self._format_strategy_data(global_strategy)} ## Per-Cryptocurrency Strategy Data -{self.format_strategy_data(crypto_strategy)} +{self._format_strategy_data(crypto_strategy)} ## Tracked Cryptocurrencies {json.dumps(cryptocurrencies, indent=2)} @@ -170,23 +200,19 @@ def _build_user_prompt(self, state: AIAgentState) -> str: Remember: Base ONLY on the provided data. Do not make allocation decisions - only synthesize. """ - async def execute(self, state: AIAgentState, llm_service) -> typing.Any: + async def execute(self, input_data: typing.Any, ai_service) -> typing.Any: """ Execute signal analysis and synthesis. Args: - state: The current agent state. - llm_service: The LLM service instance. + input_data: The current agent state (AIAgentState). + ai_service: The AI service instance. Returns: Dictionary with signal_outputs and signal_synthesis. """ - self.logger.info(f"Starting {self.name}...") - - # Create wrapper model for strict schema validation - class SignalAgentOutput(BaseModel): - per_crypto_signals: list[CryptoSignalOutput] - synthesis: SignalSynthesisOutput + state = input_data + self.logger.debug(f"Starting {self.AGENT_NAME}...") try: messages = [ @@ -194,11 +220,11 @@ class SignalAgentOutput(BaseModel): {"role": "user", "content": self._build_user_prompt(state)}, ] + # Uses SignalAIAgentChannel.OUTPUT_SCHEMA (SignalAgentOutput) by default response_data = await self._call_llm( messages, - llm_service, + ai_service, json_output=True, - response_schema=SignalAgentOutput, ) # Process per-crypto signals @@ -211,11 +237,14 @@ class SignalAgentOutput(BaseModel): signal_output = CryptoSignalOutput(**signal_data) signal_outputs["signals"][crypto] = signal_output - # Process synthesis + # Process synthesis with pre-validation normalization synthesis_data = response_data.get("synthesis", {}) - synthesis_output = SignalSynthesisOutput(**synthesis_data) if synthesis_data else None + if synthesis_data: + synthesis_output = SignalSynthesisOutput(**synthesis_data) + else: + synthesis_output = None - self.logger.info(f"{self.name} completed successfully.") + self.logger.debug(f"{self.AGENT_NAME} completed successfully.") return { "signal_outputs": signal_outputs, @@ -223,20 +252,21 @@ class SignalAgentOutput(BaseModel): } except Exception as e: - self.logger.exception(f"Error in {self.name}: {e}") + self.logger.exception(f"Error in {self.AGENT_NAME}: {e}") return {} -async def run_signal_agent(state: AIAgentState, llm_service) -> dict: +async def run_signal_agent(state: AIAgentState, ai_service, agent_id: str = "signal-agent") -> dict: """ Convenience function to run the signal agent. Args: state: The current agent state. - llm_service: The LLM service instance. + ai_service: The AI service instance. + agent_id: Unique identifier for the agent instance. Returns: State updates from the agent. """ - agent = SignalAgent() - return await agent.execute(state, llm_service) + signal_agent = SignalAIAgentProducer(channel=None) + return await signal_agent.execute(state, ai_service) diff --git a/Trading/Mode/ai_trading_mode/agents/state.py b/Agent/Trading/signal_agent/state.py similarity index 70% rename from Trading/Mode/ai_trading_mode/agents/state.py rename to Agent/Trading/signal_agent/state.py index ef53f42b0..220e86fce 100644 --- a/Trading/Mode/ai_trading_mode/agents/state.py +++ b/Agent/Trading/signal_agent/state.py @@ -1,4 +1,4 @@ -# Drakkar-Software OctoBot +# Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or @@ -17,17 +17,9 @@ """ AI Agent State definitions for the trading mode agents. """ -import operator from typing import Dict, List, Optional, Any from typing_extensions import Annotated, TypedDict -from tentacles.Trading.Mode.ai_trading_mode.agents.models import ( - CryptoSignalOutput, - RiskAssessmentOutput, - SignalSynthesisOutput, - DistributionOutput, -) - def merge_dicts(a: dict, b: dict) -> dict: """Merge two dictionaries, with b overwriting a.""" @@ -65,32 +57,27 @@ class StrategyData(TypedDict, total=False): evaluation_type: str -class SignalAgentsOutput(TypedDict, total=False): - """Output from all signal agents.""" - signals: Annotated[Dict[str, CryptoSignalOutput], merge_dicts] - - class AIAgentState(TypedDict, total=False): """ Shared state for all AI trading agents. Contains strategy data, portfolio info, and agent outputs. """ # Input data - global_strategy_data: Dict[str, List[StrategyData]] - crypto_strategy_data: Dict[str, Dict[str, List[StrategyData]]] # {cryptocurrency: strategy_data} + global_strategy_data: Dict[str, List[Any]] + crypto_strategy_data: Dict[str, Dict[str, List[Any]]] # {cryptocurrency: strategy_data} cryptocurrencies: List[str] reference_market: str # Trading context - portfolio: Annotated[PortfolioState, merge_dicts] - orders: Annotated[OrdersState, merge_dicts] + portfolio: Dict[str, Any] + orders: Dict[str, Any] current_distribution: Dict[str, float] # Current portfolio distribution percentages # Agent outputs - signal_outputs: Annotated[SignalAgentsOutput, merge_dicts] - risk_output: Annotated[Optional[RiskAssessmentOutput], replace_value] - signal_synthesis: Annotated[Optional[SignalSynthesisOutput], replace_value] - distribution_output: Annotated[Optional[DistributionOutput], replace_value] + signal_outputs: Dict[str, Any] + risk_output: Optional[Any] + signal_synthesis: Optional[Any] + distribution_output: Optional[Any] # Metadata exchange_name: str diff --git a/Agent/__init__.py b/Agent/__init__.py new file mode 100644 index 000000000..029ec40b0 --- /dev/null +++ b/Agent/__init__.py @@ -0,0 +1,3 @@ +from .Evaluators import * +from .Teams import * +from .Trading import * diff --git a/Evaluator/Strategies/ai_strategies_evaluator/__init__.py b/Evaluator/Strategies/ai_strategies_evaluator/__init__.py index a5d827e36..f90d2ea3a 100644 --- a/Evaluator/Strategies/ai_strategies_evaluator/__init__.py +++ b/Evaluator/Strategies/ai_strategies_evaluator/__init__.py @@ -3,11 +3,3 @@ CryptoLLMAIStrategyEvaluator, GlobalLLMAIStrategyEvaluator ) -from .agents import ( - BaseAgent, - SummarizationAgent, - TechnicalAnalysisAgent, - SentimentAnalysisAgent, - RealTimeAnalysisAgent, - AgentFactory, -) diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/base_agent.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/base_agent.py deleted file mode 100644 index b11003ced..000000000 --- a/Evaluator/Strategies/ai_strategies_evaluator/agents/base_agent.py +++ /dev/null @@ -1,102 +0,0 @@ -# Drakkar-Software OctoBot-Tentacles -# Copyright (c) Drakkar-Software, All rights reserved. -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 3.0 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library. -import typing -import json -import logging -from abc import ABC, abstractmethod - - -class BaseAgent(ABC): - """Abstract base class for LLM-powered agents with common configuration and functionality.""" - - DEFAULT_MODEL = None - DEFAULT_MAX_TOKENS = 10000 - DEFAULT_TEMPERATURE = 0.3 - MAX_RETRIES = 3 - - def __init__(self, name: str, model=None, max_tokens=None, temperature=None): - self.name = name - self.model = model or self.DEFAULT_MODEL - self.max_tokens = max_tokens or self.DEFAULT_MAX_TOKENS - self.temperature = temperature or self.DEFAULT_TEMPERATURE - self.evaluator_type = None # Optional: associated evaluator type - self._custom_prompt = None - self.logger = logging.getLogger(f"[Agent][{self.name}]") - - @property - def prompt(self) -> str: - """Get the agent's prompt, allowing override via config.""" - return self._custom_prompt or self._get_default_prompt() - - @prompt.setter - def prompt(self, value: str): - """Allow custom prompt override.""" - self._custom_prompt = value - - @abstractmethod - def _get_default_prompt(self) -> str: - """Return the default prompt for this agent type.""" - pass - - @abstractmethod - async def execute(self, input_data, llm_service) -> typing.Any: - """Execute the agent's primary function.""" - pass - - async def _call_llm(self, messages, llm_service, json_output=True, response_schema=None): - """ - Common LLM calling method with error handling and automatic retries. - - Args: - messages: List of message dicts with 'role' and 'content'. - llm_service: The LLM service instance. - json_output: Whether to parse response as JSON. - response_schema: Optional Pydantic model or JSON schema for structured output. - - Returns: - Parsed JSON dict or raw string response. - - Raises: - RuntimeError: If all retries are exhausted. - """ - last_exception = None - - for attempt in range(1, self.MAX_RETRIES + 1): - try: - response = await llm_service.get_completion( - messages=messages, - model=self.model, - max_tokens=self.max_tokens, - temperature=self.temperature, - json_output=json_output, - response_schema=response_schema, - ) - if json_output: - return json.loads(response.strip()) - return response.strip() - except (json.JSONDecodeError, ValueError, KeyError, AttributeError) as e: - last_exception = e - if attempt < self.MAX_RETRIES: - self.logger.warning( - f"LLM call failed on attempt {attempt}/{self.MAX_RETRIES} for agent {self.name}: {str(e)}. Retrying..." - ) - else: - self.logger.error( - f"LLM call failed on final attempt {attempt}/{self.MAX_RETRIES} for agent {self.name}: {str(e)}" - ) - - # All retries exhausted - raise RuntimeError(f"LLM call failed for agent {self.name} after {self.MAX_RETRIES} retries: {str(last_exception)}") diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/factory.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/factory.py deleted file mode 100644 index 34edda666..000000000 --- a/Evaluator/Strategies/ai_strategies_evaluator/agents/factory.py +++ /dev/null @@ -1,56 +0,0 @@ -# Drakkar-Software OctoBot-Tentacles -# Copyright (c) Drakkar-Software, All rights reserved. -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 3.0 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library. -import octobot_evaluators.enums as evaluators_enums -from .base_agent import BaseAgent -from .summarization_agent import SummarizationAgent -from .technical_analysis_agent import TechnicalAnalysisAgent -from .sentiment_analysis_agent import SentimentAnalysisAgent -from .real_time_analysis_agent import RealTimeAnalysisAgent - - -class AgentFactory: - """Factory for creating LLM agents.""" - - @staticmethod - def create_summarization_agent( - synthesis_method="weighted", **config - ) -> SummarizationAgent: - return SummarizationAgent(synthesis_method=synthesis_method, **config) - - @staticmethod - def create_technical_agent(**config) -> TechnicalAnalysisAgent: - return TechnicalAnalysisAgent(**config) - - @staticmethod - def create_sentiment_agent(**config) -> SentimentAnalysisAgent: - return SentimentAnalysisAgent(**config) - - @staticmethod - def create_realtime_agent(**config) -> RealTimeAnalysisAgent: - return RealTimeAnalysisAgent(**config) - - @staticmethod - def create_agent_for_evaluator_type(evaluator_type: str, **config) -> BaseAgent: - """Create appropriate agent for evaluator type.""" - type_to_agent = { - evaluators_enums.EvaluatorMatrixTypes.TA.value: TechnicalAnalysisAgent, - evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value: SentimentAnalysisAgent, - evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value: RealTimeAnalysisAgent, - } - agent_class = type_to_agent.get(evaluator_type) - if agent_class: - return agent_class(**config) - raise ValueError(f"No agent available for evaluator type: {evaluator_type}") diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/models.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/models.py deleted file mode 100644 index 13eaaec8e..000000000 --- a/Evaluator/Strategies/ai_strategies_evaluator/agents/models.py +++ /dev/null @@ -1,209 +0,0 @@ -# Drakkar-Software OctoBot-Tentacles -# Copyright (c) Drakkar-Software, All rights reserved. -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 3.0 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library. - -""" -Pydantic models for AI strategy evaluator agent outputs. -""" -from typing import Dict, List, Optional -from pydantic import BaseModel, Field, field_validator - - -class TechnicalAnalysisOutput(BaseModel): - """Output from the technical analysis agent.""" - eval_note: float = Field( - ge=-1.0, - le=1.0, - description="Evaluation score from -1 (strong sell) to 1 (strong buy)." - ) - confidence: float = Field( - ge=0.0, - le=1.0, - description="Confidence level of the analysis (0-1)." - ) - description: str = Field( - description="Summary description of the technical analysis." - ) - trend: Optional[str] = Field( - default=None, - description="Current market trend: 'uptrend', 'downtrend', 'sideways'. Leave empty if unclear." - ) - support_level: Optional[float] = Field( - default=None, - description="Identified support level price. Leave empty if no clear support identified." - ) - resistance_level: Optional[float] = Field( - default=None, - description="Identified resistance level price. Leave empty if no clear resistance identified." - ) - key_indicators: Optional[List[str]] = Field( - default=None, - description="Key technical indicators analyzed. Leave empty if no relevant indicators." - ) - recommendations: Optional[List[str]] = Field( - default=None, - description="Trading recommendations based on technical analysis. Leave empty if no specific recommendations." - ) - - @field_validator("trend") - def validate_trend(cls, v: str) -> str: - if v is None: - return None - allowed_trends = ["uptrend", "downtrend", "sideways"] - v_lower = v.lower() - if v_lower not in allowed_trends: - raise ValueError(f"Trend must be one of {allowed_trends}") - return v_lower - - -class SentimentMetrics(BaseModel): - """Sentiment analysis metrics.""" - sentiment_score: float = Field( - ge=-1.0, - le=1.0, - description="Sentiment score from -1 (very negative) to 1 (very positive)." - ) - - @field_validator("sentiment_score") - def validate_score(cls, v: float) -> float: - return max(-1.0, min(1.0, v)) - - -class SentimentAnalysisOutput(BaseModel): - """Output from the sentiment analysis agent.""" - eval_note: float = Field( - ge=-1.0, - le=1.0, - description="Evaluation score from -1 (very negative) to 1 (very positive)." - ) - confidence: float = Field( - ge=0.0, - le=1.0, - description="Confidence level of the sentiment analysis (0-1)." - ) - description: str = Field( - description="Summary description of the sentiment analysis." - ) - sentiment_score: float = Field( - ge=-1.0, - le=1.0, - description="Detailed sentiment score from -1 to 1. Same as eval_note." - ) - sources_analyzed: Optional[List[str]] = Field( - default=None, - description="Data sources used for sentiment analysis. Leave empty if none identified." - ) - key_mentions: Optional[List[str]] = Field( - default=None, - description="Key mentions or topics driving sentiment. Leave empty if none." - ) - market_implications: Optional[str] = Field( - default=None, - description="Implications of sentiment for market direction. Leave empty if unclear." - ) - recommendations: Optional[List[str]] = Field( - default=None, - description="Trading recommendations based on sentiment. Leave empty if none." - ) - - -class RealTimeAnalysisOutput(BaseModel): - """Output from the real-time analysis agent.""" - eval_note: float = Field( - ge=-1.0, - le=1.0, - description="Evaluation score from -1 (strong selling pressure) to 1 (strong buying pressure)." - ) - confidence: float = Field( - ge=0.0, - le=1.0, - description="Confidence level of the real-time analysis (0-1)." - ) - description: str = Field( - description="Summary description of the real-time market analysis." - ) - price_momentum: Optional[float] = Field( - default=None, - ge=-1.0, - le=1.0, - description="Price momentum indicator from -1 (strong downward) to 1 (strong upward). Leave empty if no momentum." - ) - current_status: Optional[str] = Field( - default=None, - description="Current market status: 'bullish', 'bearish', 'neutral', 'volatile'. Leave empty if unclear." - ) - volume_signal: Optional[str] = Field( - default=None, - description="Volume analysis: 'high', 'normal', 'low'. Leave empty if no volume data." - ) - urgency_level: Optional[str] = Field( - default=None, - description="Action urgency: 'immediate', 'high', 'medium', 'low', 'none'. Leave empty if no urgency." - ) - critical_events: Optional[List[str]] = Field( - default=None, - description="Any critical events or catalysts detected. Leave empty if none." - ) - recommendations: Optional[List[str]] = Field( - default=None, - description="Real-time trading recommendations. Leave empty if none." - ) - - @field_validator("current_status") - def validate_status(cls, v: str) -> str: - if v is None: - return None - allowed_statuses = ["bullish", "bearish", "neutral", "volatile"] - v_lower = v.lower() - if v_lower not in allowed_statuses: - raise ValueError(f"Status must be one of {allowed_statuses}") - return v_lower - - @field_validator("volume_signal") - def validate_volume(cls, v: str) -> str: - if v is None: - return None - allowed_volumes = ["high", "normal", "low"] - v_lower = v.lower() - if v_lower not in allowed_volumes: - raise ValueError(f"Volume signal must be one of {allowed_volumes}") - return v_lower - - @field_validator("urgency_level") - def validate_urgency(cls, v: str) -> str: - if v is None: - return None - allowed_urgencies = ["immediate", "high", "medium", "low", "none"] - v_lower = v.lower() - if v_lower not in allowed_urgencies: - raise ValueError(f"Urgency level must be one of {allowed_urgencies}") - return v_lower - - -class SummarizationOutput(BaseModel): - """Output from the summarization agent.""" - eval_note: float = Field( - ge=-1.0, - le=1.0, - description="Final evaluation score from -1 (strong sell) to 1 (strong buy)." - ) - confidence: float = Field( - ge=0.0, - le=1.0, - description="Confidence level of the final analysis (0-1)." - ) - description: str = Field( - description="Comprehensive summary of market analysis including key consensus points, overall outlook (bullish/bearish/mixed), key recommendations, and identified risks." - ) diff --git a/Evaluator/Strategies/ai_strategies_evaluator/agents/summarization_agent.py b/Evaluator/Strategies/ai_strategies_evaluator/agents/summarization_agent.py deleted file mode 100644 index bd23014ad..000000000 --- a/Evaluator/Strategies/ai_strategies_evaluator/agents/summarization_agent.py +++ /dev/null @@ -1,134 +0,0 @@ -# Drakkar-Software OctoBot-Tentacles -# Copyright (c) Drakkar-Software, All rights reserved. -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 3.0 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library. -import json -from .base_agent import BaseAgent -from .models import SummarizationOutput - - -class SummarizationAgent(BaseAgent): - """Agent specialized in combining multiple evaluations into a final recommendation.""" - - def __init__(self, synthesis_method="weighted", **kwargs): - super().__init__("summarization", **kwargs) - self.synthesis_method = synthesis_method - - def _get_default_prompt(self) -> str: - return ( - "You are a Market Analysis Synthesis AI expert. Your task is to combine multiple specialized AI agent evaluations " - "into a single, coherent, and actionable trading recommendation.\n\n" - "Follow these steps:\n" - "1. Review all agent evaluations comprehensively: technical, sentiment, and real-time analysis\n" - "2. Assess overall market direction: Determine if signals indicate strong bullish, bearish, neutral, or mixed conditions\n" - "3. Consider different perspectives: Weight technical signals, social sentiment, and real-time momentum appropriately\n" - "4. Evaluate signal convergence/divergence: Look for confirmation across agents vs. conflicting signals\n" - "5. Calculate balanced final eval_note: Use the full range from -1 (strong sell) to 1 (strong buy), but most syntheses result in neutral to mildly bullish/bearish recommendations\n" - "6. Consider confidence levels (0-1): Higher confidence for consistent signals; lower for conflicting or weak data\n" - "7. Provide detailed reasoning in description: Explain key consensus points, overall outlook, recommendations, and identified risks\n\n" - "Important: Markets are rarely extremely bullish or bearish. Use extreme values (-1/1) only for very strong, consistent signals across all agents.\n\n" - "MANDATORY FIELDS:\n" - "- eval_note: final score from -1 (strong sell) to 1 (strong buy)\n" - "- confidence: confidence level (0-1)\n" - "- description: comprehensive summary including key points, outlook (bullish/bearish/mixed), recommendations, and risks\n\n" - "Output only valid JSON matching the SummarizationOutput schema with these three fields." - ) - - async def execute(self, input_data, llm_service, context_info=None) -> tuple: - """Combine multiple agent results into final evaluation.""" - agent_results = input_data - if not agent_results: - return 0, "No agent results available" - - # Filter out empty/error results - valid_results = [r for r in agent_results if r.get("eval_note") is not None] - - if not valid_results: - return 0, "All agent evaluations failed" - - # If only one result, use it directly - if len(valid_results) == 1: - result = valid_results[0] - return result["eval_note"], result["eval_note_description"] - - # Prepare summarization data - summary_data = {} - for i, result in enumerate(valid_results): - summary_data[f"agent_{i}"] = { - "eval_note": result.get("eval_note", 0), - "description": result.get("eval_note_description", ""), - "confidence": result.get("confidence", 0), - } - - # Add context about data completeness - context_str = "" - if context_info: - missing = context_info.get("missing_data_types", []) - available = context_info.get("available_data_types", []) - total = context_info.get("total_expected_types", []) - if missing: - context_str = f"\n\nNote: Analysis is based on incomplete data. Missing evaluator types: {missing}. Available: {available}. Expected total: {total}." - - messages = [ - llm_service.create_message("system", self.prompt), - llm_service.create_message( - "user", - f"Agent evaluations to synthesize:\n{json.dumps(summary_data, indent=2)}{context_str}\n\n" - "Provide final evaluation as JSON matching the SummarizationOutput schema with three fields: eval_note, confidence, and description.", - ), - ] - - try: - parsed = await self._call_llm( - messages, - llm_service, - json_output=True, - response_schema=SummarizationOutput, - ) - final_eval_note = float(parsed.get("eval_note", 0)) - final_eval_note_description = parsed.get("description", "AI synthesis") - confidence = float(parsed.get("confidence", 0)) - - # Clamp eval_note - final_eval_note = max(-1, min(1, final_eval_note)) - - # Include confidence in description - final_eval_note_description = f"{final_eval_note_description} (Confidence: {confidence:.1%})" - - return final_eval_note, final_eval_note_description - except RuntimeError: - # Fallback: weighted average of agent results - total_weight = 0 - weighted_sum = 0 - descriptions = [] - - for result in valid_results: - confidence = result.get("confidence", 50) / 100.0 # Normalize to 0-1 - eval_note = result.get("eval_note", 0) - weighted_sum += eval_note * confidence - total_weight += confidence - descriptions.append(result.get("eval_note_description", "")) - - if total_weight > 0: - final_eval_note = weighted_sum / total_weight - else: - final_eval_note = sum( - r.get("eval_note", 0) for r in valid_results - ) / len(valid_results) - - final_eval_note_description = ( - " | ".join(descriptions) if descriptions else "Fallback synthesis" - ) - - return max(-1, min(1, final_eval_note)), final_eval_note_description diff --git a/Evaluator/Strategies/ai_strategies_evaluator/ai_strategies.py b/Evaluator/Strategies/ai_strategies_evaluator/ai_strategies.py index 452efdf20..9acf7e9de 100644 --- a/Evaluator/Strategies/ai_strategies_evaluator/ai_strategies.py +++ b/Evaluator/Strategies/ai_strategies_evaluator/ai_strategies.py @@ -14,19 +14,17 @@ # You should have received a copy of the GNU Lesser General Public # License along with this library. import typing -import asyncio import octobot_commons.constants as common_constants import octobot_commons.enums as commons_enums import octobot_commons.evaluators_util as evaluators_util import octobot_evaluators.matrix as matrix import octobot_evaluators.enums as evaluators_enums -import octobot_evaluators.constants as evaluators_constants import octobot_evaluators.evaluators as evaluators import octobot_services.api.services as services_api import tentacles.Services.Services_bases -from .agents import AgentFactory +from tentacles.Agent.Teams.simple_ai_evaluator_agents_team import SimpleAIEvaluatorAgentsTeam class BaseLLMAIStrategyEvaluator(evaluators.StrategyEvaluator): @@ -152,74 +150,44 @@ async def _run_agents_analysis( aggregated_data: dict, missing_data_types: list, gpt_service, - ) -> tuple[float, str]: + ) -> tuple[float | str, str]: """ - Run strategy agents on aggregated data and return eval_note and description. + Run strategy agents on aggregated data using the SimpleAIEvaluatorAgentsTeam. + + Returns: + Tuple of (eval_note, eval_note_description). """ - # Create strategy agents for each evaluator type - strategy_agents = [] - for eval_type in self.evaluator_types: - if eval_type in aggregated_data: - agent = AgentFactory.create_agent_for_evaluator_type( - eval_type, - model=self.model, - max_tokens=self.max_tokens, - temperature=self.temperature, - ) - # Store the evaluator type on the agent for data access - agent.evaluator_type = eval_type - strategy_agents.append(agent) - - if not strategy_agents: - self.logger.error("No valid strategy agents could be created") - return 0, "Error: No valid strategy agents" - - # Run agents in parallel - agent_results = await asyncio.gather( - *[ - agent.execute(aggregated_data[agent.evaluator_type], gpt_service) - for agent in strategy_agents - ], - return_exceptions=True, - ) - - # Filter out exceptions and get valid results - valid_results = [] - for i, result in enumerate(agent_results): - if isinstance(result, Exception): - self.logger.error(f"Strategy agent {i} failed: {result}") - else: - valid_results.append(result) - - if not valid_results: - self.logger.error("All strategy agents failed") - return 0, "Error: All strategy agents failed" - - # Use summarization agent to combine results - summarization_agent = AgentFactory.create_summarization_agent( + # Determine which agents to include based on available data + include_ta = evaluators_enums.EvaluatorMatrixTypes.TA.value in aggregated_data + include_sentiment = evaluators_enums.EvaluatorMatrixTypes.SOCIAL.value in aggregated_data + include_realtime = evaluators_enums.EvaluatorMatrixTypes.REAL_TIME.value in aggregated_data + + if not any([include_ta, include_sentiment, include_realtime]): + self.logger.error("No valid data available for any agent") + return common_constants.START_PENDING_EVAL_NOTE, "Error: No valid data available" + + # Create and run the team + team = SimpleAIEvaluatorAgentsTeam( + ai_service=gpt_service, model=self.model, max_tokens=self.max_tokens, temperature=self.temperature, + include_ta=include_ta, + include_sentiment=include_sentiment, + include_realtime=include_realtime, ) - # Pass information about missing data types to help with more appropriate analysis - context_info = { - "missing_data_types": missing_data_types, - "available_data_types": list(aggregated_data.keys()), - "total_expected_types": self.evaluator_types, - } - eval_note, eval_note_description = await summarization_agent.execute( - valid_results, gpt_service, context_info - ) - - # Apply output format if needed - if self.output_format == "with_confidence" and valid_results: - # Use average confidence from agents - avg_confidence = sum(r.get("confidence", 0) for r in valid_results) / len( - valid_results + + try: + eval_note, eval_note_description = await team.run_with_data( + aggregated_data=aggregated_data, + missing_data_types=missing_data_types, ) - eval_note_description += f" (Confidence: {avg_confidence:.0f}%)" - - return eval_note, eval_note_description + + return eval_note, eval_note_description + + except Exception as e: + self.logger.exception(f"SimpleAIEvaluatorAgentsTeam failed: {e}") + return common_constants.START_PENDING_EVAL_NOTE, f"Error: Agent team failed: {str(e)}" class CryptoLLMAIStrategyEvaluator(BaseLLMAIStrategyEvaluator): diff --git a/Services/Services_bases/gpt_service/gpt.py b/Services/Services_bases/gpt_service/gpt.py index 129138f99..48ca8abc3 100644 --- a/Services/Services_bases/gpt_service/gpt.py +++ b/Services/Services_bases/gpt_service/gpt.py @@ -21,6 +21,12 @@ import logging import datetime +try: + from openai.lib._pydantic import to_strict_json_schema +except ImportError: + # Fallback for older OpenAI SDK versions + to_strict_json_schema = None + import octobot_services.constants as services_constants import octobot_services.services as services import octobot_services.errors as errors @@ -49,10 +55,15 @@ SYSTEM = "system" USER = "user" +REASONING_EFFORT_LOW = "low" +REASONING_EFFORT_MEDIUM = "medium" +REASONING_EFFORT_HIGH = "high" +REASONING_EFFORT_VALUES = (REASONING_EFFORT_LOW, REASONING_EFFORT_MEDIUM, REASONING_EFFORT_HIGH) + -class LLMService(services.AbstractService): +class LLMService(services.AbstractService, services.AbstractAIService): BACKTESTING_ENABLED = True - DEFAULT_MODEL = "gpt-4o-mini" + DEFAULT_MODEL = None NO_TOKEN_LIMIT_VALUE = -1 def get_fields_description(self): @@ -71,6 +82,15 @@ def get_fields_description(self): f"Daily token limit (default: {self.NO_TOKEN_LIMIT_VALUE} for no limit). " "Can be overridden by GPT_DAILY_TOKEN_LIMIT environment variable." ), + services_constants.CONFIG_LLM_SHOW_REASONING: ( + "Show model thinking/reasoning. When enabled, uses Responses API for better reasoning access. " + "Default: False." + ), + services_constants.CONFIG_LLM_REASONING_EFFORT: ( + "Reasoning effort level for models that support reasoning. " + "Values: 'low', 'medium', 'high', or empty for default. " + "If configured, the model will be treated as reasoning-capable." + ), } return {} @@ -81,6 +101,8 @@ def get_default_value(self): services_constants.CONFIG_LLM_CUSTOM_BASE_URL: "", services_constants.CONFIG_LLM_MODEL: self.DEFAULT_MODEL, services_constants.CONFIG_LLM_DAILY_TOKENS_LIMIT: self.NO_TOKEN_LIMIT_VALUE, + services_constants.CONFIG_LLM_SHOW_REASONING: False, + services_constants.CONFIG_LLM_REASONING_EFFORT: "", } return {} @@ -145,6 +167,44 @@ def _load_token_limit_from_config(self): except (KeyError, TypeError, ValueError): pass + def _load_show_reasoning_from_config(self) -> bool: + """Load show_reasoning setting from config.""" + try: + config_show_reasoning = self.config[services_constants.CONFIG_CATEGORY_SERVICES][ + self.get_type() + ].get(services_constants.CONFIG_LLM_SHOW_REASONING) + if config_show_reasoning is not None: + if isinstance(config_show_reasoning, str): + return config_show_reasoning.lower() in ("true", "1", "yes") + return bool(config_show_reasoning) + except (KeyError, TypeError): + pass + return False + + def _load_reasoning_effort_from_config(self) -> typing.Optional[str]: + """Load reasoning_effort setting from config. + + Returns: + str: "low", "medium", "high", or None if not configured + """ + try: + config_reasoning_effort = self.config[services_constants.CONFIG_CATEGORY_SERVICES][ + self.get_type() + ].get(services_constants.CONFIG_LLM_REASONING_EFFORT) + if config_reasoning_effort is not None: + if isinstance(config_reasoning_effort, str): + effort = config_reasoning_effort.strip().lower() + if effort in REASONING_EFFORT_VALUES: + return effort + elif effort == "": + return None + # Try to convert other types + effort_str = str(config_reasoning_effort).lower() + return effort_str if effort_str in REASONING_EFFORT_VALUES else None + except (KeyError, TypeError): + pass + return None + @staticmethod def create_message(role, content, model: typing.Optional[str] = None): if role == SYSTEM and model in NO_SYSTEM_PROMPT_MODELS: @@ -154,6 +214,7 @@ def create_message(role, content, model: typing.Optional[str] = None): return {"role": USER, "content": content} return {"role": role, "content": content} + async def get_completion( self, messages, @@ -164,7 +225,9 @@ async def get_completion( temperature=0.5, json_output=False, response_schema=None, - ) -> typing.Optional[str]: + reasoning_effort: typing.Optional[str] = None, + show_reasoning: typing.Optional[bool] = None, + ) -> typing.Union[str, dict, None]: """Get a completion from the LLM. Args: @@ -177,13 +240,50 @@ async def get_completion( json_output: Return JSON format response_schema: Optional Pydantic model or JSON schema dict for structured output. If provided with json_output=True, enforces the response to match schema. + reasoning_effort: Set reasoning effort if model supports reasoning. + Values: "low", "medium", "high", or None to use config/default. + If configured (via parameter or config), model is treated as reasoning-capable. + show_reasoning: If True and reasoning_effort is configured, returns reasoning summary. + If None, uses config value. Default: False. + + Returns: + str: Content when show_reasoning=False + dict: {"content": str, "reasoning": str} when show_reasoning=True and reasoning available + None: On error """ self._ensure_rate_limit() try: model = model or self.model + + # Load reasoning_effort from config if not provided as parameter + if reasoning_effort is None: + reasoning_effort = self._load_reasoning_effort_from_config() + + # Determine if we should show reasoning + if show_reasoning is None: + show_reasoning = self._load_show_reasoning_from_config() + + # If reasoning_effort is configured, treat model as reasoning-capable + has_reasoning_config = reasoning_effort is not None and reasoning_effort in REASONING_EFFORT_VALUES + + # Use Responses API when reasoning is configured and show_reasoning is enabled + if has_reasoning_config and show_reasoning: + try: + return await self._get_completion_with_responses_api( + messages, model, max_tokens, n, stop, temperature, + json_output, response_schema, reasoning_effort + ) + except (AttributeError, errors.InvalidRequestError) as err: + # Fall back to Chat Completions if Responses API fails + self.logger.warning( + f"Responses API failed for {model}: {err}. " + "Falling back to Chat Completions API." + ) + # Continue with Chat Completions below + supports_params = not self._is_minimal_params_model(model) if not supports_params: - self.logger.info( + self.logger.debug( f"The {model} model does not support every required parameter, results might not be as accurate " f"as with other models." ) @@ -197,12 +297,26 @@ async def get_completion( "messages": messages, } + # Add reasoning_effort if configured + if has_reasoning_config: + api_kwargs["reasoning_effort"] = reasoning_effort + if json_output: if response_schema is not None: - schema = response_schema if hasattr(response_schema, "model_json_schema"): - # It's a Pydantic model - extract and enhance schema - schema = response_schema.model_json_schema() + # It's a Pydantic model - use OpenAI's built-in function + if to_strict_json_schema is not None: + schema = to_strict_json_schema(response_schema) + else: + # Fallback for older OpenAI SDK versions + schema = response_schema.model_json_schema() + self.logger.warning( + "to_strict_json_schema not available. " + "Schema may not be fully compatible with strict mode." + ) + else: + # response_schema is already a dict + schema = response_schema api_kwargs["response_format"] = { "type": "json_schema", "json_schema": { @@ -218,13 +332,26 @@ async def get_completion( completions = await self._get_client().chat.completions.create(**api_kwargs) if completions.usage is not None: self._update_token_usage(completions.usage.total_tokens) - return completions.choices[0].message.content + + if not completions.choices: + raise errors.InvalidRequestError( + f"Empty response from model {model}: no choices returned" + ) + + message_content = completions.choices[0].message.content + if message_content is None: + raise errors.InvalidRequestError( + f"Empty content in response from model {model}. " + "This may occur when tool calls are present or the model returned no content." + ) + return message_content except ( openai.BadRequestError, openai.UnprocessableEntityError, # error in request ) as err: if "does not support 'system' with this model" in str(err): - desc = err.message + # v2 compatibility: use getattr to safely access message attribute + desc = getattr(err, 'message', str(err)) err_message = ( f'The "{model}" model can\'t be used with {SYSTEM} prompts. ' f"It should be added to NO_SYSTEM_PROMPT_MODELS: {desc}" @@ -251,6 +378,130 @@ def _get_client(self) -> openai.AsyncOpenAI: base_url=self._get_base_url(), ) + async def _get_completion_with_responses_api( + self, + messages, + model: str, + max_tokens: int, + n: int, + stop, + temperature: float, + json_output: bool, + response_schema, + reasoning_effort: typing.Optional[str], + ) -> typing.Union[str, dict]: + """Get completion using Responses API for better reasoning access. + + This method is used for reasoning models when show_reasoning is enabled. + The Responses API provides better access to reasoning summaries. + """ + try: + # Convert messages to Responses API format + # Responses API expects input as a list of input items + input_items = [] + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + + if role == "system": + # For system messages, prepend to first user message or add as separate text + if input_items and input_items[-1].get("type") == "input_text": + # Prepend system message to last text input + input_items[-1]["text"] = f"System: {content}\n\n{input_items[-1]['text']}" + else: + input_items.append({ + "type": "input_text", + "text": f"System: {content}" + }) + elif role in ("user", "assistant"): + input_items.append({ + "type": "input_text", + "text": content + }) + + # Prepare Responses API parameters + responses_kwargs = { + "model": model, + "input": input_items, + "max_output_tokens": max_tokens, + } + + # Add reasoning_effort if specified + if reasoning_effort and reasoning_effort in REASONING_EFFORT_VALUES: + responses_kwargs["reasoning"] = {"effort": reasoning_effort} + + # Call Responses API + response = await self._get_client().responses.create(**responses_kwargs) + + # Extract content and reasoning from response + content = None + reasoning_summary = None + + if hasattr(response, 'output') and response.output: + for output_item in response.output: + # Handle different output item types + item_type = getattr(output_item, 'type', None) + + if item_type == 'text': + # Extract text content + content = getattr(output_item, 'text', None) + if content is None: + # Try alternative attribute names + content = getattr(output_item, 'content', None) + + elif item_type == 'reasoning': + # Extract reasoning summary + summary = getattr(output_item, 'summary', None) + if summary: + if isinstance(summary, list) and len(summary) > 0: + # Summary is a list of summary items + first_summary = summary[0] + reasoning_summary = getattr(first_summary, 'text', None) or str(first_summary) + elif hasattr(summary, 'text'): + reasoning_summary = summary.text + else: + reasoning_summary = str(summary) + + # Update token usage if available + if hasattr(response, 'usage') and response.usage: + total_tokens = getattr(response.usage, 'total_tokens', None) + if total_tokens: + self._update_token_usage(total_tokens) + + # Log reasoning if available + if reasoning_summary: + self.logger.info( + f"Model reasoning summary for {model} ({len(reasoning_summary)} chars): " + f"{reasoning_summary[:200]}{'...' if len(reasoning_summary) > 200 else ''}" + ) + + if content is None: + raise errors.InvalidRequestError( + f"Empty content in response from model {model} via Responses API." + ) + + # Return dict with content and reasoning if reasoning is available + if reasoning_summary: + return { + "content": content, + "reasoning": reasoning_summary + } + return content + + except AttributeError as err: + # Responses API might not be available in all SDK versions + self.logger.warning( + f"Responses API not available or error occurred: {err}. " + "Falling back to Chat Completions API." + ) + # Fall through to regular Chat Completions + raise + except Exception as err: + self.logger.error(f"Error using Responses API: {err}") + raise errors.InvalidRequestError( + f"Error when using Responses API with model {model}: {err}" + ) from err + def _is_of_series(self, model: str, series: str) -> bool: if model.startswith(series) and len(model) > 1: # avoid false positive: check if the next character is a number (ex: o3 model) @@ -303,7 +554,7 @@ def check_required_config(self, config): if self._env_secret_key is not None or self._get_base_url(): return True try: - config_key = config[services_constants.CONIG_OPENAI_SECRET_KEY] + config_key = config[services_constants.CONFIG_OPENAI_SECRET_KEY] return ( bool(config_key) and config_key not in commons_constants.DEFAULT_CONFIG_VALUES @@ -323,7 +574,7 @@ def has_required_configuration(self): def get_required_config(self): return ( - [] if self._env_secret_key else [services_constants.CONIG_OPENAI_SECRET_KEY] + [] if self._env_secret_key else [services_constants.CONFIG_OPENAI_SECRET_KEY] ) @classmethod @@ -365,13 +616,13 @@ async def prepare(self) -> None: self._load_token_limit_from_config() if self._get_base_url(): - self.logger.info(f"Using custom LLM url: {self._get_base_url()}") + self.logger.debug(f"Using custom LLM url: {self._get_base_url()}") fetched_models = await self._get_client().models.list() if fetched_models.data: - self.logger.info(f"Fetched {len(fetched_models.data)} models") + self.logger.debug(f"Fetched {len(fetched_models.data)} models") self.models = [d.id for d in fetched_models.data] else: - self.logger.info("No fetched models") + self.logger.warning("No fetched models") self.models = [] if self.model not in self.models: if self._get_base_url(): diff --git a/Trading/Mode/ai_trading_mode/agents/__init__.py b/Trading/Mode/ai_trading_mode/agents/__init__.py index 167a4111b..88bccca0e 100644 --- a/Trading/Mode/ai_trading_mode/agents/__init__.py +++ b/Trading/Mode/ai_trading_mode/agents/__init__.py @@ -1,4 +1,4 @@ -# Drakkar-Software OctoBot +# Drakkar-Software OctoBot-Tentacles # Copyright (c) Drakkar-Software, All rights reserved. # # This library is free software; you can redistribute it and/or @@ -15,13 +15,30 @@ # License along with this library. """ -AI Trading Mode Agents package. -Contains signal, risk, and distribution agents for AI-driven portfolio management. +DEPRECATED: This module is kept for backward compatibility. +All agents have been moved to tentacles.Agent.Trading/ +Please use the new locations: +- tentacles.Agent.Trading.signal_agent.SignalAgent +- tentacles.Agent.Trading.risk_agent.RiskAgent +- tentacles.Agent.Trading.distribution_agent.DistributionAgent +- tentacles.Agent.Trading.team.AIAgentTeam """ -from tentacles.Trading.Mode.ai_trading_mode.agents.team import AIAgentTeam -from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState -from tentacles.Trading.Mode.ai_trading_mode.agents.base_agent import BaseAgent -from tentacles.Trading.Mode.ai_trading_mode.agents.signal_agent import SignalAgent -from tentacles.Trading.Mode.ai_trading_mode.agents.risk_agent import RiskAgent -from tentacles.Trading.Mode.ai_trading_mode.agents.distribution_agent import DistributionAgent +# Re-export from new locations for backward compatibility +from tentacles.Agent.Trading.signal_agent import SignalAgent +from tentacles.Agent.Trading.signal_agent.state import AIAgentState +from tentacles.Agent.Trading.risk_agent import RiskAgent +from tentacles.Agent.Trading.distribution_agent import DistributionAgent +from tentacles.Agent.Trading.team import AIAgentTeam + +# BaseAgent is now AbstractAIAgentChannelProducer from octobot_agents +from octobot_agents import AbstractAIAgentChannelProducer as BaseAgent + +__all__ = [ + "AIAgentTeam", + "AIAgentState", + "BaseAgent", + "SignalAgent", + "RiskAgent", + "DistributionAgent", +] diff --git a/Trading/Mode/ai_trading_mode/agents/base_agent.py b/Trading/Mode/ai_trading_mode/agents/base_agent.py deleted file mode 100644 index 6cf569343..000000000 --- a/Trading/Mode/ai_trading_mode/agents/base_agent.py +++ /dev/null @@ -1,152 +0,0 @@ -# Drakkar-Software OctoBot -# Copyright (c) Drakkar-Software, All rights reserved. -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 3.0 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library. - -""" -Base agent class for AI trading mode agents. -""" -import logging -import json -import typing -from abc import ABC, abstractmethod - -from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState - - -class BaseAgent(ABC): - """ - Abstract base class for AI trading mode agents. - Provides common LLM calling functionality - agents only define prompts. - """ - - AGENT_NAME: str = "BaseAgent" - AGENT_VERSION: str = "1.0.0" - DEFAULT_MODEL = None - DEFAULT_MAX_TOKENS = 10000 - DEFAULT_TEMPERATURE = 0.3 - MAX_RETRIES = 3 - - def __init__(self, - name: typing.Optional[str] = None, - model: typing.Optional[str] = None, - max_tokens: typing.Optional[int] = None, - temperature: typing.Optional[float] = None): - """ - Initialize the agent. - - Args: - name: Agent name for logging. - model: LLM model to use. - max_tokens: Maximum tokens for response. - temperature: Temperature for LLM randomness. - """ - self.name = name or self.AGENT_NAME - self.model = model or self.DEFAULT_MODEL - self.max_tokens = max_tokens or self.DEFAULT_MAX_TOKENS - self.temperature = temperature or self.DEFAULT_TEMPERATURE - self.logger = logging.getLogger(f"[AITrading][{self.name}]") - self._custom_prompt = None - - @property - def prompt(self) -> str: - """Get the agent's prompt, allowing override via config.""" - return self._custom_prompt or self._get_default_prompt() - - @prompt.setter - def prompt(self, value: str): - """Allow custom prompt override.""" - self._custom_prompt = value - - @abstractmethod - def _get_default_prompt(self) -> str: - """Return the default prompt for this agent type.""" - pass - - @abstractmethod - async def execute(self, input_data: typing.Any, llm_service) -> typing.Any: - """ - Execute the agent's primary function. - - Args: - input_data: The input data for the agent to process. - llm_service: The LLM service instance. - - Returns: - The agent's output. - """ - pass - - async def _call_llm(self, messages: list, llm_service, json_output: bool = True, response_schema=None) -> typing.Any: - """ - Common LLM calling method with error handling and automatic retries. - - Args: - messages: List of message dicts with 'role' and 'content'. - llm_service: The LLM service instance. - json_output: Whether to parse response as JSON. - response_schema: Optional Pydantic model or JSON schema for structured output. - - Returns: - Parsed JSON dict or raw string response. - - Raises: - Exception: If all retries are exhausted. - """ - last_exception = None - - for attempt in range(1, self.MAX_RETRIES + 1): - try: - response = await llm_service.get_completion( - messages=messages, - model=self.model, - max_tokens=self.max_tokens, - temperature=self.temperature, - json_output=json_output, - response_schema=response_schema, - ) - if json_output: - return json.loads(response.strip()) - return response.strip() - except (json.JSONDecodeError, ValueError, KeyError, AttributeError) as e: - last_exception = e - if attempt < self.MAX_RETRIES: - self.logger.warning( - f"LLM call failed on attempt {attempt}/{self.MAX_RETRIES} for agent {self.name}: {str(e)}. Retrying..." - ) - else: - self.logger.error( - f"LLM call failed on final attempt {attempt}/{self.MAX_RETRIES} for agent {self.name}: {str(e)}" - ) - - # All retries exhausted - raise Exception(f"LLM call failed for agent {self.name} after {self.MAX_RETRIES} retries: {str(last_exception)}") - - def format_strategy_data(self, strategy_data: dict) -> str: - """Format strategy data for inclusion in prompts.""" - if not strategy_data: - return "No strategy data available." - return json.dumps(strategy_data, indent=2, default=str) - - def format_portfolio(self, portfolio: dict) -> str: - """Format portfolio data for inclusion in prompts.""" - if not portfolio: - return "No portfolio data available." - return json.dumps(portfolio, indent=2, default=str) - - def format_orders(self, orders: dict) -> str: - """Format orders data for inclusion in prompts.""" - if not orders: - return "No orders data available." - return json.dumps(orders, indent=2, default=str) diff --git a/Trading/Mode/ai_trading_mode/agents/models.py b/Trading/Mode/ai_trading_mode/agents/models.py deleted file mode 100644 index cd33c5c3f..000000000 --- a/Trading/Mode/ai_trading_mode/agents/models.py +++ /dev/null @@ -1,240 +0,0 @@ -# Drakkar-Software OctoBot -# Copyright (c) Drakkar-Software, All rights reserved. -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 3.0 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library. - -""" -Pydantic models for AI agent outputs. -""" -from typing import Dict, List, Optional -from pydantic import BaseModel, Field, field_validator - - -class SignalRecommendation(BaseModel): - """A trading signal recommendation for an asset.""" - action: str = Field( - description="Trading action: 'buy', 'sell', 'hold', 'increase', 'decrease'." - ) - confidence: float = Field( - default=0.5, - ge=0.0, - le=1.0, - description="Confidence level of the signal (0 to 1)." - ) - reasoning: str = Field( - description="Explanation of why this signal was generated." - ) - - @field_validator("action") - def validate_action(cls, v: str) -> str: - allowed_actions = ["buy", "sell", "hold", "increase", "decrease"] - v_lower = v.lower() - if v_lower not in allowed_actions: - raise ValueError(f"Action must be one of {allowed_actions}") - return v_lower - - -class CryptoSignalOutput(BaseModel): - """Output from a cryptocurrency signal agent.""" - cryptocurrency: str = Field(description="The cryptocurrency being analyzed.") - signal: SignalRecommendation = Field(description="The trading signal for this cryptocurrency.") - market_context: str = Field(description="Brief description of current market context.") - key_factors: List[str] = Field( - default_factory=list, - description="Key factors influencing this signal." - ) - - -class RiskMetrics(BaseModel): - """Portfolio risk metrics.""" - overall_risk_level: str = Field( - description="Overall risk level: 'low', 'medium', 'high', 'critical'." - ) - concentration_risk: float = Field( - ge=0.0, - le=1.0, - description="Risk from over-concentration in few assets (0-1)." - ) - volatility_exposure: float = Field( - ge=0.0, - le=1.0, - description="Exposure to volatile assets (0-1)." - ) - liquidity_risk: float = Field( - ge=0.0, - le=1.0, - description="Risk from illiquid positions (0-1)." - ) - - @field_validator("overall_risk_level") - def validate_risk_level(cls, v: str) -> str: - allowed_levels = ["low", "medium", "high", "critical"] - v_lower = v.lower() - if v_lower not in allowed_levels: - raise ValueError(f"Risk level must be one of {allowed_levels}") - return v_lower - - -class RiskAssessmentOutput(BaseModel): - """Output from the risk assessment agent.""" - metrics: RiskMetrics = Field(description="Calculated risk metrics.") - recommendations: List[str] = Field( - description="Risk mitigation recommendations." - ) - max_allocation_per_asset: Dict[str, float] = Field( - default_factory=dict, - description="Maximum recommended allocation percentage per asset." - ) - min_cash_reserve: float = Field( - default=0.1, - ge=0.0, - le=1.0, - description="Minimum recommended cash/stablecoin reserve (0-1)." - ) - reasoning: str = Field(description="Explanation of the risk assessment.") - - -class SynthesizedSignal(BaseModel): - """A synthesized signal for an asset combining multiple signal sources.""" - asset: str = Field(description="The asset symbol.") - direction: str = Field( - description="Synthesized direction: 'bullish', 'bearish', 'neutral'." - ) - strength: float = Field( - ge=0.0, - le=1.0, - description="Signal strength (0-1)." - ) - consensus_level: str = Field( - description="Level of agreement between signals: 'strong', 'moderate', 'weak', 'conflicting'." - ) - trading_instruction: str = Field( - description="Clear trading instruction derived from signals." - ) - - @field_validator("direction") - def validate_direction(cls, v: str) -> str: - allowed_directions = ["bullish", "bearish", "neutral"] - v_lower = v.lower() - if v_lower not in allowed_directions: - raise ValueError(f"Direction must be one of {allowed_directions}") - return v_lower - - @field_validator("consensus_level") - def validate_consensus(cls, v: str) -> str: - allowed_levels = ["strong", "moderate", "weak", "conflicting"] - v_lower = v.lower() - if v_lower not in allowed_levels: - raise ValueError(f"Consensus level must be one of {allowed_levels}") - return v_lower - - -class SignalSynthesisOutput(BaseModel): - """Output from the signal manager agent - synthesizes all signals.""" - synthesized_signals: List[SynthesizedSignal] = Field( - description="List of synthesized signals per asset." - ) - market_outlook: str = Field( - description="Overall market outlook: 'bullish', 'bearish', 'neutral', 'mixed'." - ) - summary: str = Field( - description="Summary of the synthesized signals without making decisions." - ) - - -class AssetDistribution(BaseModel): - """Distribution allocation for a single asset.""" - asset: str = Field(description="Asset symbol.") - percentage: float = Field( - ge=0.0, - le=100.0, - description="Allocation percentage (0-100)." - ) - action: str = Field( - description="Action to take: 'increase', 'decrease', 'maintain', 'add', 'remove'." - ) - explanation: str = Field( - description="Explanation for this allocation." - ) - - @field_validator("action") - def validate_action(cls, v: str) -> str: - allowed_actions = ["increase", "decrease", "maintain", "add", "remove"] - v_lower = v.lower() - if v_lower not in allowed_actions: - raise ValueError(f"Action must be one of {allowed_actions}") - return v_lower - - -class DistributionOutput(BaseModel): - """Output from the distribution agent - final portfolio distribution.""" - distributions: List[AssetDistribution] = Field( - description="Target distribution for each asset." - ) - rebalance_urgency: str = Field( - description="Urgency of rebalancing: 'immediate', 'soon', 'low', 'none'." - ) - reasoning: str = Field( - description="Overall reasoning for the distribution decisions." - ) - - @field_validator("rebalance_urgency") - def validate_urgency(cls, v: str) -> str: - allowed_urgency = ["immediate", "soon", "low", "none"] - v_lower = v.lower() - if v_lower not in allowed_urgency: - raise ValueError(f"Urgency must be one of {allowed_urgency}") - return v_lower - - def get_distribution_dict(self) -> Dict[str, float]: - """Convert distributions to a simple dict format.""" - return {d.asset: d.percentage for d in self.distributions} - - def get_ai_instructions(self) -> List[dict]: - """Convert distributions to AI instruction format for ai_index_distribution.""" - from tentacles.Trading.Mode.ai_trading_mode import ai_index_distribution - - instructions = [] - for dist in self.distributions: - if dist.action == "increase": - instructions.append({ - ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_INCREASE_EXPOSURE, - ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, - ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, - }) - elif dist.action == "decrease": - instructions.append({ - ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_REDUCE_EXPOSURE, - ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, - ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, - }) - elif dist.action == "add": - instructions.append({ - ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_ADD_TO_DISTRIBUTION, - ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, - ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, - }) - elif dist.action == "remove": - instructions.append({ - ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_REMOVE_FROM_DISTRIBUTION, - ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, - }) - elif dist.action == "maintain": - instructions.append({ - ai_index_distribution.INSTRUCTION_ACTION: ai_index_distribution.ACTION_UPDATE_RATIO, - ai_index_distribution.INSTRUCTION_SYMBOL: dist.asset, - ai_index_distribution.INSTRUCTION_WEIGHT: dist.percentage, - }) - - return instructions diff --git a/Trading/Mode/ai_trading_mode/agents/team.py b/Trading/Mode/ai_trading_mode/agents/team.py deleted file mode 100644 index 50df1a878..000000000 --- a/Trading/Mode/ai_trading_mode/agents/team.py +++ /dev/null @@ -1,310 +0,0 @@ -# Drakkar-Software OctoBot -# Copyright (c) Drakkar-Software, All rights reserved. -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 3.0 of the License, or (at your option) any later version. -# -# This library is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library. - -""" -AI Agent Team Orchestrator. -Coordinates the execution of all agents in the proper order: -1. Signal agent - analyzes all cryptocurrencies and synthesizes signals -2. Risk agent + Distribution agent (parallel) -3. Complete -""" -import asyncio -import logging -import os -from datetime import datetime -from typing import Dict, List, Any, Optional - -from tentacles.Trading.Mode.ai_trading_mode.agents.state import AIAgentState -from tentacles.Trading.Mode.ai_trading_mode.agents.signal_agent import SignalAgent -from tentacles.Trading.Mode.ai_trading_mode.agents.risk_agent import RiskAgent -from tentacles.Trading.Mode.ai_trading_mode.agents.distribution_agent import DistributionAgent -from tentacles.Trading.Mode.ai_trading_mode.agents.models import DistributionOutput - - -class AIAgentTeam: - """ - Orchestrates the AI agent team for portfolio management. - - Execution flow: - 1. Signal agent analyzes all cryptocurrencies and synthesizes signals - 2. Risk agent and Distribution agent run in parallel (after signals complete) - 3. Complete - """ - - def __init__(self, llm_service): - """ - Initialize the agent team. - - Args: - llm_service: The LLM service instance for agent communication. - """ - self.llm_service = llm_service - self.logger = logging.getLogger("AITradingTeam") - - async def run( - self, - global_strategy_data: Dict[str, List[dict]], - crypto_strategy_data: Dict[str, Dict[str, List[dict]]], - cryptocurrencies: List[str], - portfolio: Dict[str, Any], - orders: Dict[str, Any], - current_distribution: Dict[str, float], - reference_market: str = "USD", - exchange_name: str = "unknown", - ) -> Optional[DistributionOutput]: - """ - Execute the full agent pipeline and return distribution decisions. - - Args: - global_strategy_data: Global strategy evaluation data. - crypto_strategy_data: Per-cryptocurrency strategy data. - cryptocurrencies: List of cryptocurrencies to analyze. - portfolio: Current portfolio state from trading API. - orders: Current orders state from trading API. - current_distribution: Current portfolio distribution percentages. - reference_market: The reference market/stablecoin. - exchange_name: Name of the exchange. - - Returns: - DistributionOutput with final portfolio distribution decisions, or None. - """ - self.logger.info("Starting AI agent team execution...") - - # Initialize state - use dict to avoid type issues with TypedDict - state: AIAgentState = {} # type: ignore - state["global_strategy_data"] = global_strategy_data # type: ignore - state["crypto_strategy_data"] = crypto_strategy_data # type: ignore - state["cryptocurrencies"] = cryptocurrencies - state["reference_market"] = reference_market - state["portfolio"] = portfolio # type: ignore - state["orders"] = orders # type: ignore - state["current_distribution"] = current_distribution - state["signal_outputs"] = {"signals": {}} - state["risk_output"] = None - state["signal_synthesis"] = None - state["distribution_output"] = None - state["exchange_name"] = exchange_name - state["timestamp"] = datetime.utcnow().isoformat() - - state = await self._run_signal_agent(state) - state = await self._run_risk_agent(state) - state = await self._run_distribution_agent(state) - - self.logger.info("AI agent team execution completed.") - - return state.get("distribution_output") - - async def _run_signal_agent(self, state: AIAgentState) -> AIAgentState: - """ - Run the unified signal agent that analyzes all cryptocurrencies at once. - - Args: - state: Current agent state. - - Returns: - Updated state with signal outputs and synthesis. - - Raises: - Exception: Re-raises any exception from signal agent execution. - """ - signal_agent = SignalAgent() - - try: - result = await signal_agent.execute(state, self.llm_service) - - # Extract signal outputs - if "signal_outputs" in result: - state["signal_outputs"] = result["signal_outputs"] # type: ignore - - # Extract signal synthesis - if "signal_synthesis" in result: - state["signal_synthesis"] = result["signal_synthesis"] - - self.logger.info("Signal agent completed successfully.") - except Exception as e: - self.logger.error(f"Signal agent failed: {e}") - # Critical failure - signal analysis is required for downstream agents - raise Exception(f"Signal agent execution failed, cannot proceed: {e}") from e - - return state - - async def _run_risk_agent(self, state: AIAgentState) -> AIAgentState: - """ - Run the risk assessment agent with signal outputs. - - Args: - state: Current agent state with signal outputs. - - Returns: - Updated state with risk output. - - Raises: - Exception: If risk agent fails. - """ - risk_agent = RiskAgent() - - try: - result = await risk_agent.execute(state, self.llm_service) - - # Extract risk output - if "risk_output" in result: - state["risk_output"] = result["risk_output"] - - self.logger.info("Risk agent completed successfully.") - except Exception as e: - self.logger.error(f"Risk agent failed: {e}") - raise Exception(f"Risk agent execution failed: {e}") from e - - return state - - async def _run_distribution_agent(self, state: AIAgentState) -> AIAgentState: - """ - Run the distribution decision agent with signal and risk outputs. - - Args: - state: Current agent state with signal and risk outputs. - - Returns: - Updated state with distribution output. - - Raises: - Exception: If distribution agent fails. - """ - distribution_agent = DistributionAgent() - - try: - result = await distribution_agent.execute(state, self.llm_service) - - # Extract distribution output - if "distribution_output" in result: - state["distribution_output"] = result["distribution_output"] - - self.logger.info("Distribution agent completed successfully.") - except Exception as e: - self.logger.error(f"Distribution agent failed: {e}") - raise Exception(f"Distribution agent execution failed: {e}") from e - - return state - - @staticmethod - def build_portfolio_state( - exchange_manager, - ) -> Dict[str, Any]: - """ - Build portfolio state from exchange manager. - - Args: - exchange_manager: The OctoBot exchange manager. - - Returns: - Portfolio state dictionary. - """ - import octobot_trading.api as trading_api - - portfolio = trading_api.get_portfolio(exchange_manager) - reference_market = trading_api.get_portfolio_reference_market(exchange_manager) - - holdings = {} - holdings_value = {} - total_value = 0 - - for asset, amount in portfolio.items(): - if hasattr(amount, 'total'): - holdings[asset] = float(amount.total) - elif isinstance(amount, dict): - holdings[asset] = float(amount.get('total', 0)) - else: - holdings[asset] = float(amount) - - # Get portfolio value - try: - portfolio_value_holder = exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder - total_value = float(portfolio_value_holder.get_traded_assets_holdings_value(reference_market)) - except Exception: - total_value = 0 - - # Get available balance - try: - available_balance = float( - exchange_manager.exchange_personal_data.portfolio_manager.portfolio - .get_currency_portfolio(reference_market).available - ) - except Exception: - available_balance = 0 - - return { - "holdings": holdings, - "holdings_value": holdings_value, - "total_value": total_value, - "reference_market": reference_market, - "available_balance": available_balance, - } - - @staticmethod - def build_orders_state(exchange_manager) -> Dict[str, Any]: - """ - Build orders state from exchange manager. - - Args: - exchange_manager: The OctoBot exchange manager. - - Returns: - Orders state dictionary. - """ - import octobot_trading.api as trading_api - - try: - open_orders = trading_api.get_open_orders(exchange_manager) - orders_list = [ - { - "symbol": order.symbol, - "side": order.side.value if hasattr(order.side, 'value') else str(order.side), - "type": order.order_type.value if hasattr(order.order_type, 'value') else str(order.order_type), - "amount": float(order.origin_quantity), - "price": float(order.origin_price) if order.origin_price else None, - "status": order.status.value if hasattr(order.status, 'value') else str(order.status), - } - for order in open_orders - ] - except Exception: - orders_list = [] - - return { - "open_orders": orders_list, - "pending_orders": [], - "recent_trades": [], - } - - @staticmethod - def build_current_distribution(trading_mode) -> Dict[str, float]: - """ - Build current distribution from trading mode. - - Args: - trading_mode: The AI trading mode instance. - - Returns: - Dictionary of asset to percentage distribution. - """ - if not hasattr(trading_mode, 'ratio_per_asset') or not trading_mode.ratio_per_asset: - return {} - - from tentacles.Trading.Mode.index_trading_mode import index_distribution - - return { - asset: float(data.get(index_distribution.DISTRIBUTION_VALUE, 0)) - for asset, data in trading_mode.ratio_per_asset.items() - } diff --git a/Trading/Mode/ai_trading_mode/ai_index_trading.py b/Trading/Mode/ai_trading_mode/ai_index_trading.py index baa5f28f6..0c2b401ef 100644 --- a/Trading/Mode/ai_trading_mode/ai_index_trading.py +++ b/Trading/Mode/ai_trading_mode/ai_index_trading.py @@ -18,8 +18,8 @@ AI Index Trading Mode module for OctoBot. Handles AI-driven portfolio rebalancing based on strategy evaluations and external agent instructions. """ -import os import typing +from datetime import datetime import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants @@ -31,12 +31,13 @@ import octobot_trading.enums as trading_enums import octobot_trading.modes as trading_modes import octobot_trading.constants as trading_constants +import octobot_trading.api as trading_api import octobot_services.api.services as services_api import tentacles.Services.Services_bases from tentacles.Trading.Mode.ai_trading_mode import ai_index_distribution from tentacles.Trading.Mode.index_trading_mode import index_trading -from tentacles.Trading.Mode.ai_trading_mode.agents.team import AIAgentTeam +from tentacles.Trading.Mode.ai_trading_mode.team import TradingAgentTeam # Data keys STRATEGY_DATA_KEY = "strategy_data" @@ -53,6 +54,16 @@ def __init__(self, channel, config, trading_mode, exchange_manager): self._crypto_strategy_data = {} # {cryptocurrency: strategy_data} self.services_config = None + def get_channels_registration(self): + """ + Override parent to register on MATRIX_CHANNEL instead of candle channels. + AI trading mode should only trade based on AI evaluator results (strategy evaluations), + not on candle events directly. + """ + return [ + self.TOPIC_TO_CHANNEL_NAME[commons_enums.ActivationTopics.EVALUATION_CYCLE.value] + ] + async def set_final_eval( self, matrix_id: str, @@ -231,7 +242,7 @@ async def _trigger_crypto_analysis( """ Handle cryptocurrency-specific strategy analysis from CryptoLLMAIStrategyEvaluator. This is only triggered when both global and crypto-specific data are available. - Runs the AI agent team to generate portfolio distribution decisions. + Runs the AI agents sequentially to generate portfolio distribution decisions. """ if not self._global_strategy_data or cryptocurrency not in self._crypto_strategy_data: self.logger.debug( @@ -241,80 +252,185 @@ async def _trigger_crypto_analysis( # Check if all cryptocurrencies have been analyzed # Only run the full agent team when we have data for all tracked coins - indexed_coins = getattr(self.trading_mode, 'indexed_coins', []) - if not indexed_coins: + if not self.trading_mode.indexed_coins: self.logger.debug("No indexed coins configured, skipping agent analysis.") return # Check if we have crypto strategy data for all indexed coins all_coins_ready = all( coin in self._crypto_strategy_data - for coin in indexed_coins + for coin in self.trading_mode.indexed_coins ) if not all_coins_ready: self.logger.debug( f"Waiting for all crypto strategy data. " f"Have: {list(self._crypto_strategy_data.keys())}, " - f"Need: {indexed_coins}" + f"Need: {self.trading_mode.indexed_coins}" ) return - self.logger.info("All strategy data collected. Running AI agent team...") + self.logger.debug("All strategy data collected. Running AI agents...") try: - await self._run_agent_team() + await self._run_agents() except Exception as e: - self.logger.exception(f"Error running AI agent team: {e}") + self.logger.exception(f"Error running AI agents: {e}") - async def _run_agent_team(self): + async def _run_agents(self): """ - Run the AI agent team to analyze portfolio and generate distribution decisions. + Run AI agents using TradingAgentTeam to analyze portfolio and generate distribution decisions. + + The team orchestrates: + 1. Signal agent - analyzes all cryptocurrencies and synthesizes signals + 2. Risk agent - evaluates portfolio risk based on signals + 3. Distribution agent - makes final allocation decisions """ # Get LLM service - llm_service = await self._get_llm_service() - if llm_service is None: + ai_service = await self._get_llm_service() + if ai_service is None: self.logger.error("Failed to create LLM service. Check AI configuration.") return - # Build agent team - agent_team = AIAgentTeam(llm_service=llm_service) + # Build state + state = self._build_agent_state() - # Get portfolio and orders state - portfolio_state = AIAgentTeam.build_portfolio_state(self.exchange_manager) - orders_state = AIAgentTeam.build_orders_state(self.exchange_manager) - current_distribution = AIAgentTeam.build_current_distribution(self.trading_mode) + self.logger.debug("Running TradingAgentTeam for portfolio distribution analysis...") - # Get cryptocurrencies - cryptocurrencies = list(self._crypto_strategy_data.keys()) - reference_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market + # Create and run the team + team = TradingAgentTeam(ai_service=ai_service) - # Run agent team - self.logger.info("Running AI agent team for portfolio distribution analysis...") - distribution_output = await agent_team.run( - global_strategy_data=self._global_strategy_data, - crypto_strategy_data=self._crypto_strategy_data, - cryptocurrencies=cryptocurrencies, - portfolio=portfolio_state, - orders=orders_state, - current_distribution=current_distribution, - reference_market=reference_market, - exchange_name=self.exchange_name, - ) + try: + distribution_output = await team.run_with_state(state) + except Exception as e: + self.logger.exception(f"TradingAgentTeam execution failed: {e}") + return if distribution_output is None: self.logger.warning("Agent team returned no distribution output.") return self.logger.info( - f"Agent team completed. Urgency: {distribution_output.rebalance_urgency}" + f"TradingAgentTeam completed. Urgency: {distribution_output.rebalance_urgency}" ) + self.logger.info(f"AI Reasoning: {distribution_output.reasoning}") + for dist in distribution_output.distributions: + self.logger.info( + f" {dist.asset}: {dist.percentage:.1f}% ({dist.action}) - {dist.explanation}" + ) # Convert to AI instructions and trigger rebalance ai_instructions = distribution_output.get_ai_instructions() if ai_instructions and distribution_output.rebalance_urgency != "none": + self.logger.info(f"Triggering rebalance with {len(ai_instructions)} instructions") await self._submit_trading_evaluation(ai_instructions) + else: + if distribution_output.rebalance_urgency == "none": + self.logger.info("No rebalance triggered (urgency is 'none')") + else: + self.logger.info("No rebalance triggered (no instructions)") + + def _build_agent_state(self) -> dict: + """ + Build the state dictionary for agent execution. + """ + portfolio_state = self._build_portfolio_state() + orders_state = self._build_orders_state() + current_distribution = self._build_current_distribution() + cryptocurrencies = list(self._crypto_strategy_data.keys()) + reference_market = self.exchange_manager.exchange_personal_data.portfolio_manager.reference_market + + return { + "global_strategy_data": self._global_strategy_data, + "crypto_strategy_data": self._crypto_strategy_data, + "cryptocurrencies": cryptocurrencies, + "reference_market": reference_market, + "portfolio": portfolio_state, + "orders": orders_state, + "current_distribution": current_distribution, + "signal_outputs": {"signals": {}}, + "risk_output": None, + "signal_synthesis": None, + "distribution_output": None, + "exchange_name": self.exchange_name, + "timestamp": datetime.utcnow().isoformat(), + } + + def _build_portfolio_state(self) -> dict: + """Build portfolio state from exchange manager.""" + portfolio = trading_api.get_portfolio(self.exchange_manager) + reference_market = trading_api.get_portfolio_reference_market(self.exchange_manager) + + holdings = {} + holdings_value = {} + total_value = 0 + + for asset, amount in portfolio.items(): + if hasattr(amount, 'total'): + holdings[asset] = float(amount.total) + elif isinstance(amount, dict): + holdings[asset] = float(amount.get('total', 0)) + else: + holdings[asset] = float(amount) + + # Get portfolio value + try: + portfolio_value_holder = self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio_value_holder + total_value = float(portfolio_value_holder.get_traded_assets_holdings_value(reference_market)) + except Exception: + total_value = 0 + + # Get available balance + try: + available_balance = float( + self.exchange_manager.exchange_personal_data.portfolio_manager.portfolio + .get_currency_portfolio(reference_market).available + ) + except Exception: + available_balance = 0 + + return { + "holdings": holdings, + "holdings_value": holdings_value, + "total_value": total_value, + "reference_market": reference_market, + "available_balance": available_balance, + } + + def _build_orders_state(self) -> dict: + """Build orders state from exchange manager.""" + try: + open_orders = trading_api.get_open_orders(self.exchange_manager) + orders_list = [ + { + "symbol": order.symbol, + "side": order.side.value if hasattr(order.side, 'value') else str(order.side), + "type": order.order_type.value if hasattr(order.order_type, 'value') else str(order.order_type), + "amount": float(order.origin_quantity), + "price": float(order.origin_price) if order.origin_price else None, + "status": order.status.value if hasattr(order.status, 'value') else str(order.status), + } + for order in open_orders + ] + except Exception: + orders_list = [] + + return { + "open_orders": orders_list, + "pending_orders": [], + "recent_trades": [], + } + + def _build_current_distribution(self) -> dict: + """Build current distribution from trading mode.""" + if not hasattr(self.trading_mode, 'ratio_per_asset') or not self.trading_mode.ratio_per_asset: + return {} + + return { + asset: float(data.get(index_trading.index_distribution.DISTRIBUTION_VALUE, 0)) + for asset, data in self.trading_mode.ratio_per_asset.items() + } async def _get_llm_service(self): """Get the LLM service instance.""" diff --git a/Trading/Mode/ai_trading_mode/team.py b/Trading/Mode/ai_trading_mode/team.py new file mode 100644 index 000000000..e45528232 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/team.py @@ -0,0 +1,164 @@ +# Drakkar-Software OctoBot-Tentacles +# Copyright (c) Drakkar-Software, All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library. + +""" +AI Trading Agent Team. +Orchestrates Signal, Risk, and Distribution agents for portfolio management. + +DAG Structure: + Signal ──┬──> Risk ──┐ + │ ├──> Distribution + └───────────┘ + +The Distribution agent receives inputs from both Signal and Risk agents. +""" +import typing + +import octobot_agents as agent + +from tentacles.Agent.Trading.signal_agent import ( + SignalAIAgentChannel, + SignalAIAgentProducer, +) +from tentacles.Agent.Trading.risk_agent import ( + RiskAIAgentChannel, + RiskAIAgentProducer, +) +from tentacles.Agent.Trading.distribution_agent import ( + DistributionAIAgentChannel, + DistributionAIAgentProducer, + DistributionOutput, +) + + +class TradingAgentTeamChannel(agent.AbstractAgentTeamChannel): + """Channel for TradingAgentTeam outputs.""" + OUTPUT_SCHEMA = DistributionOutput + + +class TradingAgentTeamConsumer(agent.AbstractAgentTeamChannelConsumer): + """Consumer for TradingAgentTeam outputs.""" + pass + + +class TradingAgentTeam(agent.AbstractSyncAgentTeamChannelProducer): + """ + Sync team that orchestrates trading agents for portfolio distribution. + + Execution flow: + 1. Signal agent analyzes cryptocurrencies and generates signals + 2. Risk agent evaluates portfolio risk based on signal outputs + 3. Distribution agent makes final allocation decisions + + Usage: + team = TradingAgentTeam(ai_service=llm_service) + results = await team.run(agent_state) + distribution_output = results["DistributionAgent"]["distribution_output"] + """ + + TEAM_NAME = "TradingAgentTeam" + TEAM_CHANNEL = TradingAgentTeamChannel + TEAM_CONSUMER = TradingAgentTeamConsumer + + def __init__( + self, + ai_service: typing.Any, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + channel: typing.Optional[TradingAgentTeamChannel] = None, + team_id: typing.Optional[str] = None, + ): + """ + Initialize the trading agent team. + + Args: + ai_service: The LLM service instance. + model: LLM model to use for all agents. + max_tokens: Maximum tokens for LLM responses. + temperature: Temperature for LLM randomness. + channel: Optional output channel for team results. + team_id: Unique identifier for this team instance. + """ + # Create agent producers + signal_producer = SignalAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + + risk_producer = RiskAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + + distribution_producer = DistributionAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + + agents = [signal_producer, risk_producer, distribution_producer] + + # Define relations: + # Signal -> Risk (Risk needs signal synthesis) + # Signal -> Distribution (Distribution needs signal outputs) + # Risk -> Distribution (Distribution needs risk assessment) + relations = [ + (SignalAIAgentChannel, RiskAIAgentChannel), + (SignalAIAgentChannel, DistributionAIAgentChannel), + (RiskAIAgentChannel, DistributionAIAgentChannel), + ] + + super().__init__( + channel=channel, + agents=agents, + relations=relations, + ai_service=ai_service, + team_name=self.TEAM_NAME, + team_id=team_id, + ) + + async def run_with_state( + self, + state: dict, + ) -> typing.Optional["DistributionOutput"]: + """ + Convenience method to run the team with an agent state dict. + + Args: + state: Dict containing portfolio, strategy data, etc. + + Returns: + DistributionOutput from the distribution agent, or None on error. + """ + # Run the team + results = await self.run(state) + + # Extract distribution result + distribution_result = results.get("DistributionAgent") + if distribution_result is None: + return None + + # Handle dict result format + if isinstance(distribution_result, dict): + return distribution_result.get("distribution_output") + + return distribution_result