diff --git a/Agent/Critic/__init__.py b/Agent/Critic/__init__.py new file mode 100644 index 000000000..974dd1623 --- /dev/null +++ b/Agent/Critic/__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/Agent/Critic/backtesting_results_critic_agent/__init__.py b/Agent/Critic/backtesting_results_critic_agent/__init__.py new file mode 100644 index 000000000..0a979c38c --- /dev/null +++ b/Agent/Critic/backtesting_results_critic_agent/__init__.py @@ -0,0 +1,21 @@ +# 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 .backtesting_results_critic_agent import BacktestingResultsAICriticAgentProducer + +__all__ = [ + "BacktestingResultsAICriticAgentProducer", +] diff --git a/Agent/Critic/backtesting_results_critic_agent/backtesting_results_critic_agent.py b/Agent/Critic/backtesting_results_critic_agent/backtesting_results_critic_agent.py new file mode 100644 index 000000000..50ccaedf9 --- /dev/null +++ b/Agent/Critic/backtesting_results_critic_agent/backtesting_results_critic_agent.py @@ -0,0 +1,118 @@ +# 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. + +""" +Backtesting Results Critic Agent. +Analyzes backtesting results (portfolio, trades, profitability) and produces critic analysis +for learning and strategy improvement. +""" +import json +import typing + +import octobot_commons.logging as logging +import octobot_agents.models as models +import octobot_services.services.abstract_ai_service as abstract_ai_service + +from octobot_agents.team.critic import AICriticAgentProducer + + +class BacktestingResultsAICriticAgentProducer(AICriticAgentProducer): + """ + Critic agent dedicated to backtesting results. + Takes a backtesting report (profitability, portfolio, trades, etc.) and produces + issues, improvements, and summary for strategy refinement. + """ + + def __init__( + self, + channel: typing.Optional[typing.Any] = None, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + self_improving: bool = True, + **kwargs, + ): + super().__init__( + channel=channel, + model=model, + max_tokens=max_tokens or 3000, + temperature=temperature if temperature is not None else 0.3, + self_improving=self_improving, + **kwargs, + ) + self.logger = logging.get_logger(self.__class__.__name__) + + def _get_default_prompt(self) -> str: + return """You are a Backtesting Results Critic for a trading system. +You receive a backtesting report: profitability, starting/end portfolio, trades, market comparison. + +Your role: +1. Identify issues: poor risk-adjusted returns, concentration risks, timing issues, overtrading. +2. Suggest improvements: allocation changes, risk limits, entry/exit rules. +3. Summarize: short actionable summary and key lessons. + +Output a JSON object matching CriticAnalysis: +- "issues": list of general problems (team-level) +- "errors": list of errors encountered (can be empty) +- "inconsistencies": list of inconsistencies (e.g. portfolio vs trades) +- "optimizations": list of optimization opportunities +- "summary": overall analysis summary (2-4 sentences) +- "agent_improvements": {} (empty dict; this critic does not target specific agents) +""" + + async def execute( + self, + input_data: typing.Union[models.CriticInput, typing.Dict[str, typing.Any]], + ai_service: abstract_ai_service.AbstractAIService, + ) -> models.CriticAnalysis: + report = input_data if isinstance(input_data, dict) else getattr(input_data, "backtesting_report", input_data) + if isinstance(input_data, dict) and "backtesting_report" in input_data: + report = input_data["backtesting_report"] + report_str = json.dumps(report, indent=2, default=str) if report else "No report." + + messages = [ + {"role": "system", "content": self._get_default_prompt()}, + {"role": "user", "content": f"Backtesting report:\n{report_str}\n\nAnalyze and return CriticAnalysis JSON."}, + ] + + try: + response = await self._call_llm( + messages, + ai_service, + json_output=True, + response_schema=models.CriticAnalysis, + ) + except Exception as e: + self.logger.exception(f"Backtesting critic LLM call failed: {e}") + return models.CriticAnalysis( + issues=[f"Critic analysis failed: {e}"], + errors=[], + inconsistencies=[], + optimizations=[], + summary="Backtesting critic could not complete analysis.", + agent_improvements={}, + ) + + if isinstance(response, dict): + return models.CriticAnalysis( + issues=response.get("issues", []), + errors=response.get("errors", []), + inconsistencies=response.get("inconsistencies", []), + optimizations=response.get("optimizations", []), + summary=response.get("summary", ""), + agent_improvements=response.get("agent_improvements", {}), + ) + return response diff --git a/Agent/Critic/backtesting_results_critic_agent/metadata.json b/Agent/Critic/backtesting_results_critic_agent/metadata.json new file mode 100644 index 000000000..89e2edaab --- /dev/null +++ b/Agent/Critic/backtesting_results_critic_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["BacktestingResultsCriticAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Critic/default_critic_agent/__init__.py b/Agent/Critic/default_critic_agent/__init__.py new file mode 100644 index 000000000..1604c7aa7 --- /dev/null +++ b/Agent/Critic/default_critic_agent/__init__.py @@ -0,0 +1,35 @@ +# 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 .default_critic_agent import ( + DefaultCriticAgentProducer, + DefaultCriticAgentChannel, + DefaultCriticAgentConsumer, +) +from .default_ai_critic_agent import ( + DefaultAICriticAgentProducer, + DefaultAICriticAgentChannel, + DefaultAICriticAgentConsumer, +) + +__all__ = [ + "DefaultCriticAgentProducer", + "DefaultCriticAgentChannel", + "DefaultCriticAgentConsumer", + "DefaultAICriticAgentProducer", + "DefaultAICriticAgentChannel", + "DefaultAICriticAgentConsumer", +] diff --git a/Agent/Critic/default_critic_agent/default_ai_critic_agent.py b/Agent/Critic/default_critic_agent/default_ai_critic_agent.py new file mode 100644 index 000000000..9c1636365 --- /dev/null +++ b/Agent/Critic/default_critic_agent/default_ai_critic_agent.py @@ -0,0 +1,412 @@ +# 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 +from typing import TYPE_CHECKING, List + +from pydantic import BaseModel, Field + +from octobot_agents.models import AgentBaseModel +from octobot_agents.team.critic import ( + AICriticAgentChannel, + AICriticAgentConsumer, + AICriticAgentProducer, + AbstractCriticAgent, +) +from .default_critic_agent import DefaultCriticAgentProducer +import octobot_agents.models as models + +if TYPE_CHECKING: + from octobot_agents.models import CriticInput + + +class QualityEvaluationOutput(AgentBaseModel): + """ + Output schema for LLM-based quality evaluation. + + This model is intentionally focused on behavioral and strategic quality only. + Schema/field/type validation is handled separately by Pydantic and runtime + checks, not by the critic. + """ + __strict_json_schema__ = True + + quality_ok: bool = Field(description="Whether the output quality is acceptable.") + # Behavior/strategy/risk/logic issues suitable for learning and memory. + issues: List[str] = Field( + default_factory=list, + description="List of behavioral/strategic quality issues (empty if quality_ok is true).", + ) + reasoning: str = Field( + default="", + description="Brief explanation of the behavioral quality assessment." + ) + + +class DefaultAICriticAgentChannel(AICriticAgentChannel): + """Channel for default AI critic agent.""" + __slots__ = () + + +class DefaultAICriticAgentConsumer(AICriticAgentConsumer): + """Consumer for default AI critic agent.""" + __slots__ = () + + +class DefaultAICriticAgentProducer(AICriticAgentProducer): + """ + Default AI critic agent - hybrid rule-based + LLM analysis. + + Combines basic quality heuristics from DefaultCriticAgentProducer with + LLM-based evaluation for comprehensive result quality assessment. + Designed as the default critic for AI teams. + + Features: + - Basic quality checks (empty results, schema validation, placeholder detection) + - LLM-based quality evaluation (correctness, relevance, completeness, expectations) + - Context-aware analysis (compares results against execution plan) + - Enhanced improvement suggestions + """ + + AGENT_CHANNEL: typing.Type[AICriticAgentChannel] = DefaultAICriticAgentChannel + AGENT_CONSUMER: typing.Type[AICriticAgentConsumer] = DefaultAICriticAgentConsumer + + def __init__( + self, + channel: typing.Optional[DefaultAICriticAgentChannel] = None, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + self_improving: bool = True, + **kwargs, + ): + super().__init__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + self_improving=self_improving, + **kwargs, + ) + # Create instance of DefaultCriticAgentProducer to reuse its quality evaluation + self._basic_critic = DefaultCriticAgentProducer(channel=None) + + def _get_default_prompt(self) -> str: + """ + Return the default prompt for the AI critic agent. + + Returns: + The default system prompt string. + """ + return """You are an AI critic agent for an AI agent team. +Your role is to analyze team execution and evaluate the *behavioral and strategic quality* +of each agent's output, not to restate low-level schema or type constraints. + +Focus on: +1. Result & Behavior Quality: + - Correctness: Are the decisions and outputs logically and financially sound? + - Relevance: Do they address the intended trading or evaluation objective? + - Completeness: Are the key behavioral steps and justifications present (not just fields)? + - Expectations: Do they match the intent of the execution plan and agent role? + +2. Issues and Problems: + - Identify logical errors, poor risk management, or inconsistent reasoning. + - Detect off-topic, misleading, or unsupported conclusions. + - Highlight missing analysis or skipped safety checks that should have been performed. + +3. Agent Improvements: + - Only include agents in agent_improvements if they have memory enabled. + - Provide specific, actionable improvements to behavior, strategy, or coordination. + - Explain why each agent needs improvement in terms of its decisions and patterns, + not just missing keys or fields. + - For strong results, suggest what should be captured as reusable best practices. + +4. Context Awareness: + - Compare outputs against the execution plan (goals, constraints, and roles). + - Check consistency between related agent outputs (e.g., no conflicting signals). + - Consider recent history and regime (if provided) when judging severity of issues. + +For each agent with memory enabled: +- If the result is high quality: include a positive learning entry that describes the + pattern to reuse (what worked and why). +- If the result has quality issues: include specific, behavior-level issues and + improvements (what to change in logic/decision-making). +- If the result is off-topic or shallow: explain why, and propose how to better + align with the task and other agents.""" + + def _evaluate_result_quality_basic( + self, + output: typing.Any, + agent: typing.Optional[typing.Any] + ) -> typing.Tuple[bool, typing.List[str]]: + """ + Evaluate result quality using basic heuristics (from DefaultCriticAgentProducer). + + Args: + output: The agent output to evaluate + agent: The agent instance (optional, for schema access) + + Returns: + Tuple of (is_quality_ok, list_of_issues) + """ + # Reuse the basic quality evaluation from DefaultCriticAgentProducer + return self._basic_critic._evaluate_result_quality(output, agent) + + async def _evaluate_result_quality_with_llm( + self, + output: typing.Any, + agent: typing.Optional[typing.Any], + agent_name: str, + execution_plan: typing.Optional[typing.Any], + other_outputs: typing.Dict[str, typing.Any], + ai_service: typing.Any, + ) -> typing.Tuple[bool, typing.List[str], typing.Optional[str]]: + """ + Evaluate result quality using LLM for deeper assessment. + + Args: + output: The agent output to evaluate + agent: The agent instance (optional, for schema access) + agent_name: Name of the agent + execution_plan: The execution plan (for context) + other_outputs: Other agent outputs (for consistency checking) + ai_service: The AI service instance + + Returns: + Tuple of (is_quality_ok, list_of_issues, llm_reasoning) + """ + issues = [] + reasoning = None + + # Build context for LLM evaluation + agent_context = { + "agent_name": agent_name, + "output": output, + } + + # Add schema info if available (team agents are producers with AGENT_CHANNEL) + if agent is not None: + try: + schema = agent.AGENT_CHANNEL.get_output_schema() + if schema: + agent_context["expected_schema"] = schema.__name__ + except (AttributeError, TypeError): + pass + + # Build evaluation prompt + evaluation_prompt = f"""Evaluate the behavioral and strategic quality of this agent's output: + +Agent: {agent_name} +Output: {self.format_data(output)} + +Context: +- Execution Plan: {self.format_data(execution_plan.to_dict() if execution_plan else {})} +- Other Agent Outputs: {self.format_data({k: v for k, v in other_outputs.items() if k != agent_name})} + +Your task: +1. Completely ignore low-level schema/field/type/format problems. Assume that + any output you see has already passed structural validation. +2. Evaluate only behavioral and strategic quality: + - Correctness: Are the decisions and conclusions logically and financially sound? + - Relevance: Do they address the intended trading or evaluation objective? + - Completeness: Are key behavioral steps, checks, and justifications present? + - Expectations: Do they match the intent of the execution plan and agent role? + - Consistency: Are they coherent with other agent outputs? + +Respond with structured JSON fields: +- quality_ok: true/false +- issues: list of BEHAVIORAL issues only (short text, empty if quality_ok is true) +- reasoning: brief explanation of the behavioral assessment + +If quality_ok is false, issues should clearly explain what to change in the +agent's decisions, strategies, or risk management, not its output schema.""" + + try: + messages = [ + {"role": "system", "content": "You are a quality evaluation expert. Analyze agent outputs for correctness, relevance, completeness, and whether they meet expectations."}, + {"role": "user", "content": evaluation_prompt}, + ] + + # Call LLM for quality evaluation with structured output schema + response = await self._call_llm( + messages, + ai_service, + json_output=True, + response_schema=QualityEvaluationOutput, + ) + + # Parse response using Pydantic model + quality_eval = QualityEvaluationOutput.model_validate(response) + quality_ok = quality_eval.quality_ok + issues.extend(quality_eval.issues) + reasoning = quality_eval.reasoning + + # Return issues directly; they all describe behavioral problems. + return quality_ok, issues, reasoning + + except Exception as e: + # If LLM evaluation fails, log and fall back to basic evaluation + self.logger.warning(f"LLM quality evaluation failed for {agent_name}: {e}. Falling back to basic evaluation.") + return True, [], None + + async def execute( + self, + input_data: typing.Union[models.CriticInput, typing.Dict[str, typing.Any]], + ai_service: typing.Any + ) -> models.CriticAnalysis: + """ + Execute critic analysis using hybrid rule-based + LLM evaluation. + + Combines basic heuristics with LLM-based quality assessment for comprehensive + result quality evaluation. + + Args: + input_data: Contains {"team_producer": team_producer, "execution_plan": ExecutionPlan, "execution_results": Dict, "agent_outputs": Dict, "execution_metadata": dict} + ai_service: The AI service instance for LLM calls + + Returns: + CriticAnalysis with comprehensive findings + """ + execution_results = input_data.get("execution_results", {}) + agent_outputs = input_data.get("agent_outputs", {}) + execution_metadata = input_data.get("execution_metadata", {}) + execution_plan = input_data.get("execution_plan") + team_producer = input_data.get("team_producer") + + if team_producer is None: + raise ValueError("team_producer is required in input_data") + + issues = [] + errors = [] + inconsistencies = [] + optimizations = [] + agent_improvements = {} + + # Check for general errors + if execution_metadata.get("errors"): + errors.extend(execution_metadata["errors"]) + + # Process each agent with hybrid quality evaluation + for agent_name, output in agent_outputs.items(): + # Skip agents that failed (no result available) + if output is None: + continue + + # Check for errors in execution results + if agent_name in execution_results: + result = execution_results[agent_name] + if isinstance(result, dict) and result.get("error"): + continue + + # Get agent instance for quality evaluation and memory check + agent = None + has_memory_enabled = False + if team_producer: + try: + agent = team_producer.get_agent_by_name(agent_name) + if agent is None: + manager = team_producer.get_manager() + if manager and manager.name == agent_name: + agent = manager + + if agent: + try: + has_memory_enabled = agent.has_memory_enabled() + except AttributeError: + has_memory_enabled = getattr(agent, 'ENABLE_MEMORY', False) + except (AttributeError, KeyError): + has_memory_enabled = False + + # Skip agents without memory enabled + if not has_memory_enabled: + continue + + # Step 1: Run basic quality checks + basic_ok, basic_issues = self._evaluate_result_quality_basic(output, agent) + + # Step 2: Run LLM-based quality evaluation + # NOTE: llm_issues here are already behavior-focused, schema-only + # issues are kept inside the QualityEvaluationOutput.issues field + # and added above via the issues list. + llm_ok, llm_behavior_issues, llm_reasoning = await self._evaluate_result_quality_with_llm( + output=output, + agent=agent, + agent_name=agent_name, + execution_plan=execution_plan, + other_outputs=agent_outputs, + ai_service=ai_service, + ) + + # Step 3: Combine assessments + # basic_issues may contain schema-like problems; llm_behavior_issues + # are explicitly behavior/strategy/risk oriented. + all_behavior_issues = basic_issues + llm_behavior_issues + is_quality_ok = basic_ok and llm_ok + + # Build reasoning + if is_quality_ok: + reasoning = f"Agent {agent_name} produced quality result" + if llm_reasoning: + reasoning += f": {llm_reasoning}" + reasoning += " - capturing learnings" + + # For successful results, capture positive behavioral pattern + agent_improvements[agent_name] = models.AgentImprovement( + agent_name=agent_name, + improvements=["Capture successful execution patterns"], + issues=[], + errors=[], + reasoning=reasoning, + ) + else: + # Combine basic and LLM behavior issues for reasoning, while schema-only + # issues remain available in the global issues list. + combined_reasoning = f"Agent {agent_name} produced result with quality issues" + if basic_issues: + combined_reasoning += f". Basic checks: {', '.join(basic_issues)}" + if llm_behavior_issues: + combined_reasoning += f". LLM behavior evaluation: {', '.join(llm_behavior_issues)}" + if llm_reasoning: + combined_reasoning += f". Analysis: {llm_reasoning}" + + agent_improvements[agent_name] = models.AgentImprovement( + agent_name=agent_name, + # Focus memory on behavioral improvements, not schema fixes. + improvements=["Improve result quality, correctness, and completeness"], + issues=all_behavior_issues, + errors=[], + reasoning=combined_reasoning, + ) + + # Count quality vs non-quality results + quality_count = sum( + 1 for imp in agent_improvements.values() + if not imp.issues and not imp.errors + ) + quality_issue_count = len(agent_improvements) - quality_count + + summary = ( + f"Found {len(errors)} errors, {len(issues)} issues. " + f"{quality_count} agents with quality results processed for memory updates. " + f"{quality_issue_count} agents with quality issues identified (hybrid evaluation)." + ) + + return models.CriticAnalysis( + issues=issues, + errors=errors, + inconsistencies=inconsistencies, + optimizations=optimizations, + summary=summary, + agent_improvements=agent_improvements, + ) diff --git a/Agent/Critic/default_critic_agent/default_critic_agent.py b/Agent/Critic/default_critic_agent/default_critic_agent.py new file mode 100644 index 000000000..7200234ae --- /dev/null +++ b/Agent/Critic/default_critic_agent/default_critic_agent.py @@ -0,0 +1,244 @@ +# 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 +from typing import TYPE_CHECKING + +from octobot_agents.team.critic import ( + CriticAgentChannel, + CriticAgentConsumer, + CriticAgentProducer, + AbstractCriticAgent, +) +import octobot_agents.models as models + +if TYPE_CHECKING: + from octobot_agents.models import CriticInput + + +class DefaultCriticAgentChannel(CriticAgentChannel): + """Channel for default critic agent.""" + __slots__ = () + + +class DefaultCriticAgentConsumer(CriticAgentConsumer): + """Consumer for default critic agent.""" + __slots__ = () + + +class DefaultCriticAgentProducer(CriticAgentProducer): + """ + Default critic agent - simple rule-based analysis. + + Inherits from CriticAgentProducer. Uses simple heuristics instead of LLM. + + Note: This is a basic rule-based critic with limited quality evaluation capabilities. + For proper quality evaluation (checking correctness, relevance, completeness, etc.), + use AICriticAgentProducer which uses LLM-based analysis. + """ + + AGENT_CHANNEL: typing.Type[CriticAgentChannel] = DefaultCriticAgentChannel + AGENT_CONSUMER: typing.Type[CriticAgentConsumer] = DefaultCriticAgentConsumer + + def __init__( + self, + channel: typing.Optional[DefaultCriticAgentChannel] = None, + self_improving: bool = True, + ): + super().__init__(channel=channel, self_improving=self_improving) + + def _evaluate_result_quality( + self, + output: typing.Any, + agent: typing.Optional[typing.Any] + ) -> typing.Tuple[bool, typing.List[str]]: + """ + Evaluate result quality using basic heuristics. + + Checks for: + - Empty dict results + - Schema validation failures (if OUTPUT_SCHEMA available) + - All empty/None values + - Placeholder or incomplete data + + Args: + output: The agent output to evaluate + agent: The agent instance (optional, for schema access) + + Returns: + Tuple of (is_quality_ok, list_of_issues) + """ + issues = [] + + # Check for empty dict + if isinstance(output, dict) and len(output) == 0: + return False, ["Result is empty dict"] + + # Try schema validation if available (team agents are producers with AGENT_CHANNEL) + if agent is not None: + try: + schema = agent.AGENT_CHANNEL.get_output_schema() + if schema: + try: + schema.model_validate(output) + except Exception as e: + issues.append(f"Schema validation failed: {str(e)}") + except (AttributeError, TypeError): + # Schema not available or not callable, skip validation + pass + + # Check for all None/empty values in dict + if isinstance(output, dict): + all_empty = all( + v is None or v == "" or (isinstance(v, dict) and len(v) == 0) or (isinstance(v, list) and len(v) == 0) + for v in output.values() + ) + if all_empty: + issues.append("All result fields are empty, None, or empty collections") + + # Check for placeholder-like values (very basic check) + if isinstance(output, dict): + placeholder_indicators = ["n/a", "none", "null", "placeholder", "todo", "tbd"] + for key, value in output.items(): + if isinstance(value, str) and value.lower() in placeholder_indicators: + issues.append(f"Field '{key}' contains placeholder value: {value}") + break + + return len(issues) == 0, issues + + async def execute( + self, + input_data: typing.Union[models.CriticInput, typing.Dict[str, typing.Any]], + ai_service: typing.Any # AbstractAIService - type not available at runtime + ) -> models.CriticAnalysis: + """ + Execute critic analysis using simple heuristics. + + Evaluates agent results using basic quality checks: + - Empty result detection + - Schema validation (if OUTPUT_SCHEMA available) + - Completeness checks (empty fields, placeholder values) + + Note: This is a basic rule-based approach with limited evaluation capabilities. + It cannot assess correctness, relevance, or whether results meet expectations. + For comprehensive quality evaluation, use CriticAgentProducer (LLM-based). + + Args: + input_data: Contains {"team_producer": team_producer, "execution_plan": ExecutionPlan, "execution_results": Dict, "agent_outputs": Dict, "execution_metadata": dict} + ai_service: Not used by default critic agent + + Returns: + CriticAnalysis with basic findings + """ + execution_results = input_data.get("execution_results", {}) + agent_outputs = input_data.get("agent_outputs", {}) + execution_metadata = input_data.get("execution_metadata", {}) + + issues = [] + errors = [] + inconsistencies = [] + optimizations = [] + agent_improvements = {} + + # Check for general errors + if execution_metadata.get("errors"): + errors.extend(execution_metadata["errors"]) + + # Get team producer to check agent memory status + team_producer = input_data.get("team_producer") + + # Check each agent - only process agents with valid results + for agent_name, output in agent_outputs.items(): + # Skip agents that failed (no result available) + if output is None: + # Agent failed - skip it, don't include in improvements + continue + + # Check for errors in execution results + if agent_name in execution_results: + result = execution_results[agent_name] + if isinstance(result, dict) and result.get("error"): + # Agent failed with error - skip it, don't include in improvements + continue + + # Get agent instance for quality evaluation and memory check + agent = None + has_memory_enabled = False + if team_producer: + try: + # Try to get agent instance from team + agent = team_producer.get_agent_by_name(agent_name) + if agent is None: + # Check if it's the manager + manager = team_producer.get_manager() + if manager and manager.name == agent_name: + agent = manager + + if agent: + # Check if agent has memory enabled + try: + has_memory_enabled = agent.has_memory_enabled() + except AttributeError: + # Check ENABLE_MEMORY class variable as fallback + has_memory_enabled = getattr(agent, 'ENABLE_MEMORY', False) + except (AttributeError, KeyError): + # If we can't check, assume no memory + has_memory_enabled = False + + # Evaluate result quality + is_quality_ok, quality_issues = self._evaluate_result_quality(output, agent) + + # Only include agents with quality results and memory enabled + if has_memory_enabled: + if is_quality_ok: + # Agent has quality result and memory enabled - capture learnings + agent_improvements[agent_name] = models.AgentImprovement( + agent_name=agent_name, + improvements=["Capture successful execution patterns"], + issues=[], + errors=[], + reasoning=f"Agent {agent_name} produced quality result with memory enabled - capturing learnings", + ) + else: + # Agent has result but quality issues - include with issues for improvement + agent_improvements[agent_name] = models.AgentImprovement( + agent_name=agent_name, + improvements=["Improve result quality and completeness"], + issues=quality_issues, + errors=[], + reasoning=f"Agent {agent_name} produced result but quality checks failed: {', '.join(quality_issues)}", + ) + + # Count quality vs non-quality results + quality_count = sum( + 1 for imp in agent_improvements.values() + if not imp.issues and not imp.errors + ) + quality_issue_count = len(agent_improvements) - quality_count + + summary = ( + f"Found {len(errors)} errors, {len(issues)} issues. " + f"{quality_count} agents with quality results processed for memory updates. " + f"{quality_issue_count} agents with quality issues identified." + ) + + return models.CriticAnalysis( + issues=issues, + errors=errors, + inconsistencies=inconsistencies, + optimizations=optimizations, + summary=summary, + agent_improvements=agent_improvements, + ) diff --git a/Agent/Critic/default_critic_agent/metadata.json b/Agent/Critic/default_critic_agent/metadata.json new file mode 100644 index 000000000..e13d0f60e --- /dev/null +++ b/Agent/Critic/default_critic_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["DefaultCriticAgent", "DefaultAICriticAgent"], + "tentacles-requirements": [] +} 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/Agent/Evaluators/real_time_analysis_agent/__init__.py b/Agent/Evaluators/real_time_analysis_agent/__init__.py new file mode 100644 index 000000000..ff3d13828 --- /dev/null +++ b/Agent/Evaluators/real_time_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 .real_time_analysis_agent import ( + RealTimeAnalysisAIAgentChannel, + RealTimeAnalysisAIAgentConsumer, + RealTimeAnalysisAIAgentProducer, +) +from .models import RealTimeAnalysisOutput + +__all__ = [ + "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..29feef467 --- /dev/null +++ b/Agent/Evaluators/real_time_analysis_agent/models.py @@ -0,0 +1,97 @@ +# 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 + +from octobot_agents.models import AgentBaseModel + + +class RealTimeAnalysisOutput(AgentBaseModel): + __strict_json_schema__ = True + """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/Agent/Evaluators/real_time_analysis_agent/real_time_analysis_agent.py b/Agent/Evaluators/real_time_analysis_agent/real_time_analysis_agent.py new file mode 100644 index 000000000..35c517cfd --- /dev/null +++ b/Agent/Evaluators/real_time_analysis_agent/real_time_analysis_agent.py @@ -0,0 +1,117 @@ +# 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 octobot_agents as agent + +from .models import RealTimeAnalysisOutput + + +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_VERSION = "1.0.0" + AGENT_CHANNEL = RealTimeAnalysisAIAgentChannel + AGENT_CONSUMER = RealTimeAnalysisAIAgentConsumer + + def __init__(self, channel, **kwargs): + super().__init__(channel, **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, ai_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 = [ + 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. " + "Include mandatory fields (eval_note, confidence, description). " + "Include optional fields only if you have data for them.", + ), + ] + + try: + # Uses RealTimeAnalysisAIAgentChannel.OUTPUT_SCHEMA by default + parsed = await self._call_llm( + messages, + ai_service, + json_output=True, + ) + 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 Exception as e: + self.logger.exception(f"Error in real-time analysis: {e}") + return { + "eval_note": 0, + "eval_note_description": f"Error in real-time analysis: {str(e)}", + "confidence": 0, + } 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..bb1f6b4e8 --- /dev/null +++ b/Agent/Evaluators/sentiment_analysis_agent/models.py @@ -0,0 +1,77 @@ +# 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 + +from octobot_agents.models import AgentBaseModel + + +class SentimentMetrics(AgentBaseModel): + __strict_json_schema__ = True + """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(AgentBaseModel): + """Output from the sentiment analysis agent.""" + __strict_json_schema__ = True + + 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/Agent/Evaluators/sentiment_analysis_agent/sentiment_analysis_agent.py b/Agent/Evaluators/sentiment_analysis_agent/sentiment_analysis_agent.py new file mode 100644 index 000000000..9cc2bb989 --- /dev/null +++ b/Agent/Evaluators/sentiment_analysis_agent/sentiment_analysis_agent.py @@ -0,0 +1,119 @@ +# 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 octobot_agents as agent + +from .models import SentimentAnalysisOutput + + +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_VERSION = "1.0.0" + AGENT_CHANNEL = SentimentAnalysisAIAgentChannel + AGENT_CONSUMER = SentimentAnalysisAIAgentConsumer + + def __init__(self, channel, **kwargs): + super().__init__(channel, **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, ai_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 = [ + 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. " + "Include mandatory fields (eval_note, confidence, description, sentiment_score). " + "Include optional fields only if you have data for them.", + ), + ] + + try: + # Uses SentimentAnalysisAIAgentChannel.OUTPUT_SCHEMA by default + parsed = await self._call_llm( + messages, + ai_service, + json_output=True, + ) + 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 Exception as e: + self.logger.error(f"Error in sentiment analysis: {e}") + return { + "eval_note": 0, + "eval_note_description": f"Error in sentiment analysis: {str(e)}", + "confidence": 0, + } 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..1eb660b92 --- /dev/null +++ b/Agent/Evaluators/summarization_agent/models.py @@ -0,0 +1,40 @@ +# 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 + +from octobot_agents.models import AgentBaseModel + + +class SummarizationOutput(AgentBaseModel): + __strict_json_schema__ = True + """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..65c3d5095 --- /dev/null +++ b/Agent/Evaluators/summarization_agent/summarization_agent.py @@ -0,0 +1,265 @@ +# 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 octobot_agents.constants import RESULT_KEY, AGENT_NAME_KEY + +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_VERSION = "1.0.0" + AGENT_CHANNEL = SummarizationAIAgentChannel + AGENT_CONSUMER = SummarizationAIAgentConsumer + ENABLE_MEMORY = True + 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 = RESULT_KEY + agent_name_key = AGENT_NAME_KEY + + try: + inner_result = result[result_key] + if isinstance(inner_result, dict): + # 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) + except (KeyError, TypeError): + pass + 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 + try: + # Try dict access + agent_results_dict = typing.cast(AgentResultsDict, input_data) + agent_results_list = list(agent_results_dict.values()) + except (TypeError, AttributeError): + # List input + 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 Exception as e: + self.logger.error(f"Error in summarization: {e}") + # 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..a5fc6bb70 --- /dev/null +++ b/Agent/Evaluators/technical_analysis_agent/models.py @@ -0,0 +1,71 @@ +# 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 + +from octobot_agents.models import AgentBaseModel + + +class TechnicalAnalysisOutput(AgentBaseModel): + __strict_json_schema__ = True + """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/Agent/Evaluators/technical_analysis_agent/technical_analysis_agent.py b/Agent/Evaluators/technical_analysis_agent/technical_analysis_agent.py new file mode 100644 index 000000000..d15a1978b --- /dev/null +++ b/Agent/Evaluators/technical_analysis_agent/technical_analysis_agent.py @@ -0,0 +1,117 @@ +# 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 octobot_agents as agent + +from .models import TechnicalAnalysisOutput + + +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_VERSION = "1.0.0" + AGENT_CHANNEL = TechnicalAnalysisAIAgentChannel + AGENT_CONSUMER = TechnicalAnalysisAIAgentConsumer + + def __init__(self, channel, **kwargs): + super().__init__(channel, **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, ai_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 = [ + 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. " + "Include mandatory fields (eval_note, confidence, description). " + "Include optional fields only if you have data for them.", + ), + ] + + try: + # Uses TechnicalAnalysisAIAgentChannel.OUTPUT_SCHEMA by default + parsed = await self._call_llm( + messages, + ai_service, + json_output=True, + ) + 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 Exception as e: + self.logger.error(f"Error in technical analysis: {e}") + return { + "eval_note": 0, + "eval_note_description": f"Error in technical analysis: {str(e)}", + "confidence": 0, + } diff --git a/Agent/Manager/__init__.py b/Agent/Manager/__init__.py new file mode 100644 index 000000000..974dd1623 --- /dev/null +++ b/Agent/Manager/__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/Agent/Manager/default_manager_agent/__init__.py b/Agent/Manager/default_manager_agent/__init__.py new file mode 100644 index 000000000..ef4e3479a --- /dev/null +++ b/Agent/Manager/default_manager_agent/__init__.py @@ -0,0 +1,43 @@ +# 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 .default_manager_agent import ( + DefaultTeamManagerAgentProducer, + DefaultTeamManagerAgentChannel, + DefaultTeamManagerAgentConsumer, +) +from .ai_plan_manager_agent import ( + AIPlanTeamManagerAgentProducer, + AIPlanTeamManagerAgentChannel, + AIPlanTeamManagerAgentConsumer, +) +from .ai_tools_manager_agent import ( + AIToolsTeamManagerAgentProducer, + AIToolsTeamManagerAgentChannel, + AIToolsTeamManagerAgentConsumer, +) + +__all__ = [ + "DefaultTeamManagerAgentProducer", + "DefaultTeamManagerAgentChannel", + "DefaultTeamManagerAgentConsumer", + "AIPlanTeamManagerAgentProducer", + "AIPlanTeamManagerAgentChannel", + "AIPlanTeamManagerAgentConsumer", + "AIToolsTeamManagerAgentProducer", + "AIToolsTeamManagerAgentChannel", + "AIToolsTeamManagerAgentConsumer", +] diff --git a/Agent/Manager/default_manager_agent/ai_plan_manager_agent.py b/Agent/Manager/default_manager_agent/ai_plan_manager_agent.py new file mode 100644 index 000000000..f8e057907 --- /dev/null +++ b/Agent/Manager/default_manager_agent/ai_plan_manager_agent.py @@ -0,0 +1,179 @@ +# 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 team manager agent - uses LLM to decide execution flow. +""" +import typing +from typing import TYPE_CHECKING + +from octobot_agents.team.manager import ( + AIPlanManagerAgentChannel, + AIPlanManagerAgentConsumer, + AIPlanManagerAgentProducer, + AbstractTeamManagerAgent, +) +from octobot_agents.models import ExecutionPlan + +if TYPE_CHECKING: + from octobot_agents.models import ManagerInput + + +class AIPlanTeamManagerAgentChannel(AIPlanManagerAgentChannel): + """Channel for AI plan team manager.""" + __slots__ = () + + +class AIPlanTeamManagerAgentConsumer(AIPlanManagerAgentConsumer): + """Consumer for AI plan team manager.""" + __slots__ = () + + +class AIPlanTeamManagerAgentProducer(AIPlanManagerAgentProducer): + """ + AI plan team manager agent - uses LLM to decide execution flow. + + Inherits from AIPlanManagerAgentProducer. Has Channel, Producer, Consumer components (as all AI agents do). + """ + + AGENT_CHANNEL: typing.Type[AIPlanManagerAgentChannel] = AIPlanTeamManagerAgentChannel + AGENT_CONSUMER: typing.Type[AIPlanManagerAgentConsumer] = AIPlanTeamManagerAgentConsumer + + def __init__( + self, + channel: typing.Optional[AIPlanTeamManagerAgentChannel] = None, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + **kwargs, + ): + super().__init__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + **kwargs, + ) + + def _get_default_prompt(self) -> str: + """ + Return the default prompt for the AI team manager. + + Returns: + The default system prompt string. + """ + return """You are a team execution manager for an agent team system. +Your role is to analyze the team structure, current state, and any instructions, +then create an execution plan. The plan can contain two kinds of steps: + +1. Agent steps (step_type "agent" or omit): run a single agent. + - agent_name: name of the agent to run + - instructions (optional): list of instructions to send before execution + - wait_for (optional): agent names that must complete before this step + - skip (optional): set true to skip this step in this iteration + +2. Debate steps (step_type "debate"): run a debate phase (debators take turns, then judge decides continue or exit). + - debate_config: object with debator_agent_names (list of agent names that debate, e.g. Bull, Bear), + judge_agent_name (name of the judge agent), max_rounds (max debate rounds, e.g. 3) + - For debate steps, agent_name can be a placeholder (e.g. "debate_1") for logging. + +You may include zero, one, or multiple debate steps in the plan. Debate steps run debators in rounds until the judge decides exit or max_rounds is reached. Order and instructions for agent steps, and whether to loop execution, should optimize for the team's goals while respecting dependencies.""" + + async def execute( + self, + input_data: typing.Union["ManagerInput", typing.Dict[str, typing.Any]], + ai_service: typing.Any # AbstractAIService - type not available at runtime + ) -> ExecutionPlan: + """ + Build execution plan using LLM. + + Args: + input_data: Contains {"team_producer": team_producer, "initial_data": initial_data, "instructions": instructions} + ai_service: The AI service instance for LLM calls + + Returns: + ExecutionPlan from LLM + """ + team_producer = input_data.get("team_producer") + initial_data = input_data.get("initial_data", {}) + instructions = input_data.get("instructions") + + if team_producer is None: + raise ValueError("team_producer is required in input_data") + + # Build context + agents_info = [] + for agent in team_producer.agents: + agents_info.append({ + "name": agent.name, + "channel": agent.AGENT_CHANNEL.__name__ if agent.AGENT_CHANNEL else None, + }) + + relations_info = [] + for source_channel, target_channel in team_producer.relations: + relations_info.append({ + "source": source_channel.__name__, + "target": target_channel.__name__, + }) + + context = { + "team_name": team_producer.team_name, + "agents": agents_info, + "relations": relations_info, + "initial_data": initial_data, + "instructions": instructions, + } + + # Build messages for LLM + messages = [ + {"role": "system", "content": self.prompt}, + { + "role": "user", + "content": f"""Analyze the following team structure and create an execution plan: + +Team: {team_producer.team_name} +Agents: {self.format_data(agents_info)} +Relations: {self.format_data(relations_info)} +Initial Data: {self.format_data(initial_data)} +Instructions: {self.format_data(instructions) if instructions else "None"} + +Create an execution plan. Use agent steps (step_type "agent" or omit) for single-agent steps and debate steps (step_type "debate" with debate_config) when you want debators to argue and a judge to decide; you can include multiple debate steps if needed.""" + }, + ] + + # Call LLM with ExecutionPlan as response schema + response_data = await self._call_llm( + messages, + ai_service, + json_output=True, + response_schema=ExecutionPlan, + ) + + execution_plan = ExecutionPlan.model_validate(response_data) + + # Filter out debate steps if no judge agent is configured in the team + team_producer = input_data.get("team_producer") + if team_producer is None or team_producer.judge_agent is None: + filtered_steps = [] + for step in execution_plan.steps: + if step.step_type == "debate": + self.logger.debug(f"Skipping debate step '{step.agent_name}' - no judge agent configured in team") + continue + filtered_steps.append(step) + execution_plan.steps = filtered_steps + + self.logger.debug(f"Generated execution plan with {len(execution_plan.steps)} steps") + + return execution_plan diff --git a/Agent/Manager/default_manager_agent/ai_tools_manager_agent.py b/Agent/Manager/default_manager_agent/ai_tools_manager_agent.py new file mode 100644 index 000000000..33ec63f1a --- /dev/null +++ b/Agent/Manager/default_manager_agent/ai_tools_manager_agent.py @@ -0,0 +1,87 @@ +# 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 tools team manager agent - uses LLM with tools to decide execution flow. +""" +import typing +from typing import TYPE_CHECKING + +from octobot_agents.team.manager import ( + AIToolsManagerAgentChannel, + AIToolsManagerAgentConsumer, + AIToolsManagerAgentProducer, + AbstractTeamManagerAgent, +) + +if TYPE_CHECKING: + from octobot_agents.models import ManagerInput + + +class AIToolsTeamManagerAgentChannel(AIToolsManagerAgentChannel): + """Channel for AI tools team manager.""" + __slots__ = () + + +class AIToolsTeamManagerAgentConsumer(AIToolsManagerAgentConsumer): + """Consumer for AI tools team manager.""" + __slots__ = () + + +class AIToolsTeamManagerAgentProducer(AIToolsManagerAgentProducer): + """ + AI tools team manager agent - uses LLM with tools to decide execution flow. + + Inherits from AIToolsManagerAgentProducer. Has Channel, Producer, Consumer components (as all AI agents do). + """ + + AGENT_CHANNEL: typing.Type[AIToolsManagerAgentChannel] = AIToolsTeamManagerAgentChannel + AGENT_CONSUMER: typing.Type[AIToolsManagerAgentConsumer] = AIToolsTeamManagerAgentConsumer + + def __init__( + self, + channel: typing.Optional[AIToolsTeamManagerAgentChannel] = None, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + max_tool_calls: typing.Optional[int] = None, + **kwargs, + ): + super().__init__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + max_tool_calls=max_tool_calls, + **kwargs, + ) + + def _get_default_prompt(self) -> str: + """ + Return the default prompt for the AI tools team manager. + + Returns: + The default system prompt string. + """ + return """You are a tools-driven team execution manager for an agent team system. +Your role is to analyze the team structure, current state, and any instructions, +then coordinate execution using available tools. You have access to tools to run agents and debates. + +Available tools: +- run_agent: Execute a specific agent by name +- run_debate: Run a debate between multiple agents with a judge +- finish: Complete execution and return current results + +Use these tools to coordinate the team execution. Call finish when you have sufficient results.""" \ No newline at end of file diff --git a/Agent/Manager/default_manager_agent/default_manager_agent.py b/Agent/Manager/default_manager_agent/default_manager_agent.py new file mode 100644 index 000000000..beb31234e --- /dev/null +++ b/Agent/Manager/default_manager_agent/default_manager_agent.py @@ -0,0 +1,129 @@ +# 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. +""" +Default team manager agent - simple agent that executes in topological order. +""" +import typing +from typing import TYPE_CHECKING, List, Optional + +from octobot_agents.team.manager import ( + ManagerAgentChannel, + ManagerAgentConsumer, + ManagerAgentProducer, + AbstractTeamManagerAgent, +) +from octobot_agents.enums import StepType +from octobot_agents.models import DebatePhaseConfig, ExecutionPlan, ExecutionStep + +if TYPE_CHECKING: + from octobot_agents.models import ManagerInput + + +class DefaultTeamManagerAgentChannel(ManagerAgentChannel): + """Channel for default team manager.""" + __slots__ = () + + +class DefaultTeamManagerAgentConsumer(ManagerAgentConsumer): + """Consumer for default team manager.""" + __slots__ = () + + +class DefaultTeamManagerAgentProducer(ManagerAgentProducer): + """ + Default team manager agent - simple agent that executes in topological order. + + Inherits from ManagerAgentProducer. Has Channel, Producer, Consumer components (as all agents do). + """ + + AGENT_CHANNEL: typing.Type[ManagerAgentChannel] = DefaultTeamManagerAgentChannel + AGENT_CONSUMER: typing.Type[ManagerAgentConsumer] = DefaultTeamManagerAgentConsumer + + def __init__( + self, + channel: typing.Optional[DefaultTeamManagerAgentChannel] = None, + ): + super().__init__(channel=channel) + + async def execute( + self, + input_data: typing.Union["ManagerInput", typing.Dict[str, typing.Any]], + ai_service: typing.Any # AbstractAIService - type not available at runtime + ) -> ExecutionPlan: + """ + Build execution plan from topological sort. + + Args: + input_data: Contains {"team_producer": team_producer, "initial_data": initial_data, "instructions": instructions} + ai_service: Not used by default manager + + Returns: + ExecutionPlan with steps in topological order + """ + team_producer = input_data.get("team_producer") + if team_producer is None: + raise ValueError("team_producer is required in input_data") + + # Get execution order (topological sort) + execution_order = team_producer._get_execution_order() + incoming_edges, _ = team_producer._build_dag() + + # Build ExecutionPlan + steps: List[ExecutionStep] = [] + for agent in execution_order: + # Get predecessors for wait_for + channel_type = agent.AGENT_CHANNEL + if channel_type is None: + continue + + predecessors = incoming_edges.get(channel_type, []) + wait_for: Optional[List[str]] = None + if predecessors: + wait_for = [] + for pred_channel in predecessors: + pred_agent = team_producer._producer_by_channel.get(pred_channel) + if pred_agent: + wait_for.append(pred_agent.name) + + step = ExecutionStep( + agent_name=agent.name, + instructions=None, # No instructions by default + wait_for=wait_for, + skip=False, + ) + steps.append(step) + + # Optional: inject debate steps from initial_data.debate_phases + initial_data = input_data.get("initial_data") or {} + debate_phases = initial_data.get("debate_phases") if isinstance(initial_data, dict) else None + if isinstance(debate_phases, list) and debate_phases: + for idx, phase in enumerate(debate_phases): + config = DebatePhaseConfig.model_validate(phase) if not isinstance(phase, DebatePhaseConfig) else phase + steps.append( + ExecutionStep( + agent_name=f"debate_{idx + 1}", + step_type=StepType.DEBATE.value, + debate_config=config, + skip=False, + ) + ) + + return ExecutionPlan( + steps=steps, + loop=False, + loop_condition=None, + max_iterations=None, + ) diff --git a/Agent/Manager/default_manager_agent/metadata.json b/Agent/Manager/default_manager_agent/metadata.json new file mode 100644 index 000000000..f8f64b9cd --- /dev/null +++ b/Agent/Manager/default_manager_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["DefaultTeamManagerAgent", "AIPlanTeamManagerAgent", "AIToolsTeamManagerAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Memory/__init__.py b/Agent/Memory/__init__.py new file mode 100644 index 000000000..974dd1623 --- /dev/null +++ b/Agent/Memory/__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/Agent/Memory/default_memory_agent/__init__.py b/Agent/Memory/default_memory_agent/__init__.py new file mode 100644 index 000000000..b51abdd9c --- /dev/null +++ b/Agent/Memory/default_memory_agent/__init__.py @@ -0,0 +1,20 @@ +from .default_memory_agent import ( + DefaultMemoryAgentProducer, + DefaultMemoryAgentChannel, + DefaultMemoryAgentConsumer, +) +from .default_ai_memory_agent import ( + DefaultAIMemoryAgentProducer, + DefaultAIMemoryAgentChannel, + DefaultAIMemoryAgentConsumer, +) + +__all__ = [ + "DefaultMemoryAgentProducer", + "DefaultMemoryAgentChannel", + "DefaultMemoryAgentConsumer", + "DefaultAIMemoryAgentProducer", + "DefaultAIMemoryAgentChannel", + "DefaultAIMemoryAgentConsumer", +] + diff --git a/Agent/Memory/default_memory_agent/default_ai_memory_agent.py b/Agent/Memory/default_memory_agent/default_ai_memory_agent.py new file mode 100644 index 000000000..49e1c6a57 --- /dev/null +++ b/Agent/Memory/default_memory_agent/default_ai_memory_agent.py @@ -0,0 +1,339 @@ +# 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 + +from octobot_agents.agent.memory.channels import ( + AIMemoryAgentChannel, + AIMemoryAgentConsumer, + AIMemoryAgentProducer, + AbstractMemoryAgent, +) +import octobot_agents.models as models +from octobot_agents.constants import ( + MEMORY_TITLE_MAX_LENGTH, + MEMORY_CONTEXT_MAX_LENGTH, + MEMORY_CONTENT_MAX_LENGTH, +) + + +class DefaultAIMemoryAgentChannel(AIMemoryAgentChannel): + """Channel for default AI memory agent.""" + __slots__ = () + + +class DefaultAIMemoryAgentConsumer(AIMemoryAgentConsumer): + """Consumer for default AI memory agent.""" + __slots__ = () + + +class DefaultAIMemoryAgentProducer(AIMemoryAgentProducer): + """ + Default AI memory agent - uses LLM to generate memory titles, context, and content. + + Inherits from AIMemoryAgentProducer. Uses LLM to transform critic feedback into structured memory instructions. + + Design influenced by multi-layer and agent memory works including: + Mem-Agent, Moom, LightMem, Nemori, O-Mem (Omni Memory), SEDM, MemoRAG, + EM-LLM, COMEDY, Agent Workflow Memory (AWM), temporal/meta-data-aware RAG, + Memory Decoder (MemDec), Explicit Working Memory (Ewe), + Memento 2 (Stateful Reflective Memory), Agentic Memory (AgeMem), + Agentic Context Engineering (ACE), and practical agent memory systems + such as MemoryBank, LONGMEM, Reflexion and Generative Agents. + """ + + AGENT_CHANNEL: typing.Type[AIMemoryAgentChannel] = DefaultAIMemoryAgentChannel + AGENT_CONSUMER: typing.Type[AIMemoryAgentConsumer] = DefaultAIMemoryAgentConsumer + + def __init__( + self, + channel: typing.Optional[DefaultAIMemoryAgentChannel] = None, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + self_improving: bool = True, + **kwargs, + ): + super().__init__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + self_improving=self_improving, + **kwargs, + ) + + def _get_default_prompt(self) -> str: + """ + Return the default prompt for the AI-powered memory agent. + + Returns: + The default system prompt string. + """ + return f"""You are a memory management agent for an AI agent team. +Your role is to analyze critic feedback and transform it into short, simple, precise, command-like +instructions that help agents improve their behavior, strategies, and coordination over time. + +CRITICAL REQUIREMENTS: +1. Title (max {MEMORY_TITLE_MAX_LENGTH} chars): short, direct, action-oriented + (e.g., "Tighten risk on low-liquidity assets", "Use conservative allocation when volatility spikes"). +2. Context (max {MEMORY_CONTEXT_MAX_LENGTH} chars): very brief description of the situation or + recurring pattern (e.g., "Over-trading in sideways markets", "Stops too tight in high volatility"). +3. Content (max {MEMORY_CONTENT_MAX_LENGTH} chars): simple list of direct behavior-level commands, + one per line, no headers or long explanations. + +PRECISION REQUIREMENTS (CRITICAL): +- Focus on decision-making, strategy, risk management, and coordination between agents. +- Describe WHEN to apply a rule (market regime, instrument type, time horizon, signal conditions). +- Describe WHAT to do differently (e.g., adjust thresholds, avoid certain patterns, prefer + particular workflows, add safety checks). +- Avoid restating low-level schema or type constraints that are already enforced by Pydantic + models (do NOT create memories whose only purpose is to list required fields or types). +- Instead, capture the reasoning behind *why* an agent failed or succeeded so behavior can be + improved or reused in future similar situations. + +FORMAT REQUIREMENTS: +- Use imperative, command-like sentences (e.g., "Reduce position size when volatility increases", + "Wait for confirmation from multiple indicators before entering a trade"). +- NO section headers like "Structured Actions:" or "Guidance:". +- NO numbering prefixes ("1.", "2.", etc.). +- NO full stack traces or raw error messages; summarize them into a short, human-readable issue. +- Prefer a handful of strong, high-value commands over many tiny, redundant ones. + +GENERALIZATION RULES (STRICTLY ENFORCED): +- DO NOT include specific cryptocurrency names (e.g., "BTC", "ETH") or hard-coded tickers. +- DO express conditions and behaviors in generic terms (e.g., "asset", "market", "indicator", + "volatility spike", "low liquidity", "trend vs range"). +- Make content reusable across agents and markets by focusing on patterns, not one-off incidents. + +You will receive critic analysis that identifies which specific agents need improvements. +Only process memories for agents listed in critic_analysis.agent_improvements. +For each such agent, produce a small set of concise, high-impact behavioral or strategic +instructions that would have helped avoid the issues or replicate the successes.""" + + async def execute( + self, + input_data: typing.Union[models.MemoryInput, typing.Dict[str, typing.Any]], + ai_service: typing.Any, + ) -> models.MemoryOperation: + """ + Execute memory operations using LLM. + + Args: + input_data: Contains {"critic_analysis": CriticAnalysis, "agent_outputs": Dict, "execution_metadata": dict} + ai_service: The AI service instance for LLM calls + + Returns: + MemoryOperation with list of operations performed + """ + critic_analysis = input_data.get("critic_analysis") + agent_outputs = input_data.get("agent_outputs", {}) + execution_metadata = input_data.get("execution_metadata", {}) + + if not critic_analysis: + return models.MemoryOperation( + success=False, + operations=[], + memory_ids=[], + agent_updates={}, + agents_processed=[], + agents_skipped=[], + message="No critic analysis provided", + ) + + critic_analysis = models.CriticAnalysis.model_validate_or_self(critic_analysis) + + agent_improvements = critic_analysis.get_agent_improvements() + agents_to_process = list(agent_improvements.keys()) + + if not agents_to_process: + return models.MemoryOperation( + success=True, + operations=[], + memory_ids=[], + agent_updates={}, + agents_processed=[], + agents_skipped=list(agent_outputs.keys()), + message="No agents need memory updates", + ) + + improvements_summary = [] + for agent_name, improvement in agent_improvements.items(): + improvement = models.AgentImprovement.model_validate_or_self(improvement) + improvements_summary.append( + { + "agent_name": agent_name, + "improvements": improvement.improvements, + "issues": improvement.issues, + "errors": improvement.errors, + "reasoning": improvement.reasoning, + } + ) + + critic_summary = critic_analysis.get_summary() + critic_issues = critic_analysis.get_issues() + critic_errors = critic_analysis.errors + + messages = [ + {"role": "system", "content": self.prompt}, + { + "role": "user", + "content": f"""Transform the following critic feedback into short, simple, precise, command-like +instructions for each agent. Focus ONLY on behavioral, strategic, risk, and coordination +improvements – ignore pure schema/format/type issues. + +IMPORTANT FILTER: +- If the feedback for an agent only contains schema/format/type errors (e.g. missing keys, + wrong types, "output must be dict"), and NO behavioral or strategic issues, then: + -> Do NOT return any instructions for that agent. +- Only create instructions when there is at least one behavior/strategy/risk/logic issue + that can improve the agent's decisions. + +CRITICAL REQUIREMENTS: +- Title: Maximum {MEMORY_TITLE_MAX_LENGTH} characters - short, direct, behavioral/strategic command. +- Context: Maximum {MEMORY_CONTEXT_MAX_LENGTH} characters - short description of the situation or + recurring pattern (NOT raw error messages). +- Content: Maximum {MEMORY_CONTENT_MAX_LENGTH} characters - simple list of behavior-level commands, + one per line. + +Critic Summary: {critic_summary} +Team Issues: {self.format_data(critic_issues)} +Team Errors: {self.format_data(critic_errors)} + +Agent Improvements: +{self.format_data(improvements_summary)} + +Return a JSON object with an \"instructions\" array containing entries only for agents that +have at least one behavioral or strategic issue to learn from.""", + }, + ] + + try: + response_data = await self._call_llm( + messages, + ai_service, + json_output=True, + response_schema=models.AgentMemoryInstructionsList, + input_data=input_data, + ) + + instructions_list_model = models.AgentMemoryInstructionsList.model_validate(response_data) + operations: typing.List[str] = [] + memory_ids: typing.List[str] = [] + agent_updates: typing.Dict[str, typing.List[str]] = {} + + team_producer = execution_metadata.get("team_producer") + + agents_processed: typing.List[str] = [] + agents_skipped: typing.List[str] = [] + + instructions_list = instructions_list_model.instructions + instructions_by_agent = { + item.agent_name: item.instructions + for item in instructions_list + } + + for agent_name in agents_to_process: + improvement = agent_improvements[agent_name] + improvement = models.AgentImprovement.model_validate_or_self(improvement) + + agent = None + if team_producer: + try: + agent = team_producer.get_agent_by_name(agent_name) + if agent is None: + manager = team_producer.get_manager() + if manager and manager.name == agent_name: + agent = manager + except (AttributeError, KeyError): + agent = None + + if agent is None: + agents_skipped.append(agent_name) + continue + + try: + memory_enabled = agent.has_memory_enabled() + except AttributeError: + memory_enabled = getattr(agent, "ENABLE_MEMORY", False) + + if not memory_enabled: + agents_skipped.append(agent_name) + continue + + try: + agent_memory_storage = agent.memory_manager + except AttributeError: + agent_memory_storage = None + if agent_memory_storage is None: + agents_skipped.append(agent_name) + continue + + agent_instructions = instructions_by_agent.get(agent_name) + if not agent_instructions: + agents_skipped.append(agent_name) + continue + + memory_instruction = models.MemoryInstruction.model_validate_or_self(agent_instructions) + title = memory_instruction.title + context_text = memory_instruction.context + content = memory_instruction.build_content() + + await agent_memory_storage.store_memory( + messages=[{"role": "user", "content": content}], + input_data={"agent_id": ""}, + metadata={ + "category": "improvement", + "importance_score": 0.7, + "title": title, + "context": context_text, + "source_agent": agent_name, + "source_type": "critic_improvement", + "memory_system": "ai_memory_agent", + "domain_tags": execution_metadata.get("domain_tags", []), + }, + ) + + all_memories = agent_memory_storage.get_all_memories() + if all_memories: + new_memory_id = all_memories[-1].get("id") + memory_ids.append(new_memory_id) + agent_updates.setdefault(agent_name, []).append(new_memory_id) + if "generated" not in operations: + operations.append("generated") + + agents_processed.append(agent_name) + + return models.MemoryOperation( + success=True, + operations=operations, + memory_ids=memory_ids, + agent_updates=agent_updates, + agents_processed=agents_processed, + agents_skipped=agents_skipped, + message=f"Processed memories for {len(agents_processed)} agents", + ) + except Exception as e: + self.logger.error(f"DefaultAIMemoryAgentProducer error executing memory operations: {e}") + return models.MemoryOperation( + success=False, + operations=[], + memory_ids=[], + agent_updates={}, + agents_processed=[], + agents_skipped=agents_to_process, + message=f"Error: {str(e)}", + ) + diff --git a/Agent/Memory/default_memory_agent/default_memory_agent.py b/Agent/Memory/default_memory_agent/default_memory_agent.py new file mode 100644 index 000000000..3e720257f --- /dev/null +++ b/Agent/Memory/default_memory_agent/default_memory_agent.py @@ -0,0 +1,355 @@ +# 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 + +from octobot_agents.agent.memory.channels import ( + MemoryAgentChannel, + MemoryAgentConsumer, + MemoryAgentProducer, + AbstractMemoryAgent, +) +import octobot_agents.models as models +from octobot_agents.constants import ( + MEMORY_TITLE_MAX_LENGTH, + MEMORY_CONTEXT_MAX_LENGTH, + MEMORY_CONTENT_MAX_LENGTH, +) + + +class DefaultMemoryAgentChannel(MemoryAgentChannel): + """Channel for default memory agent.""" + __slots__ = () + + +class DefaultMemoryAgentConsumer(MemoryAgentConsumer): + """Consumer for default memory agent.""" + __slots__ = () + + +class DefaultMemoryAgentProducer(MemoryAgentProducer): + """ + Default memory agent - simple rule-based memory operations. + + Inherits from MemoryAgentProducer. Uses simple heuristics instead of LLM. + """ + + AGENT_CHANNEL: typing.Type[MemoryAgentChannel] = DefaultMemoryAgentChannel + AGENT_CONSUMER: typing.Type[MemoryAgentConsumer] = DefaultMemoryAgentConsumer + + def __init__( + self, + channel: typing.Optional[DefaultMemoryAgentChannel] = None, + self_improving: bool = True, + ): + super().__init__(channel=channel, self_improving=self_improving) + + def _transform_to_instructions( + self, + improvement: models.AgentImprovement, + agent_name: str + ) -> typing.Tuple[str, str, str]: + """ + Transform critic feedback into actionable instructions using heuristics. + + Args: + improvement: The AgentImprovement model with critic feedback + agent_name: Name of the agent + + Returns: + Tuple of (title, content, context) for the memory + """ + improvements_list = improvement.improvements + issues_list = improvement.issues + reasoning = improvement.reasoning + + # Build structured actions from improvements and issues + structured_actions = [] + + # Transform common issues into short, direct commands + for issue in issues_list: + issue_lower = issue.lower() + if "empty dict" in issue_lower or "empty result" in issue_lower: + structured_actions.append("Return structured output") + elif "schema validation failed" in issue_lower or "validation failed" in issue_lower: + structured_actions.append("Validate schema") + elif "missing data" in issue_lower or "missing field" in issue_lower: + structured_actions.append("Include all required fields") + elif "incorrect" in issue_lower or "wrong" in issue_lower: + structured_actions.append("Verify output format") + elif "tuple" in issue_lower and "expected" in issue_lower: + structured_actions.append("Return dict format") + + # Add improvements as short commands if not already covered + for improvement_text in improvements_list: + improvement_lower = improvement_text.lower() + if "quality" in improvement_lower: + if not any("validate" in action.lower() or "structure" in action.lower() for action in structured_actions): + structured_actions.append("Validate output") + elif "completeness" in improvement_lower: + if not any("complete" in action.lower() or "missing" in action.lower() or "include" in action.lower() for action in structured_actions): + structured_actions.append("Include all fields") + elif "correctness" in improvement_lower: + if not any("correct" in action.lower() or "verify" in action.lower() for action in structured_actions): + structured_actions.append("Verify output") + + # If no structured actions were generated, create short commands from improvements + if not structured_actions: + for improvement_text in improvements_list: + # Make it short and imperative + improvement_short = improvement_text.lower() + if "improve" in improvement_short: + improvement_short = improvement_short.replace("improve", "").strip() + if "quality" in improvement_short: + structured_actions.append("Validate output") + elif "completeness" in improvement_short: + structured_actions.append("Include all fields") + elif "correctness" in improvement_short: + structured_actions.append("Verify output") + else: + # Extract key verb/noun, make imperative + words = improvement_short.split()[:3] # Take first 3 words max + structured_actions.append(" ".join(words).capitalize()) + + # Build content as simple command list - no headers + content_parts = [] + for action in structured_actions: + # Remove numbering if present + action_clean = action.lstrip("0123456789. ").strip() + if action_clean: + content_parts.append(action_clean) + + content = "\n".join(content_parts) if content_parts else "Follow instructions" + + # Generate short, direct title from first issue or improvement + if issues_list: + # Extract short problem description from first issue + first_issue = issues_list[0].lower() + if "schema validation" in first_issue or "validation failed" in first_issue: + title = "Validate schema" + elif "empty dict" in first_issue or "empty result" in first_issue: + title = "Return structured output" + elif "missing" in first_issue: + title = "Include all fields" + elif "tuple" in first_issue: + title = "Return dict format" + else: + # Extract key words (max 3-4 words) + words = first_issue.split()[:4] + title = " ".join(words).capitalize() + elif improvements_list: + # Extract short command from first improvement + first_improvement = improvements_list[0].lower() + if "quality" in first_improvement: + title = "Validate output" + elif "completeness" in first_improvement: + title = "Include all fields" + elif "correctness" in first_improvement: + title = "Verify output" + else: + words = first_improvement.split()[:3] + title = " ".join(words).capitalize() + elif reasoning: + # Extract short command from reasoning + reasoning_lower = reasoning.lower() + if "schema" in reasoning_lower: + title = "Validate schema" + elif "empty" in reasoning_lower: + title = "Return structured output" + else: + words = reasoning.split()[:3] + title = " ".join(words).capitalize() + else: + title = "Follow instructions" + + # Truncate title if needed + if len(title) > MEMORY_TITLE_MAX_LENGTH: + truncated = title[:MEMORY_TITLE_MAX_LENGTH] + last_space = truncated.rfind(' ') + if last_space > MEMORY_TITLE_MAX_LENGTH * 0.7: + title = truncated[:last_space].strip() + else: + title = truncated.strip() + + # Build short, focused context - just the problem, no verbose descriptions + if issues_list: + # Extract short problem description from first issue + first_issue = issues_list[0] + if "schema validation failed" in first_issue.lower(): + context = "Schema validation failed" + elif "empty dict" in first_issue.lower() or "empty result" in first_issue.lower(): + context = "Empty output" + elif "missing" in first_issue.lower(): + context = "Missing fields" + elif "tuple" in first_issue.lower(): + context = "Wrong format" + else: + # Extract first few words (max 5-6 words) + words = first_issue.split()[:6] + context = " ".join(words) + # Remove common verbose prefixes + context = context.replace("Agent ", "").replace("produced result with ", "").replace("quality issues. ", "") + else: + context = "Quality issue" + + # Truncate context if needed + if len(context) > MEMORY_CONTEXT_MAX_LENGTH: + truncated = context[:MEMORY_CONTEXT_MAX_LENGTH] + last_space = truncated.rfind(' ') + if last_space > MEMORY_CONTEXT_MAX_LENGTH * 0.7: + context = truncated[:last_space].strip() + else: + context = truncated.strip() + + # Truncate content if needed at sentence boundary + if len(content) > MEMORY_CONTENT_MAX_LENGTH: + truncated = content[:MEMORY_CONTENT_MAX_LENGTH] + last_period = truncated.rfind('.') + last_newline = truncated.rfind('\n') + last_break = max(last_period, last_newline) + if last_break > MEMORY_CONTENT_MAX_LENGTH * 0.7: + content = truncated[:last_break + 1].strip() + else: + content = truncated.strip() + + return title, content, context + + async def execute( + self, + input_data: typing.Union[models.MemoryInput, typing.Dict[str, typing.Any]], + ai_service: typing.Any # AbstractAIService - type not available at runtime + ) -> models.MemoryOperation: + """ + Execute memory operations using simple heuristics. + + Args: + input_data: Contains {"critic_analysis": CriticAnalysis, "agent_outputs": Dict, "execution_metadata": dict} + ai_service: Not used by default memory agent + + Returns: + MemoryOperation with list of operations performed + """ + critic_analysis = input_data.get("critic_analysis") + agent_outputs = input_data.get("agent_outputs", {}) + + if not critic_analysis: + return models.MemoryOperation( + success=False, + operations=[], + memory_ids=[], + agent_updates={}, + agents_processed=[], + agents_skipped=list(agent_outputs.keys()), + message="No critic analysis provided", + ) + + # Validate critic_analysis to ensure it's a model + critic_analysis = models.CriticAnalysis.model_validate_or_self(critic_analysis) + + # Get agent_improvements dict + agent_improvements = critic_analysis.get_agent_improvements() + agents_to_process = list(agent_improvements.keys()) + + if not agents_to_process: + return models.MemoryOperation( + success=True, + operations=[], + memory_ids=[], + agent_updates={}, + agents_processed=[], + agents_skipped=list(agent_outputs.keys()), + message="No agents need memory updates", + ) + + operations = [] + memory_ids = [] + agent_updates = {} + agents_processed = [] + agents_skipped = [] + + # Get team producer for agent version lookup + execution_metadata = input_data.get("execution_metadata", {}) + team_producer = execution_metadata.get("team_producer") + + # Process each agent + for agent_name in agents_to_process: + improvement = agent_improvements[agent_name] + + # Validate improvement to ensure it's a model + improvement = models.AgentImprovement.model_validate_or_self(improvement) + + # Get agent instance to check if memory is enabled + agent = self._get_agent_from_team(team_producer, agent_name) + + # Check if agent has memory enabled + if agent is None: + agents_skipped.append(agent_name) + continue + + try: + memory_enabled = agent.has_memory_enabled() + except AttributeError: + # Agent doesn't have memory_manager, skip it + agents_skipped.append(agent_name) + continue + + # Skip agents without memory enabled + if not memory_enabled: + agents_skipped.append(agent_name) + continue + + # Use agent's existing memory_manager instead of creating a new one + agent_memory_storage = agent.memory_manager + + # Transform critic feedback into actionable instructions + title, content, context = self._transform_to_instructions(improvement, agent_name) + + # Store the actionable instruction as memory + await agent_memory_storage.store_memory( + messages=[{"role": "user", "content": content}], + input_data={"agent_id": ""}, + metadata={ + "category": "improvement", + "importance_score": 0.7, + "title": title, + "context": context, + } + ) + + # Get newly created memory ID + all_memories = agent_memory_storage.get_all_memories() + if all_memories: + new_memory_id = all_memories[-1].get("id") + memory_ids.append(new_memory_id) + agent_updates.setdefault(agent_name, []).append(new_memory_id) + operations.append("generated") + + agents_processed.append(agent_name) + + # Add agents not in agent_improvements to skipped list + all_agent_names = self._collect_all_agent_names(agent_outputs, team_producer) + for name in all_agent_names: + if name not in agents_to_process and name not in agents_skipped: + agents_skipped.append(name) + + return models.MemoryOperation( + success=True, + operations=operations, + memory_ids=memory_ids, + agent_updates=agent_updates, + agents_processed=agents_processed, + agents_skipped=agents_skipped, + message=f"Generated memories for {len(agents_processed)} agents", + ) diff --git a/Agent/Memory/default_memory_agent/metadata.json b/Agent/Memory/default_memory_agent/metadata.json new file mode 100644 index 000000000..08beb4fa9 --- /dev/null +++ b/Agent/Memory/default_memory_agent/metadata.json @@ -0,0 +1,10 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": [ + "DefaultMemoryAgent", + "DefaultAIMemoryAgent" + ], + "tentacles-requirements": [] +} + 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..afb91f4dc --- /dev/null +++ b/Agent/Teams/simple_ai_evaluator_agents_team/simple_ai_evaluator_agents_team.py @@ -0,0 +1,207 @@ +# 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, +) +from tentacles.Agent.Critic.default_critic_agent import DefaultAICriticAgentProducer +from tentacles.Agent.Memory.default_memory_agent import DefaultAIMemoryAgentProducer +from tentacles.Agent.Manager.default_manager_agent import AIPlanTeamManagerAgentProducer + + +class SimpleAIEvaluatorAgentsTeamChannel(agent.AbstractAgentsTeamChannel): + """Channel for SimpleAIEvaluatorAgentsTeam outputs.""" + pass + + +class SimpleAIEvaluatorAgentsTeamConsumer(agent.AbstractAgentsTeamChannelConsumer): + """Consumer for SimpleAIEvaluatorAgentsTeam outputs.""" + pass + + +class SimpleAIEvaluatorAgentsTeam(agent.AbstractSyncAgentsTeamChannelProducer): + """ + 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 + + CriticAgentClass = DefaultAICriticAgentProducer + MemoryAgentClass = DefaultAIMemoryAgentProducer + ManagerAgentClass = AIPlanTeamManagerAgentProducer + + 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) + # Store reference for result lookup + self.summarization_producer = summarization_producer + + super().__init__( + channel=channel, + agents=agents, + relations=relations, + ai_service=ai_service, + team_name=self.TEAM_NAME, + team_id=team_id, + self_improving=True, + ) + + 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 using the actual agent name + summarization_result = results.get(self.summarization_producer.name) + 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 + try: + eval_note = summarization_result.get("eval_note", common_constants.START_PENDING_EVAL_NOTE) + description = summarization_result.get("eval_note_description", "") + return eval_note, description + except AttributeError: + # Not a dict, return as-is + 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/bull_bear_research_agent/__init__.py b/Agent/Trading/bull_bear_research_agent/__init__.py new file mode 100644 index 000000000..df644c993 --- /dev/null +++ b/Agent/Trading/bull_bear_research_agent/__init__.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. + +from .bull_research_agent import ( + BullResearchAIAgentChannel, + BullResearchAIAgentConsumer, + BullResearchAIAgentProducer, +) +from .bear_research_agent import ( + BearResearchAIAgentChannel, + BearResearchAIAgentConsumer, + BearResearchAIAgentProducer, +) +from .models import ResearchDebateOutput + +__all__ = [ + "BullResearchAIAgentChannel", + "BullResearchAIAgentConsumer", + "BullResearchAIAgentProducer", + "BearResearchAIAgentChannel", + "BearResearchAIAgentConsumer", + "BearResearchAIAgentProducer", + "ResearchDebateOutput", +] diff --git a/Agent/Trading/bull_bear_research_agent/bear_research_agent.py b/Agent/Trading/bull_bear_research_agent/bear_research_agent.py new file mode 100644 index 000000000..683de6910 --- /dev/null +++ b/Agent/Trading/bull_bear_research_agent/bear_research_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. + +""" +Bear Research Agent. +Takes the bearish side in a research debate: argues for caution, lower allocation, or risk reduction. +""" +import json +import typing + +import octobot_agents as agent +from octobot_services.enums import AIModelPolicy + +from .models import ResearchDebateOutput + + +class BearResearchAIAgentChannel(agent.AbstractAgentChannel): + """Channel for BearResearchAIAgentProducer.""" + OUTPUT_SCHEMA = ResearchDebateOutput + + +class BearResearchAIAgentConsumer(agent.AbstractAIAgentChannelConsumer): + """Consumer for BearResearchAIAgentProducer.""" + pass + + +class BearResearchAIAgentProducer(agent.AbstractAIAgentChannelProducer): + """ + Bear researcher: argues the bearish case in a research debate. + Uses strategy data, portfolio context, and debate history to argue for caution. + """ + + AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = BearResearchAIAgentChannel + AGENT_CONSUMER = BearResearchAIAgentConsumer + MODEL_POLICY = AIModelPolicy.FAST + + def __init__(self, channel=None, model=None, max_tokens=None, temperature=None, **kwargs): + super().__init__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + **kwargs, + ) + + def _get_default_prompt(self) -> str: + return """You are the Bear Researcher in an investment research debate. +Your role is to argue the bearish case: reasons to reduce exposure, be cautious, or favor defensive allocation. + +You receive: +1. Initial state: portfolio, strategy data (global and per-crypto), current distribution. +2. Debate history: previous messages from you and the Bull researcher. + +Respond with a short, focused argument (one paragraph) for the bearish side. Consider risks, overvaluation, and downside. +Output JSON with: "message" (your argument text), optionally "reasoning" (brief).""" + + def _build_user_prompt(self, input_data: typing.Dict[str, typing.Any]) -> str: + initial_state = input_data.get("_initial_state") or {} + debate_history = input_data.get("_debate_history") or [] + round_num = input_data.get("_debate_round", 1) + state_preview = json.dumps({ + "crypto_strategy_data_keys": list((initial_state.get("crypto_strategy_data") or {}).keys()), + "global_strategy_data_keys": list((initial_state.get("global_strategy_data") or {}).keys()), + "current_distribution": initial_state.get("current_distribution"), + "reference_market": initial_state.get("reference_market"), + }, indent=2) + debate_text = "\n".join( + f"[{e.get('agent_name', '?')}]: {e.get('message', '')[:300]}" + for e in debate_history + ) if debate_history else "No previous messages." + return f"""Round {round_num} + +Initial state (summary): +{state_preview} + +Debate so far: +{debate_text} + +Your bearish argument (short, one paragraph):""" + + async def execute(self, input_data: typing.Any, ai_service) -> typing.Any: + messages = [ + {"role": "system", "content": self.prompt}, + {"role": "user", "content": self._build_user_prompt(input_data)}, + ] + response_data = await self._call_llm(messages, ai_service, json_output=True, response_schema=ResearchDebateOutput) + out = ResearchDebateOutput(**response_data) + return {"message": out.message, "reasoning": out.reasoning} diff --git a/Agent/Trading/bull_bear_research_agent/bull_research_agent.py b/Agent/Trading/bull_bear_research_agent/bull_research_agent.py new file mode 100644 index 000000000..74ccc7f6f --- /dev/null +++ b/Agent/Trading/bull_bear_research_agent/bull_research_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. + +""" +Bull Research Agent. +Takes the bullish side in a research debate: argues for higher allocation / more risk based on strategy data. +""" +import json +import typing + +import octobot_agents as agent +from octobot_services.enums import AIModelPolicy + +from .models import ResearchDebateOutput + + +class BullResearchAIAgentChannel(agent.AbstractAgentChannel): + """Channel for BullResearchAIAgentProducer.""" + OUTPUT_SCHEMA = ResearchDebateOutput + + +class BullResearchAIAgentConsumer(agent.AbstractAIAgentChannelConsumer): + """Consumer for BullResearchAIAgentProducer.""" + pass + + +class BullResearchAIAgentProducer(agent.AbstractAIAgentChannelProducer): + """ + Bull researcher: argues the bullish case in a research debate. + Uses strategy data, portfolio context, and debate history to make a short argument. + """ + + AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = BullResearchAIAgentChannel + AGENT_CONSUMER = BullResearchAIAgentConsumer + MODEL_POLICY = AIModelPolicy.FAST + + def __init__(self, channel=None, model=None, max_tokens=None, temperature=None, **kwargs): + super().__init__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + **kwargs, + ) + + def _get_default_prompt(self) -> str: + return """You are the Bull Researcher in an investment research debate. +Your role is to argue the bullish case: reasons to increase exposure, take more risk, or favor allocation to assets. + +You receive: +1. Initial state: portfolio, strategy data (global and per-crypto), current distribution. +2. Debate history: previous messages from you and the Bear researcher. + +Respond with a short, focused argument (one paragraph) for the bullish side. Consider signals, momentum, and opportunities. +Output JSON with: "message" (your argument text), optionally "reasoning" (brief).""" + + def _build_user_prompt(self, input_data: typing.Dict[str, typing.Any]) -> str: + initial_state = input_data.get("_initial_state") or {} + debate_history = input_data.get("_debate_history") or [] + round_num = input_data.get("_debate_round", 1) + state_preview = json.dumps({ + "crypto_strategy_data_keys": list((initial_state.get("crypto_strategy_data") or {}).keys()), + "global_strategy_data_keys": list((initial_state.get("global_strategy_data") or {}).keys()), + "current_distribution": initial_state.get("current_distribution"), + "reference_market": initial_state.get("reference_market"), + }, indent=2) + debate_text = "\n".join( + f"[{e.get('agent_name', '?')}]: {e.get('message', '')[:300]}" + for e in debate_history + ) if debate_history else "No previous messages." + return f"""Round {round_num} + +Initial state (summary): +{state_preview} + +Debate so far: +{debate_text} + +Your bullish argument (short, one paragraph):""" + + async def execute(self, input_data: typing.Any, ai_service) -> typing.Any: + messages = [ + {"role": "system", "content": self.prompt}, + {"role": "user", "content": self._build_user_prompt(input_data)}, + ] + response_data = await self._call_llm(messages, ai_service, json_output=True, response_schema=ResearchDebateOutput) + out = ResearchDebateOutput(**response_data) + return {"message": out.message, "reasoning": out.reasoning} diff --git a/Agent/Trading/bull_bear_research_agent/metadata.json b/Agent/Trading/bull_bear_research_agent/metadata.json new file mode 100644 index 000000000..a3a582e19 --- /dev/null +++ b/Agent/Trading/bull_bear_research_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["BullResearchAgent", "BearResearchAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Trading/bull_bear_research_agent/models.py b/Agent/Trading/bull_bear_research_agent/models.py new file mode 100644 index 000000000..297d7925f --- /dev/null +++ b/Agent/Trading/bull_bear_research_agent/models.py @@ -0,0 +1,26 @@ +# 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. + +"""Output models for bull/bear research debate agents.""" +from typing import Optional +from octobot_agents.models import AgentBaseModel + + +class ResearchDebateOutput(AgentBaseModel): + """Output from a research debate agent (bull or bear): message for the debate.""" + __strict_json_schema__ = True + message: str + reasoning: Optional[str] = None diff --git a/Agent/Trading/distribution_agent/__init__.py b/Agent/Trading/distribution_agent/__init__.py new file mode 100644 index 000000000..fd2e8cda3 --- /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 .models import AssetDistribution, DistributionOutput +from .distribution_agent import ( + DistributionAIAgentChannel, + DistributionAIAgentConsumer, + DistributionAIAgentProducer, + run_distribution_agent, +) + +__all__ = [ + "AssetDistribution", + "DistributionOutput", + "DistributionAIAgentChannel", + "DistributionAIAgentConsumer", + "DistributionAIAgentProducer", + "run_distribution_agent", +] diff --git a/Agent/Trading/distribution_agent/constants.py b/Agent/Trading/distribution_agent/constants.py new file mode 100644 index 000000000..22d314de4 --- /dev/null +++ b/Agent/Trading/distribution_agent/constants.py @@ -0,0 +1,30 @@ +# 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. + +# 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" diff --git a/Agent/Trading/distribution_agent/distribution_agent.py b/Agent/Trading/distribution_agent/distribution_agent.py new file mode 100644 index 000000000..80bde9d47 --- /dev/null +++ b/Agent/Trading/distribution_agent/distribution_agent.py @@ -0,0 +1,338 @@ +# 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. + +""" +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 + +import octobot_agents as agent +from octobot_agents.constants import RESULT_KEY +from octobot_services.enums import AIModelPolicy + +from tentacles.Agent.Trading.signal_agent.state import AIAgentState +from .models import DistributionOutput + + +class DistributionAIAgentChannel(agent.AbstractAgentChannel): + """Channel for DistributionAIAgentProducer.""" + OUTPUT_SCHEMA = DistributionOutput + + +class DistributionAIAgentConsumer(agent.AbstractAIAgentChannelConsumer): + """Consumer for DistributionAIAgentProducer.""" + pass + + +class DistributionAIAgentProducer(agent.AbstractAIAgentChannelProducer): + """ + Distribution agent producer that makes final portfolio allocation decisions. + Combines signal synthesis and risk assessment to determine target distribution. + """ + MODEL_POLICY = AIModelPolicy.REASONING + + AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = DistributionAIAgentChannel + AGENT_CONSUMER = DistributionAIAgentConsumer + ENABLE_MEMORY = True + + def __init__(self, channel, model=None, max_tokens=None, temperature=None, **kwargs): + """ + 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__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + **kwargs, + ) + + 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 + +## REQUIRED OUTPUT FORMAT - STRICT SCHEMA + +Output a JSON object with: +- "distributions" (or "allocations"): Array of objects, each with ALL of these REQUIRED fields: + * "asset" (string): Asset symbol like "BTC", "ETH", "USD" - REQUIRED + * "percentage" (number): Float between 0.0 and 100.0 - REQUIRED (can also be named "target_percentage", "target_allocation", "allocation", "weight", or "ratio") + * "action" (string): EXACTLY one of "increase", "decrease", "maintain", "add", "remove" - REQUIRED + * "explanation" (string): Clear explanation text for this allocation - REQUIRED +- "rebalance_urgency" (string): EXACTLY one of "immediate", "soon", "low", "none" - REQUIRED +- "reasoning" (string): Summary explanation - REQUIRED + +CRITICAL: Every field above is REQUIRED. Do NOT omit any field, especially "explanation" in each distribution object. +""" + + 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: + try: + # Try Pydantic model access + 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 + ] + } + except AttributeError: + # It's a dict + synthesis_data = signal_synthesis + + # Format risk output + risk_data = {} + if risk_output: + try: + # Try Pydantic model access + 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 + } + except AttributeError: + # It's a 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 + +## REQUIRED OUTPUT FORMAT - STRICT SCHEMA + +You MUST return a JSON object with: +- "distributions" (or "allocations"): Array where each object has ALL of these REQUIRED fields: + * "asset" (string): REQUIRED - Asset symbol + * "percentage" (number): REQUIRED - Float 0.0-100.0 (can also be named "target_percentage", "target_allocation", "allocation", "weight", or "ratio") + * "action" (string): REQUIRED - One of "increase", "decrease", "maintain", "add", "remove" + * "explanation" (string): REQUIRED - Clear explanation for this allocation decision +- "rebalance_urgency" (string): REQUIRED - One of "immediate", "soon", "low", "none" +- "reasoning" (string): REQUIRED - Overall reasoning + +CRITICAL: Every field is REQUIRED. Do NOT omit "explanation" in any distribution object. + +Remember: +- Percentages must sum to 100% +- Only use allowed assets +- Balance opportunity with risk +""" + + def _merge_predecessor_outputs(self, input_data: typing.Any) -> dict: + """ + Merge predecessor agent outputs into state. + + When the distribution agent has multiple predecessors (Signal and Risk), + the team system passes them as a dict with agent names as keys. + This method extracts and merges their outputs into the state. + + Args: + input_data: Either a state dict (for entry agents) or a dict with + predecessor outputs keyed by agent name. + + Returns: + Merged state dict with signal_synthesis and risk_output at top level. + """ + + # Extract initial state if available (stored in _initial_state by team system) + initial_state = {} + if isinstance(input_data, dict) and "_initial_state" in input_data: + initial_state = input_data["_initial_state"] + # Create a copy of input_data without _initial_state for processing + input_data = {k: v for k, v in input_data.items() if k != "_initial_state"} + + # If input_data is already a state dict (has expected keys), use it directly + if isinstance(input_data, dict): + # Check if it's a state dict (has state keys) or predecessor outputs (has agent names) + state_keys = {"cryptocurrencies", "reference_market", "portfolio", "current_distribution"} + if state_keys.intersection(input_data.keys()): + # It's already a state dict + state = input_data.copy() + # Remove predecessor agent keys from state copy to avoid conflicts + # We'll merge their outputs properly below + agent_names = ["SignalAIAgentProducer", "RiskAIAgentProducer"] + for agent_name in agent_names: + state.pop(agent_name, None) + elif initial_state: + # Use initial_state as base if available + state = initial_state.copy() + else: + # Fallback: It's only predecessor outputs + state = {} + + # Extract outputs from predecessor agents + # Signal agent output structure: {"signal_outputs": {...}, "signal_synthesis": {...}} + # Risk agent output structure: {"risk_output": {...}} + + # Check for Signal agent output + signal_agent_name = "SignalAIAgentProducer" + if signal_agent_name in input_data: + signal_result = input_data[signal_agent_name] + if isinstance(signal_result, dict): + # Extract RESULT_KEY if present (team system wraps results) + signal_output = signal_result.get(RESULT_KEY, signal_result) + if isinstance(signal_output, dict): + # Merge signal outputs into state + if "signal_outputs" in signal_output: + state["signal_outputs"] = signal_output["signal_outputs"] + if "signal_synthesis" in signal_output: + state["signal_synthesis"] = signal_output["signal_synthesis"] + + # Check for Risk agent output + risk_agent_name = "RiskAIAgentProducer" + if risk_agent_name in input_data: + risk_result = input_data[risk_agent_name] + if isinstance(risk_result, dict): + # Extract RESULT_KEY if present + risk_output = risk_result.get(RESULT_KEY, risk_result) + if isinstance(risk_output, dict) and "risk_output" in risk_output: + state["risk_output"] = risk_output["risk_output"] + + # If state is still empty, try to use input_data as state (fallback) + if not state and isinstance(input_data, dict): + state = input_data + + return state + + async def execute(self, input_data: typing.Any, ai_service) -> typing.Any: + # Merge predecessor outputs into state + state = self._merge_predecessor_outputs(input_data) + self.logger.debug(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, + ai_service, + json_output=True, + ) + distribution_output = DistributionOutput(**response_data) + + self.logger.debug(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, ai_service, agent_id: str = "distribution-agent") -> dict: + """ + Convenience function to run the distribution agent. + + Args: + state: The current agent state. + ai_service: The AI service instance. + agent_id: Unique identifier for the agent instance. + + Returns: + State updates from the agent. + """ + 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..c7bbcc4d4 --- /dev/null +++ b/Agent/Trading/distribution_agent/models.py @@ -0,0 +1,128 @@ +# 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, AliasChoices + +from octobot_agents.models import AgentBaseModel + +from .constants import ( + INSTRUCTION_ACTION, + INSTRUCTION_SYMBOL, + INSTRUCTION_WEIGHT, + ACTION_REDUCE_EXPOSURE, + ACTION_INCREASE_EXPOSURE, + ACTION_ADD_TO_DISTRIBUTION, + ACTION_REMOVE_FROM_DISTRIBUTION, + ACTION_UPDATE_RATIO, +) + + +class AssetDistribution(AgentBaseModel): + """Distribution allocation for a single asset. + + Strict schema enforcement: All fields are required with correct types. + The LLM must return the exact format specified. + """ + __strict_json_schema__ = True + asset: str = Field(description="Asset symbol (e.g., 'BTC', 'ETH', 'USD'). Must be a string.") + percentage: float = Field( + ge=0.0, + le=100.0, + description="Allocation percentage as a number between 0.0 and 100.0. Must be a float.", + validation_alias=AliasChoices("percentage", "target_percentage", "target_allocation", "allocation", "weight", "ratio") + ) + action: str = Field( + description="Action to take. Must be one of: 'increase', 'decrease', 'maintain', 'add', 'remove'." + ) + explanation: str = Field( + description="Explanation for this allocation. Must be a descriptive string explaining the reasoning." + ) + + @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(AgentBaseModel): + """Output from the distribution agent - final portfolio distribution.""" + __strict_json_schema__ = True + + distributions: List[AssetDistribution] = Field( + description="Target distribution for each asset.", + validation_alias=AliasChoices("distributions", "allocations") + ) + 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.""" + instructions = [] + for dist in self.distributions: + if dist.action == "increase": + instructions.append({ + INSTRUCTION_ACTION: ACTION_INCREASE_EXPOSURE, + INSTRUCTION_SYMBOL: dist.asset, + INSTRUCTION_WEIGHT: dist.percentage, + }) + elif dist.action == "decrease": + instructions.append({ + INSTRUCTION_ACTION: ACTION_REDUCE_EXPOSURE, + INSTRUCTION_SYMBOL: dist.asset, + INSTRUCTION_WEIGHT: dist.percentage, + }) + elif dist.action == "add": + instructions.append({ + INSTRUCTION_ACTION: ACTION_ADD_TO_DISTRIBUTION, + INSTRUCTION_SYMBOL: dist.asset, + INSTRUCTION_WEIGHT: dist.percentage, + }) + elif dist.action == "remove": + instructions.append({ + INSTRUCTION_ACTION: ACTION_REMOVE_FROM_DISTRIBUTION, + INSTRUCTION_SYMBOL: dist.asset, + }) + elif dist.action == "maintain": + instructions.append({ + INSTRUCTION_ACTION: ACTION_UPDATE_RATIO, + INSTRUCTION_SYMBOL: dist.asset, + 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..f3d5d493f --- /dev/null +++ b/Agent/Trading/risk_agent/models.py @@ -0,0 +1,75 @@ +# 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 + +from octobot_agents.models import AgentBaseModel + + +class RiskMetrics(AgentBaseModel): + """Portfolio risk metrics.""" + __strict_json_schema__ = True + 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(AgentBaseModel): + """Output from the risk assessment agent.""" + __strict_json_schema__ = True + + 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/Agent/Trading/risk_agent/risk_agent.py b/Agent/Trading/risk_agent/risk_agent.py new file mode 100644 index 000000000..1a9c61e13 --- /dev/null +++ b/Agent/Trading/risk_agent/risk_agent.py @@ -0,0 +1,225 @@ +# 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. + +""" +Risk Assessment Agent. +Evaluates portfolio risk using trading API data. +""" +import json +import typing + +import octobot_agents as agent +from octobot_services.enums import AIModelPolicy + +from tentacles.Agent.Trading.signal_agent.state import AIAgentState +from tentacles.Agent.Trading.signal_agent.models import CryptoSignalOutput +from .models import RiskAssessmentOutput + + +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 producer that evaluates portfolio risk. + Uses portfolio data from trading API to assess concentration, volatility, and liquidity risks. + """ + + AGENT_VERSION = "1.0.0" + AGENT_CHANNEL = RiskAIAgentChannel + AGENT_CONSUMER = RiskAIAgentConsumer + ENABLE_MEMORY = True + MODEL_POLICY = AIModelPolicy.FAST + + def __init__(self, channel, model=None, max_tokens=None, temperature=None, **kwargs): + """ + 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__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + **kwargs, + ) + + 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(): + try: + # Try Pydantic model access + signal_summary[crypto] = { + "action": signal.signal.action, + "confidence": signal.signal.confidence, + "reasoning": signal.signal.reasoning + } + except AttributeError: + # It's a 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, input_data: typing.Any, ai_service) -> typing.Any: + """ + Execute risk assessment. + + Args: + input_data: The current agent state (AIAgentState). + ai_service: The AI service instance. + + Returns: + Dictionary with risk_output. + """ + state = input_data + self.logger.debug(f"Starting {self.name}...") + + try: + messages = [ + {"role": "system", "content": self.prompt}, + {"role": "user", "content": self._build_user_prompt(state)}, + ] + + # Uses RiskAIAgentChannel.OUTPUT_SCHEMA (RiskAssessmentOutput) by default + response_data = await self._call_llm( + messages, + ai_service, + json_output=True, + ) + + # Parse into model + risk_output = RiskAssessmentOutput(**response_data) + + self.logger.debug(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, ai_service, agent_id: str = "risk-agent") -> dict: + """ + Convenience function to run the risk agent. + + Args: + state: The current agent state. + ai_service: The AI service instance. + agent_id: Unique identifier for the agent instance. + + Returns: + State updates from the agent. + """ + risk_agent = RiskAIAgentProducer(channel=None) + return await risk_agent.execute(state, ai_service) diff --git a/Agent/Trading/risk_judge_agent/__init__.py b/Agent/Trading/risk_judge_agent/__init__.py new file mode 100644 index 000000000..a20d475a2 --- /dev/null +++ b/Agent/Trading/risk_judge_agent/__init__.py @@ -0,0 +1,21 @@ +# 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_judge_agent import RiskJudgeAIAgentProducer + +__all__ = [ + "RiskJudgeAIAgentProducer", +] diff --git a/Agent/Trading/risk_judge_agent/metadata.json b/Agent/Trading/risk_judge_agent/metadata.json new file mode 100644 index 000000000..573f61bf2 --- /dev/null +++ b/Agent/Trading/risk_judge_agent/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["RiskJudgeAgent"], + "tentacles-requirements": [] +} diff --git a/Agent/Trading/risk_judge_agent/risk_judge_agent.py b/Agent/Trading/risk_judge_agent/risk_judge_agent.py new file mode 100644 index 000000000..e6cb55c91 --- /dev/null +++ b/Agent/Trading/risk_judge_agent/risk_judge_agent.py @@ -0,0 +1,124 @@ +# 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. + +""" +Risk Judge Agent. +Implements AIJudgeAgentProducer: evaluates risk debate history and decides continue or exit with summary. +""" +import typing + +import octobot_commons.logging as logging +import octobot_agents.models as models +from octobot_agents.enums import JudgeDecisionType +import octobot_services.services.abstract_ai_service as abstract_ai_service + +from octobot_services.enums import AIModelPolicy +from octobot_agents.team.judge import AIJudgeAgentProducer + + +class RiskJudgeAIAgentProducer(AIJudgeAgentProducer): + """ + Risk judge agent: evaluates debate history from risk debators (e.g. risky/safe/neutral) + and decides whether to continue the debate or exit with a risk synthesis summary. + """ + MODEL_POLICY = AIModelPolicy.REASONING + + def __init__( + self, + channel: typing.Optional[typing.Any] = None, + model: typing.Optional[str] = None, + max_tokens: typing.Optional[int] = None, + temperature: typing.Optional[float] = None, + **kwargs, + ): + super().__init__( + channel=channel, + model=model, + max_tokens=max_tokens or 2000, + temperature=temperature if temperature is not None else 0.3, + **kwargs, + ) + self.logger = logging.get_logger(self.__class__.__name__) + + def _get_default_prompt(self) -> str: + return """You are a Risk Judge in a portfolio risk debate. +You receive a debate history: messages from debators (e.g. risky analyst, safe analyst, neutral analyst) arguing about portfolio risk. + +Your role: +1. Evaluate whether the debate has reached a clear conclusion or needs more rounds. +2. If views have converged or max useful exchange reached, decide "exit" and provide a short synthesis summary (risk level and key recommendations). +3. If important points are still unresolved, decide "continue" and briefly explain why. + +Output a JSON object with: +- "decision": exactly "continue" or "exit" +- "reasoning": short explanation for your decision +- "summary": when decision is "exit", a concise risk synthesis (overall risk level and top recommendations); when "continue", null or empty +""" + + async def execute( + self, + input_data: typing.Union[models.JudgeInput, typing.Dict[str, typing.Any]], + ai_service: abstract_ai_service.AbstractAIService, + ) -> models.JudgeDecision: + debate_history = input_data.get("debate_history", []) + debator_agent_names = input_data.get("debator_agent_names", []) + current_round = input_data.get("current_round", 1) + max_rounds = input_data.get("max_rounds", 3) + + if not debate_history: + return models.JudgeDecision( + decision=JudgeDecisionType.EXIT.value, + reasoning="No debate history; exiting.", + summary="No risk debate content.", + ) + + debate_text = "\n\n".join( + f"[Round {e.get('round', '?')}] {e.get('agent_name', '?')}: {e.get('message', '')}" + for e in debate_history + ) + user_content = f"""Debate history (round {current_round} of {max_rounds}, debators: {debator_agent_names}): + +{debate_text} + +Decide: continue the debate or exit with a risk synthesis?""" + + messages = [ + {"role": "system", "content": self._get_default_prompt()}, + {"role": "user", "content": user_content}, + ] + + try: + response = await self._call_llm( + messages, + ai_service, + json_output=True, + response_schema=models.JudgeDecision, + ) + except Exception as e: + self.logger.exception(f"Risk judge LLM call failed: {e}") + return models.JudgeDecision( + decision=JudgeDecisionType.EXIT.value, + reasoning=f"Error: {e}", + summary=None, + ) + + if isinstance(response, dict): + return models.JudgeDecision( + decision=response.get("decision", JudgeDecisionType.EXIT.value), + reasoning=response.get("reasoning", ""), + summary=response.get("summary"), + ) + return response 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..fca86735e --- /dev/null +++ b/Agent/Trading/signal_agent/models.py @@ -0,0 +1,97 @@ +# 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, AliasChoices + +from octobot_agents.models import AgentBaseModel + + +class SignalRecommendation(AgentBaseModel): + """A trading signal recommendation for an asset.""" + __strict_json_schema__ = True + 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(AgentBaseModel): + """Output from a cryptocurrency signal agent.""" + __strict_json_schema__ = True + + 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(AgentBaseModel): + """A synthesized signal for an asset combining multiple signal sources. + + Strict schema enforcement: All fields are required with correct types. + The LLM must return the exact format specified. + """ + __strict_json_schema__ = True + asset: str = Field( + description="The asset symbol (e.g., 'BTC', 'ETH'). Must be a string.", + validation_alias=AliasChoices("asset", "symbol") + ) + direction: Literal["bullish", "bearish", "neutral"] = Field( + description="Synthesized direction: 'bullish', 'bearish', or 'neutral'. Must be one of these exact values." + ) + strength: float = Field( + description="Signal strength as a number between 0.0 and 1.0. Must be a float, NOT a string like 'strong'.", + ge=0.0, + le=1.0 + ) + consensus_level: Literal["strong", "moderate", "weak", "conflicting"] = Field( + description="Level of agreement between signals: 'strong', 'moderate', 'weak', or 'conflicting'. " + "This is different from 'strength' - do NOT confuse them." + ) + trading_instruction: str = Field( + description="Clear trading instruction derived from signals. Must be a descriptive string." + ) + + +class SignalSynthesisOutput(AgentBaseModel): + """Output from the signal manager agent - synthesizes all signals.""" + __strict_json_schema__ = True + + synthesized_signals: List[SynthesizedSignal] = Field( + description="List of synthesized signals per asset.", + validation_alias=AliasChoices("synthesized_signals", "signals") + ) + 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/Agent/Trading/signal_agent/signal_agent.py b/Agent/Trading/signal_agent/signal_agent.py new file mode 100644 index 000000000..3d0705bac --- /dev/null +++ b/Agent/Trading/signal_agent/signal_agent.py @@ -0,0 +1,305 @@ +# 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. + +""" +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 typing import List + +import octobot_agents as agent +from octobot_agents.models import AgentBaseModel +from octobot_services.enums import AIModelPolicy + +from .state import AIAgentState +from .models import CryptoSignalOutput, SignalSynthesisOutput + + +class SignalAgentOutput(AgentBaseModel): + """Output schema for SignalAIAgentProducer.""" + __strict_json_schema__ = True + + 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 producer 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_VERSION = "1.0.0" + AGENT_CHANNEL = SignalAIAgentChannel + AGENT_CONSUMER = SignalAIAgentConsumer + ENABLE_MEMORY = True + MODEL_POLICY = AIModelPolicy.FAST + + def __init__(self, channel, model=None, max_tokens=None, temperature=None, **kwargs): + """ + 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__( + channel=channel, + model=model, + max_tokens=max_tokens, + temperature=temperature, + **kwargs, + ) + + 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 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 +- "bearish": Majority negative signals +- "neutral": Balanced or low conviction +- "mixed": Strong conflicting signals + +## REQUIRED OUTPUT SCHEMA - STRICT ENFORCEMENT + +The "synthesis" object MUST include ALL of these REQUIRED fields: +- "synthesized_signals": Array where each object has ALL of these REQUIRED fields: + * "asset" (string): REQUIRED - Asset symbol like "BTC" or "ETH" + * "direction" (string): REQUIRED - EXACTLY "bullish", "bearish", or "neutral" + * "strength" (number): REQUIRED - Float between 0.0 and 1.0 (NOT a string like "strong") + * "consensus_level" (string): REQUIRED - EXACTLY "strong", "moderate", "weak", or "conflicting" + * "trading_instruction" (string): REQUIRED - Clear trading instruction text +- "market_outlook" (string): REQUIRED - EXACTLY "bullish", "bearish", "neutral", or "mixed" +- "summary" (string): REQUIRED - Summary text + +CRITICAL: Every field is REQUIRED. Do NOT omit any field. "strength" must be a NUMBER, NOT a string. + +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", {}) + 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 + +## REQUIRED OUTPUT FORMAT - STRICT SCHEMA + +The "synthesis" object MUST contain: +- "synthesized_signals": Array of objects, each with ALL of these REQUIRED fields: + * "asset" (string): Asset symbol like "BTC" or "ETH" - REQUIRED + * "direction" (string): EXACTLY one of "bullish", "bearish", "neutral" - REQUIRED + * "strength" (number): Float between 0.0 and 1.0 - REQUIRED (NOT a string like "strong") + * "consensus_level" (string): EXACTLY one of "strong", "moderate", "weak", "conflicting" - REQUIRED + * "trading_instruction" (string): Clear trading instruction text - REQUIRED +- "market_outlook" (string): EXACTLY one of "bullish", "bearish", "neutral", "mixed" - REQUIRED +- "summary" (string): Summary text - REQUIRED + +CRITICAL: Every field above is REQUIRED. Do NOT omit any field. "strength" must be a NUMBER (0.0-1.0), NOT a string. + +Output a JSON object with TWO sections: +- "per_crypto_signals": Array of individual signals +- "synthesis": Overall signal synthesis (with ALL required fields above) + +Remember: Base ONLY on the provided data. Do not make allocation decisions - only synthesize. +""" + + async def execute(self, input_data: typing.Any, ai_service) -> typing.Any: + """ + Execute signal analysis and synthesis. + + Args: + input_data: The current agent state (AIAgentState). + ai_service: The AI service instance. + + Returns: + Dictionary with signal_outputs and signal_synthesis. + """ + state = input_data + self.logger.debug(f"Starting {self.name}...") + + try: + messages = [ + {"role": "system", "content": self.prompt}, + {"role": "user", "content": self._build_user_prompt(state)}, + ] + + # Uses SignalAIAgentChannel.OUTPUT_SCHEMA (SignalAgentOutput) by default + response_data = await self._call_llm( + messages, + ai_service, + json_output=True, + ) + + # 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 with pre-validation normalization + synthesis_data = response_data.get("synthesis", {}) + if synthesis_data: + synthesis_output = SignalSynthesisOutput(**synthesis_data) + else: + synthesis_output = None + + self.logger.debug(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, ai_service, agent_id: str = "signal-agent") -> dict: + """ + Convenience function to run the signal agent. + + Args: + state: The current agent state. + ai_service: The AI service instance. + agent_id: Unique identifier for the agent instance. + + Returns: + State updates from the agent. + """ + signal_agent = SignalAIAgentProducer(channel=None) + return await signal_agent.execute(state, ai_service) diff --git a/Agent/Trading/signal_agent/state.py b/Agent/Trading/signal_agent/state.py new file mode 100644 index 000000000..220e86fce --- /dev/null +++ b/Agent/Trading/signal_agent/state.py @@ -0,0 +1,84 @@ +# 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 Agent State definitions for the trading mode agents. +""" +from typing import Dict, List, Optional, Any +from typing_extensions import Annotated, TypedDict + + +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 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[Any]] + crypto_strategy_data: Dict[str, Dict[str, List[Any]]] # {cryptocurrency: strategy_data} + cryptocurrencies: List[str] + reference_market: str + + # Trading context + portfolio: Dict[str, Any] + orders: Dict[str, Any] + current_distribution: Dict[str, float] # Current portfolio distribution percentages + + # Agent outputs + signal_outputs: Dict[str, Any] + risk_output: Optional[Any] + signal_synthesis: Optional[Any] + distribution_output: Optional[Any] + + # Metadata + exchange_name: str + timestamp: 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/Backtesting/collectors/social/social_history_collector/__init__.py b/Backtesting/collectors/social/social_history_collector/__init__.py new file mode 100644 index 000000000..ea4edb166 --- /dev/null +++ b/Backtesting/collectors/social/social_history_collector/__init__.py @@ -0,0 +1 @@ +from .social_history_collector import SocialHistoryDataCollector diff --git a/Backtesting/collectors/social/social_history_collector/metadata.json b/Backtesting/collectors/social/social_history_collector/metadata.json new file mode 100644 index 000000000..3b3afa5b6 --- /dev/null +++ b/Backtesting/collectors/social/social_history_collector/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["SocialHistoryDataCollector"], + "tentacles-requirements": [] +} diff --git a/Backtesting/collectors/social/social_history_collector/social_history_collector.py b/Backtesting/collectors/social/social_history_collector/social_history_collector.py new file mode 100644 index 000000000..0b235938f --- /dev/null +++ b/Backtesting/collectors/social/social_history_collector/social_history_collector.py @@ -0,0 +1,191 @@ +# 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 logging +import os +import time +import asyncio + +import octobot_backtesting.collectors as collector +import octobot_backtesting.enums as backtesting_enums +import octobot_backtesting.errors as errors +import octobot_commons.constants as commons_constants +import tentacles.Backtesting.importers.social.generic_social_importer as generic_social_importer + +try: + import octobot_services.api as services_api + import octobot_services.errors as services_errors +except ImportError: + logging.error("SocialHistoryDataCollector requires OctoBot-Services package installed") + + +class SocialHistoryDataCollector(collector.AbstractSocialHistoryCollector): + IMPORTER = generic_social_importer.GenericSocialDataImporter + + def __init__(self, config, social_name, tentacles_setup_config, sources=None, symbols=None, + use_all_available_sources=False, + data_format=backtesting_enums.DataFormats.REGULAR_COLLECTOR_DATA, + start_timestamp=None, + end_timestamp=None): + super().__init__(config, social_name, tentacles_setup_config=tentacles_setup_config, + sources=sources, symbols=symbols, + use_all_available_sources=use_all_available_sources, + data_format=data_format, + start_timestamp=start_timestamp, end_timestamp=end_timestamp) + self.tentacles_setup_config = tentacles_setup_config + self.feed_instance = None + + async def start(self): + self.should_stop = False + should_stop_database = True + try: + # Resolve feed class by name (social_name is feed name) + feed_class = self._get_feed_class_by_name(self.social_name) + if feed_class is None: + available = [f.get_name() for f in services_api.get_available_backtestable_feeds()] + raise errors.DataCollectorError( + f"Feed '{self.social_name}' not found. Available feeds: {available}" + ) + + # Create feed instance and ensure required services exist for it + main_loop = asyncio.get_running_loop() + bot_id = "social_collector" + feed_factory = services_api.create_service_feed_factory(self.config, main_loop, bot_id) + self.feed_instance = feed_factory.create_service_feed(feed_class) + if feed_class.REQUIRED_SERVICES: + service_instances = [] + for service_class in feed_class.REQUIRED_SERVICES: + svc = await services_api.get_service( + service_class, is_backtesting=True, config=self.config + ) + service_instances.append(svc) + self.feed_instance.services = service_instances + + self._load_sources_if_necessary() + + await self.check_timestamps() + + # create description + await self._create_description() + + self.total_steps = len(self.sources) * (len(self.symbols) if self.symbols else 1) + if self.total_steps == 0: + self.total_steps = 1 + self.in_progress = True + + self.logger.info(f"Start collecting history on {self.social_name}") + for source_index, source in enumerate(self.sources or [None]): + if self.symbols: + for symbol_index, symbol in enumerate(self.symbols): + self.current_step_index = (source_index * len(self.symbols)) + symbol_index + 1 + self.logger.info(f"Collecting history for {self.social_name} source={source} symbol={symbol}...") + await self.get_social_history(self.social_name, source, symbol) + else: + self.current_step_index = source_index + 1 + self.logger.info(f"Collecting history for {self.social_name} source={source}...") + await self.get_social_history(self.social_name, source, None) + except Exception as err: + await self.database.stop() + should_stop_database = False + # Do not keep errored data file + if os.path.isfile(self.temp_file_path): + os.remove(self.temp_file_path) + if not self.should_stop: + self.logger.exception(err, True, f"Error when collecting {self.social_name} history: {err}") + raise errors.DataCollectorError(err) + finally: + await self.stop(should_stop_database=should_stop_database) + + def _get_feed_class_by_name(self, feed_name): + """Find backtestable feed class by name.""" + feeds = services_api.get_available_backtestable_feeds() + for feed_class in feeds: + if feed_class.get_name() == feed_name or feed_class.get_name().lower() == feed_name.lower(): + return feed_class + return None + + def _load_all_available_sources(self): + # Override this if feed provides available sources + pass + + async def stop(self, should_stop_database=True): + self.should_stop = True + if self.feed_instance is not None: + await self.feed_instance.stop() + if should_stop_database: + await self.database.stop() + self.finalize_database() + self.feed_instance = None + self.in_progress = False + self.finished = True + return self.finished + + async def get_social_history(self, feed_name, source, symbol=None): + self.current_step_percent = 0 + + # Use provided timestamps (required) + start_time = self.start_timestamp + end_time = self.end_timestamp or time.time() * 1000 + + try: + historical_data = self.feed_instance.get_historical_data( + start_time, end_time, symbols=[symbol] if symbol else None, source=source + ) + + all_events = [] + async for batch in historical_data: + if batch: # batch is a list of events + all_events.extend(batch) + # Update progress + if all_events: + last_timestamp = all_events[-1].get('timestamp', time.time() * 1000) + self.current_step_percent = \ + (last_timestamp - start_time) / ((end_time - start_time)) * 100 + self.logger.info( + f"[{self.current_step_percent:.1f}%] historical data fetched for {feed_name} " + f"source={source} symbol={symbol}" + ) + + if all_events: + self.current_step_percent = 100 + self.logger.info( + f"[100%] historical data fetch complete for {feed_name} source={source} symbol={symbol}, saving..." + ) + timestamps = [event.get('timestamp', time.time() * 1000) for event in all_events] + channels = [event.get('channel', source or '') for event in all_events] + symbols_list = [event.get('symbol', symbol or '') for event in all_events] + payloads = [event.get('payload', event) for event in all_events] + + await self.save_event( + timestamp=timestamps, + service_name=feed_name, + channel=channels, + symbol=symbols_list, + payload=payloads, + multiple=True + ) + except NotImplementedError: + self.logger.warning( + f"Feed {feed_name} does not implement get_historical_data. Skipping history collection." + ) + except Exception as err: + self.logger.exception(err, False) + self.logger.warning(f"Ignored {feed_name} history collection ({err})") + + async def check_timestamps(self): + if self.start_timestamp is None: + raise errors.DataCollectorError("start_timestamp is required for social history collection") + if self.start_timestamp > (self.end_timestamp if self.end_timestamp else (time.time() * 1000)): + raise errors.DataCollectorError("start_timestamp is higher than end_timestamp") diff --git a/Backtesting/collectors/social/social_live_collector/__init__.py b/Backtesting/collectors/social/social_live_collector/__init__.py new file mode 100644 index 000000000..205fd786f --- /dev/null +++ b/Backtesting/collectors/social/social_live_collector/__init__.py @@ -0,0 +1 @@ +from .social_live_collector import SocialLiveDataCollector diff --git a/Backtesting/collectors/social/social_live_collector/metadata.json b/Backtesting/collectors/social/social_live_collector/metadata.json new file mode 100644 index 000000000..07dd4d79b --- /dev/null +++ b/Backtesting/collectors/social/social_live_collector/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["SocialLiveDataCollector"], + "tentacles-requirements": [] +} diff --git a/Backtesting/collectors/social/social_live_collector/social_live_collector.py b/Backtesting/collectors/social/social_live_collector/social_live_collector.py new file mode 100644 index 000000000..d083a4ff0 --- /dev/null +++ b/Backtesting/collectors/social/social_live_collector/social_live_collector.py @@ -0,0 +1,165 @@ +# 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 asyncio +import logging +import time + +import octobot_backtesting.collectors as collector +import octobot_backtesting.enums as backtesting_enums +import async_channel.channels as channels + +try: + import octobot_services.api as services_api + import octobot_services.service_feeds as service_feeds + import octobot_services.service_feeds.service_feed_factory as service_feed_factory +except ImportError: + logging.error("SocialLiveDataCollector requires OctoBot-Services package installed") + + +class SocialLiveDataCollector(collector.AbstractSocialLiveCollector): + IMPORTER = None # Live collectors typically don't need importers + + def __init__(self, config, social_name, tentacles_setup_config, sources=None, symbols=None, + service_feed_class=None, channel_name=None, + data_format=backtesting_enums.DataFormats.REGULAR_COLLECTOR_DATA): + super().__init__(config, social_name, sources=sources, symbols=symbols, + data_format=data_format) + self.tentacles_setup_config = tentacles_setup_config + self.service_feed_class = service_feed_class + self.channel_name = channel_name + self.bot_id = "live_collector" # Default bot_id for live collector + self.consumers = [] + + async def start(self): + await self.initialize() + self._load_sources_if_necessary() + + # create description + await self._create_description() + + # Find service feed channels to consume from + feed_channels = await self._get_service_feed_channels() + + if not feed_channels: + self.logger.warning( + f"No service feed channels found for {self.social_name}. " + f"Make sure service feeds are running." + ) + return + + # Subscribe to all found channels + for channel_name, channel in feed_channels.items(): + self.logger.info(f"Subscribing to service feed channel: {channel_name}") + consumer = await channel.new_consumer(self._service_feed_callback) + self.consumers.append(consumer) + + self.logger.info(f"Started collecting live data from {self.social_name}") + # Keep running until stopped + await asyncio.gather(*asyncio.all_tasks(asyncio.get_event_loop())) + + async def _get_service_feed_channels(self): + """Get service feed channels associated with the social service""" + feed_channels = {} + + # If channel_name is provided, use it directly + if self.channel_name: + try: + channel = channels.get_chan(self.channel_name) + feed_channels[self.channel_name] = channel + return feed_channels + except KeyError: + self.logger.warning(f"Channel {self.channel_name} not found") + + # If service_feed_class is provided, use it + if self.service_feed_class: + try: + service_feed = services_api.get_service_feed(self.service_feed_class, self.bot_id) + if service_feed and service_feed.FEED_CHANNEL: + channel_name = service_feed.FEED_CHANNEL.get_name() + channel = channels.get_chan(channel_name) + feed_channels[channel_name] = channel + return feed_channels + except (RuntimeError, KeyError) as err: + self.logger.warning(f"Could not get service feed {self.service_feed_class}: {err}") + + # Try to find service feeds associated with the service name + available_feeds = service_feed_factory.ServiceFeedFactory.get_available_service_feeds(in_backtesting=False) + for feed_class in available_feeds: + # Check if feed name matches social_name or is related + feed_name = feed_class.get_name().lower() + social_name_lower = self.social_name.lower() + if social_name_lower in feed_name or feed_name in social_name_lower: + try: + service_feed = services_api.get_service_feed(feed_class, self.bot_id) + if service_feed and service_feed.FEED_CHANNEL: + channel_name = service_feed.FEED_CHANNEL.get_name() + channel = channels.get_chan(channel_name) + feed_channels[channel_name] = channel + self.logger.info(f"Found matching service feed: {feed_class.get_name()}") + except (RuntimeError, KeyError): + continue + + return feed_channels + + async def _service_feed_callback(self, data): + """Callback for service feed channel messages""" + try: + # Extract data from the channel message + # Service feed channels send: {"data": actual_data} + event_data = data.get("data", data) + + # Extract metadata + service_name = self.social_name + channel = data.get("channel", "") + symbol = event_data.get("symbol") if isinstance(event_data, dict) else None + timestamp = event_data.get("timestamp", time.time() * 1000) if isinstance(event_data, dict) else time.time() * 1000 + + # Prepare payload + if isinstance(event_data, dict): + payload = event_data + else: + payload = {"data": event_data} + + self.logger.info( + f"LIVE EVENT : SERVICE = {service_name} || CHANNEL = {channel} || " + f"SYMBOL = {symbol} || TIMESTAMP = {timestamp}" + ) + + await self.save_event( + timestamp=timestamp, + service_name=service_name, + channel=channel, + symbol=symbol, + payload=payload + ) + except Exception as err: + self.logger.exception(err, False, f"Error processing service feed event: {err}") + + async def stop(self, should_stop_database=True): + self.should_stop = True + + # Stop all consumers + for consumer in self.consumers: + await consumer.stop() + self.consumers = [] + + if should_stop_database: + await self.database.stop() + self.finalize_database() + + self.in_progress = False + self.finished = True + return self.finished diff --git a/Backtesting/importers/social/generic_social_importer/__init__.py b/Backtesting/importers/social/generic_social_importer/__init__.py new file mode 100644 index 000000000..a517152e3 --- /dev/null +++ b/Backtesting/importers/social/generic_social_importer/__init__.py @@ -0,0 +1 @@ +from .generic_social_importer import GenericSocialDataImporter diff --git a/Backtesting/importers/social/generic_social_importer/generic_social_importer.py b/Backtesting/importers/social/generic_social_importer/generic_social_importer.py new file mode 100644 index 000000000..0dcc30403 --- /dev/null +++ b/Backtesting/importers/social/generic_social_importer/generic_social_importer.py @@ -0,0 +1,20 @@ +# Drakkar-Software OctoBot-Backtesting +# 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_backtesting.importers as importers + + +class GenericSocialDataImporter(importers.SocialDataImporter): + pass diff --git a/Backtesting/importers/social/generic_social_importer/metadata.json b/Backtesting/importers/social/generic_social_importer/metadata.json new file mode 100644 index 000000000..1ca5a7602 --- /dev/null +++ b/Backtesting/importers/social/generic_social_importer/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["GenericSocialDataImporter"], + "tentacles-requirements": [] +} diff --git a/Evaluator/Strategies/ai_strategies_evaluator/__init__.py b/Evaluator/Strategies/ai_strategies_evaluator/__init__.py new file mode 100644 index 000000000..f90d2ea3a --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/__init__.py @@ -0,0 +1,5 @@ +from .ai_strategies import ( + BaseLLMAIStrategyEvaluator, + CryptoLLMAIStrategyEvaluator, + GlobalLLMAIStrategyEvaluator +) 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..9cbdd36c5 --- /dev/null +++ b/Evaluator/Strategies/ai_strategies_evaluator/ai_strategies.py @@ -0,0 +1,514 @@ +# 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_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.evaluators as evaluators +import octobot_services.api.services as services_api +import tentacles.Services.Services_bases + +from tentacles.Agent.Teams.simple_ai_evaluator_agents_team import SimpleAIEvaluatorAgentsTeam + + +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.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_ai_service(self): + ai_service = await services_api.get_ai_service( + is_backtesting=self._is_in_backtesting() + ) + if not ai_service: + self.logger.error("AIService not available, cannot perform LLM analysis") + return None + return ai_service + + async def _run_agents_analysis( + self, + aggregated_data: dict, + missing_data_types: list, + ai_service, + ) -> tuple[float | str, str]: + """ + Run strategy agents on aggregated data using the SimpleAIEvaluatorAgentsTeam. + + Returns: + Tuple of (eval_note, eval_note_description). + """ + # 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=ai_service, + model=self.model, + max_tokens=self.max_tokens, + temperature=self.temperature, + include_ta=include_ta, + include_sentiment=include_sentiment, + include_realtime=include_realtime, + ) + + try: + eval_note, eval_note_description = await team.run_with_data( + aggregated_data=aggregated_data, + missing_data_types=missing_data_types, + ) + + 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): + """ + 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 + + ai_service = await self._get_ai_service() + if not ai_service: + self.eval_note = 0 + final_eval_note_description = "Error: AIService 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, ai_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 + + ai_service = await self._get_ai_service() + if not ai_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, ai_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/Evaluator/TA/ai_evaluator/ai.py b/Evaluator/TA/ai_evaluator/ai.py index 567e11404..b91a34ad7 100644 --- a/Evaluator/TA/ai_evaluator/ai.py +++ b/Evaluator/TA/ai_evaluator/ai.py @@ -100,7 +100,7 @@ async def load_and_save_user_inputs(self, bot_id: str) -> dict: :return: the filled user input configuration """ self.is_backtesting = self._is_in_backtesting() - if self.is_backtesting and not _get_gpt_service().BACKTESTING_ENABLED: + if self.is_backtesting and not services_api.is_service_used_by_backtestable_feed(_get_gpt_service()): self.logger.error(f"{self.get_name()} is disabled in backtesting. It will only emit neutral evaluations") await self._init_GPT_models() return await super().load_and_save_user_inputs(bot_id) diff --git a/Services/Interfaces/mcp_interface/__init__.py b/Services/Interfaces/mcp_interface/__init__.py new file mode 100644 index 000000000..4a0bcbe11 --- /dev/null +++ b/Services/Interfaces/mcp_interface/__init__.py @@ -0,0 +1,19 @@ +# Drakkar-Software OctoBot-Interfaces +# 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 .mcp_interface import MCPInterface + +__all__ = ["MCPInterface"] diff --git a/Services/Interfaces/mcp_interface/mcp_interface.py b/Services/Interfaces/mcp_interface/mcp_interface.py new file mode 100644 index 000000000..c516b45ff --- /dev/null +++ b/Services/Interfaces/mcp_interface/mcp_interface.py @@ -0,0 +1,96 @@ +# Drakkar-Software OctoBot-Interfaces +# 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 os +import asyncio + +import octobot_services.constants as services_constants +import octobot_services.interfaces as interfaces + +from .mcp_server import MCPServer + + +class MCPInterface(interfaces.AbstractInterface): + REQUIRED_SERVICES = None + + def __init__(self, config): + super().__init__(config) + self.logger = self.get_logger() + self.mcp_server = None + self.mcp_address = None + self.mcp_port = None + self._server_task = None + + def _init_mcp_settings(self): + """Initialize MCP server settings from config with environment variable overrides.""" + try: + self.mcp_address = os.getenv( + services_constants.ENV_MCP_ADDRESS, + self.config[services_constants.CONFIG_CATEGORY_SERVICES] + [services_constants.CONFIG_MCP][services_constants.CONFIG_MCP_IP] + ) + except KeyError: + self.mcp_address = os.getenv( + services_constants.ENV_MCP_ADDRESS, + services_constants.DEFAULT_MCP_IP + ) + + try: + self.mcp_port = int(os.getenv( + services_constants.ENV_MCP_PORT, + self.config[services_constants.CONFIG_CATEGORY_SERVICES] + [services_constants.CONFIG_MCP][services_constants.CONFIG_MCP_PORT] + )) + except KeyError: + self.mcp_port = int(os.getenv( + services_constants.ENV_MCP_PORT, + services_constants.DEFAULT_MCP_PORT + )) + + async def _inner_start(self) -> bool: + """Start MCP server with HTTP transport.""" + self._init_mcp_settings() + + self.mcp_server = MCPServer() + + # Start HTTP server + self.logger.info(f"Starting MCP server with HTTP transport (address: {self.mcp_address}, port: {self.mcp_port})") + try: + self._server_task = asyncio.create_task(self.mcp_server.start_http(self.mcp_address, self.mcp_port)) + except NotImplementedError as e: + self.logger.error(f"Failed to start HTTP transport: {e}") + return False + + return True + + async def stop(self): + """Stop MCP server.""" + if self.mcp_server: + await self.mcp_server.stop() + if self._server_task: + self._server_task.cancel() + try: + await self._server_task + except asyncio.CancelledError: + pass + self.logger.info("MCP interface stopped") + + def get_connection_info(self): + """Return connection info for LLMService (HTTP URL).""" + return { + "transport": "http", + "url": f"http://{self.mcp_address}:{self.mcp_port}", + "name": "octobot-internal" + } diff --git a/Services/Interfaces/mcp_interface/mcp_server.py b/Services/Interfaces/mcp_interface/mcp_server.py new file mode 100644 index 000000000..c182683f6 --- /dev/null +++ b/Services/Interfaces/mcp_interface/mcp_server.py @@ -0,0 +1,75 @@ +# Drakkar-Software OctoBot-Interfaces +# 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 logging + +MCP_AVAILABLE = False +try: + from mcp.server import Server + MCP_AVAILABLE = True +except ImportError: + MCP_AVAILABLE = False + +from .tools import register_tools + + +class MCPServer: + """MCP server implementation for OctoBot trading tools.""" + + def __init__(self): + self.logger = logging.getLogger(self.__class__.__name__) + self.server = None + self._running = False + + async def _create_server(self): + """Create and configure MCP server with tools.""" + if not MCP_AVAILABLE: + raise ImportError( + "MCP Python SDK not available. Install with: pip install mcp" + ) + + self.server = Server("octobot-mcp") + register_tools(self.server) + return self.server + + async def start_http(self, host: str, port: int): + """Start MCP server with HTTP transport. + + Note: HTTP transport implementation depends on MCP SDK version. + This is a placeholder that will be implemented when HTTP transport + is stable in the MCP SDK. + """ + if not MCP_AVAILABLE: + self.logger.error("MCP Python SDK not available") + return + + self.logger.warning( + f"HTTP transport requested for {host}:{port}, " + "but HTTP server transport is not yet fully implemented. " + "HTTP support will be added when MCP SDK HTTP transport is stable." + ) + + # TODO: Implement HTTP transport when MCP SDK provides stable HTTP server API + # For now, raise an error to indicate HTTP is not yet supported + raise NotImplementedError( + "HTTP transport is not yet implemented." + ) + + async def stop(self): + """Stop MCP server.""" + self._running = False + if self.server: + # Server cleanup if needed + pass diff --git a/Services/Interfaces/mcp_interface/metadata.json b/Services/Interfaces/mcp_interface/metadata.json new file mode 100644 index 000000000..500fff858 --- /dev/null +++ b/Services/Interfaces/mcp_interface/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["MCPInterface"], + "tentacles-requirements": [] +} diff --git a/Services/Interfaces/mcp_interface/tools/__init__.py b/Services/Interfaces/mcp_interface/tools/__init__.py new file mode 100644 index 000000000..5002d78bd --- /dev/null +++ b/Services/Interfaces/mcp_interface/tools/__init__.py @@ -0,0 +1,78 @@ +# Drakkar-Software OctoBot-Interfaces +# 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 .portfolio_tools import ( + get_portfolio_tools_list, + execute_portfolio_tool +) +from .order_tools import ( + get_order_tools_list, + execute_order_tool +) +from .trading import ( + get_trading_tools_list, + execute_trading_tool +) +from .exchange_data_tools import ( + get_exchange_data_tools_list, + execute_exchange_data_tool +) + + +def register_tools(server): + """Register all MCP tools with the server.""" + # Collect all tools + all_tools = [] + all_tools.extend(get_portfolio_tools_list()) + all_tools.extend(get_order_tools_list()) + all_tools.extend(get_trading_tools_list()) + all_tools.extend(get_exchange_data_tools_list()) + + # Register single list_tools handler + @server.list_tools() + async def handle_list_tools() -> list: + """List all available MCP tools.""" + return all_tools + + # Register single call_tool handler + @server.call_tool() + async def handle_call_tool(name: str, arguments: dict) -> list: + """Handle tool calls and route to appropriate handler.""" + try: + # Try portfolio tools + if name in [tool["name"] for tool in get_portfolio_tools_list()]: + result = await execute_portfolio_tool(name, arguments) + return [{"content": [{"type": "text", "text": json.dumps(result)}]}] + + # Try order tools + if name in [tool["name"] for tool in get_order_tools_list()]: + result = await execute_order_tool(name, arguments) + return [{"content": [{"type": "text", "text": json.dumps(result)}]}] + + # Try trading tools + if name in [tool["name"] for tool in get_trading_tools_list()]: + result = await execute_trading_tool(name, arguments) + return [{"content": [{"type": "text", "text": json.dumps(result)}]}] + + # Try exchange data tools + if name in [tool["name"] for tool in get_exchange_data_tools_list()]: + result = await execute_exchange_data_tool(name, arguments) + return [{"content": [{"type": "text", "text": json.dumps(result)}]}] + + return [{"error": f"Unknown tool: {name}"}] + except Exception as e: + return [{"error": str(e)}] diff --git a/Services/Interfaces/mcp_interface/tools/exchange_data_tools.py b/Services/Interfaces/mcp_interface/tools/exchange_data_tools.py new file mode 100644 index 000000000..9b4a280dd --- /dev/null +++ b/Services/Interfaces/mcp_interface/tools/exchange_data_tools.py @@ -0,0 +1,94 @@ +# Drakkar-Software OctoBot-Interfaces +# 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_trading.api as trading_api +import octobot_trading.enums as enums +import octobot_services.interfaces as interfaces + + +def get_exchange_data_tools_list() -> list: + """Get list of exchange data tool definitions.""" + return [ + { + "name": "get_current_price", + "description": "Get the current price of a trading symbol", + "inputSchema": { + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": "Trading symbol (e.g., BTC/USDT)" + }, + "exchange_id": { + "type": "string", + "description": "Optional specific exchange ID to query" + } + }, + "required": ["symbol"] + } + } + ] + + +async def execute_exchange_data_tool(name: str, arguments: dict) -> dict: + """Execute an exchange data tool.""" + if name == "get_current_price": + symbol = arguments.get("symbol") + if not symbol: + return {"error": "symbol parameter is required"} + exchange_id = arguments.get("exchange_id") + return await _get_current_price(symbol, exchange_id) + else: + return {"error": f"Unknown exchange data tool: {name}"} + + +async def _get_current_price(symbol: str, exchange_id: str = None) -> dict: + """Get current price for a symbol.""" + try: + exchange_managers = interfaces.AbstractInterface.get_exchange_managers() + prices = {} + + for exchange_manager in exchange_managers: + if trading_api.is_trader_existing_and_enabled(exchange_manager): + manager_id = trading_api.get_exchange_manager_id(exchange_manager) + + # Filter by exchange_id if provided + if exchange_id and manager_id != exchange_id: + continue + + try: + ticker = await exchange_manager.exchange.get_price_ticker(symbol) + if ticker: + close_price = ticker.get(enums.ExchangeConstantsTickersColumns.CLOSE.value) + if close_price is not None: + prices[manager_id] = float(close_price) + except Exception as e: + # Skip this exchange if ticker fetch fails + # (symbol might not exist on this exchange, or exchange doesn't support ticker) + continue + + if not prices: + return { + "symbol": symbol, + "error": "No price data available for this symbol on any enabled exchange" + } + + return { + "symbol": symbol, + "prices": prices, + "count": len(prices) + } + except Exception as e: + return {"error": str(e)} diff --git a/Services/Interfaces/mcp_interface/tools/order_tools.py b/Services/Interfaces/mcp_interface/tools/order_tools.py new file mode 100644 index 000000000..36c835d7c --- /dev/null +++ b/Services/Interfaces/mcp_interface/tools/order_tools.py @@ -0,0 +1,148 @@ +# Drakkar-Software OctoBot-Interfaces +# 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_trading.api as trading_api +import octobot_services.interfaces as interfaces + + +def get_order_tools_list() -> list: + """Get list of order tool definitions.""" + return [ + { + "name": "get_open_orders", + "description": "Get open orders, optionally filtered by exchange or symbol", + "inputSchema": { + "type": "object", + "properties": { + "exchange_id": { + "type": "string", + "description": "Optional exchange ID to filter orders" + }, + "symbol": { + "type": "string", + "description": "Optional symbol to filter orders (e.g., BTC/USDT)" + } + }, + "required": [] + } + }, + { + "name": "get_order_details", + "description": "Get details for a specific order by ID", + "inputSchema": { + "type": "object", + "properties": { + "order_id": { + "type": "string", + "description": "Order ID" + } + }, + "required": ["order_id"] + } + } + ] + + +async def execute_order_tool(name: str, arguments: dict) -> dict: + """Execute an order tool.""" + if name == "get_open_orders": + exchange_id = arguments.get("exchange_id") + symbol = arguments.get("symbol") + return await _get_open_orders(exchange_id, symbol) + elif name == "get_order_details": + order_id = arguments.get("order_id") + if not order_id: + return {"error": "order_id parameter is required"} + return await _get_order_details(order_id) + else: + return {"error": f"Unknown order tool: {name}"} + + +async def _get_open_orders(exchange_id: str = None, symbol: str = None) -> dict: + """Get open orders.""" + try: + exchange_managers = interfaces.AbstractInterface.get_exchange_managers() + real_orders = [] + simulated_orders = [] + + for exchange_manager in exchange_managers: + if trading_api.is_trader_existing_and_enabled(exchange_manager): + is_simulated = trading_api.is_trader_simulated(exchange_manager) + manager_id = trading_api.get_exchange_manager_id(exchange_manager) + + # Filter by exchange_id if provided + if exchange_id and manager_id != exchange_id: + continue + + orders = trading_api.get_open_orders(exchange_manager) + + # Filter by symbol if provided + if symbol: + orders = [o for o in orders if o.symbol == symbol] + + if is_simulated: + simulated_orders.extend(orders) + else: + real_orders.extend(orders) + + return { + "real": [_format_order(order) for order in real_orders], + "simulated": [_format_order(order) for order in simulated_orders] + } + except Exception as e: + return {"error": str(e)} + + +async def _get_order_details(order_id: str) -> dict: + """Get specific order details.""" + try: + exchange_managers = interfaces.AbstractInterface.get_exchange_managers() + all_orders = [] + + for exchange_manager in exchange_managers: + if trading_api.is_trader_existing_and_enabled(exchange_manager): + # Get open orders + all_orders.extend(trading_api.get_open_orders(exchange_manager)) + # Get all orders (including historical) + all_orders.extend(trading_api.get_all_orders(exchange_manager)) + + # Find order by ID + for order in all_orders: + if order.order_id == order_id or getattr(order, 'exchange_order_id', None) == order_id: + return _format_order(order) + + return {"error": f"Order {order_id} not found"} + except Exception as e: + return {"error": str(e)} + + +def _format_order(order) -> dict: + """Format order for JSON serialization.""" + try: + return { + "order_id": order.order_id, + "exchange_order_id": getattr(order, 'exchange_order_id', None), + "symbol": order.symbol, + "side": order.side.value if hasattr(order.side, 'value') else str(order.side), + "order_type": order.order_type.value if hasattr(order.order_type, 'value') else str(order.order_type), + "status": order.status.value if hasattr(order.status, 'value') else str(order.status), + "price": float(order.price) if order.price else None, + "amount": float(order.amount) if order.amount else None, + "filled": float(order.filled) if hasattr(order, 'filled') and order.filled else None, + "creation_time": float(order.creation_time) if hasattr(order, 'creation_time') else None, + "exchange": trading_api.get_order_exchange_name(order) + } + except Exception as e: + return {"error": f"Error formatting order: {str(e)}"} diff --git a/Services/Interfaces/mcp_interface/tools/portfolio_tools.py b/Services/Interfaces/mcp_interface/tools/portfolio_tools.py new file mode 100644 index 000000000..b2dd711b1 --- /dev/null +++ b/Services/Interfaces/mcp_interface/tools/portfolio_tools.py @@ -0,0 +1,169 @@ +# Drakkar-Software OctoBot-Interfaces +# 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_trading.api as trading_api +import octobot_services.interfaces as interfaces + + +def get_portfolio_tools_list() -> list: + """Get list of portfolio tool definitions.""" + return [ + { + "name": "get_portfolio", + "description": "Get current portfolio holdings across all exchanges", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + }, + { + "name": "get_balance", + "description": "Get balance for a specific currency", + "inputSchema": { + "type": "object", + "properties": { + "currency": { + "type": "string", + "description": "Currency symbol (e.g., BTC, ETH, USDT)" + } + }, + "required": ["currency"] + } + }, + { + "name": "get_portfolio_value", + "description": "Get total portfolio value", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } + ] + + +async def execute_portfolio_tool(name: str, arguments: dict) -> dict: + """Execute a portfolio tool.""" + if name == "get_portfolio": + return await _get_portfolio() + elif name == "get_balance": + currency = arguments.get("currency") + if not currency: + return {"error": "currency parameter is required"} + return await _get_balance(currency) + elif name == "get_portfolio_value": + return await _get_portfolio_value() + else: + return {"error": f"Unknown portfolio tool: {name}"} + + +async def _get_portfolio() -> dict: + """Get full portfolio holdings.""" + try: + exchange_managers = interfaces.AbstractInterface.get_exchange_managers() + real_portfolio = {} + simulated_portfolio = {} + + for exchange_manager in exchange_managers: + if trading_api.is_trader_existing_and_enabled(exchange_manager): + holdings_values = trading_api.get_current_holdings_values(exchange_manager) + target_portfolio = simulated_portfolio if trading_api.is_trader_simulated(exchange_manager) else real_portfolio + + for currency, value in holdings_values.items(): + target_portfolio[currency] = target_portfolio.get(currency, 0) + value + + return { + "real": _format_portfolio(real_portfolio), + "simulated": _format_portfolio(simulated_portfolio) + } + except Exception as e: + return {"error": str(e)} + + +async def _get_balance(currency: str) -> dict: + """Get balance for specific currency.""" + try: + exchange_managers = interfaces.AbstractInterface.get_exchange_managers() + real_available = 0.0 + real_total = 0.0 + simulated_available = 0.0 + simulated_total = 0.0 + + for exchange_manager in exchange_managers: + if trading_api.is_trader_existing_and_enabled(exchange_manager): + portfolio = trading_api.get_portfolio(exchange_manager) + asset = portfolio.get(currency) + + if asset: + available = float(getattr(asset, 'available', 0)) + total = float(getattr(asset, 'total', 0)) + + if trading_api.is_trader_simulated(exchange_manager): + simulated_available += available + simulated_total += total + else: + real_available += available + real_total += total + + return { + "currency": currency, + "real": { + "available": str(real_available), + "total": str(real_total) + }, + "simulated": { + "available": str(simulated_available), + "total": str(simulated_total) + } + } + except Exception as e: + return {"error": str(e)} + + +async def _get_portfolio_value() -> dict: + """Get total portfolio value.""" + try: + exchange_managers = interfaces.AbstractInterface.get_exchange_managers() + real_value = 0.0 + simulated_value = 0.0 + has_real = False + has_simulated = False + + for exchange_manager in exchange_managers: + if trading_api.is_trader_existing_and_enabled(exchange_manager): + current_value = trading_api.get_current_portfolio_value(exchange_manager) or trading_api.get_origin_portfolio_value(exchange_manager) + + if trading_api.is_trader_simulated(exchange_manager): + simulated_value += current_value + has_simulated = True + else: + real_value += current_value + has_real = True + + return { + "has_real_trader": has_real, + "has_simulated_trader": has_simulated, + "real_value": real_value, + "simulated_value": simulated_value, + "total_value": real_value + simulated_value + } + except Exception as e: + return {"error": str(e)} + + +def _format_portfolio(portfolio: dict) -> dict: + """Format portfolio for JSON serialization.""" + return {currency: float(value) for currency, value in portfolio.items()} diff --git a/Services/Interfaces/mcp_interface/tools/trading/__init__.py b/Services/Interfaces/mcp_interface/tools/trading/__init__.py new file mode 100644 index 000000000..a7f702091 --- /dev/null +++ b/Services/Interfaces/mcp_interface/tools/trading/__init__.py @@ -0,0 +1,20 @@ +# Drakkar-Software OctoBot-Interfaces +# 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 .trading_tools import ( + get_trading_tools_list, + execute_trading_tool +) +__all__ = ["get_trading_tools_list", "execute_trading_tool"] diff --git a/Services/Interfaces/mcp_interface/tools/trading/trading_tools.py b/Services/Interfaces/mcp_interface/tools/trading/trading_tools.py new file mode 100644 index 000000000..0305dd374 --- /dev/null +++ b/Services/Interfaces/mcp_interface/tools/trading/trading_tools.py @@ -0,0 +1,121 @@ +# Drakkar-Software OctoBot-Interfaces +# 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_trading.api as trading_api +import octobot_services.interfaces as interfaces + + +def get_trading_tools_list() -> list: + """Get list of trading tool definitions.""" + return [ + { + "name": "get_trades_history", + "description": "Get trade history, optionally filtered by symbol", + "inputSchema": { + "type": "object", + "properties": { + "limit": { + "type": "integer", + "description": "Maximum number of trades to return", + "default": 50 + }, + "symbol": { + "type": "string", + "description": "Optional symbol to filter trades (e.g., BTC/USDT)" + } + }, + "required": [] + } + }, + { + "name": "get_reference_market", + "description": "Get reference market currency", + "inputSchema": { + "type": "object", + "properties": {}, + "required": [] + } + } + ] + + +async def execute_trading_tool(name: str, arguments: dict) -> dict: + """Execute a trading tool.""" + if name == "get_trades_history": + limit = arguments.get("limit", 50) + symbol = arguments.get("symbol") + return await _get_trades_history(limit, symbol) + elif name == "get_reference_market": + return await _get_reference_market() + else: + return {"error": f"Unknown trading tool: {name}"} + + +async def _get_trades_history(limit: int = 50, symbol: str = None) -> dict: + """Get trade history.""" + try: + exchange_managers = interfaces.AbstractInterface.get_exchange_managers() + real_trades = [] + simulated_trades = [] + + for exchange_manager in exchange_managers: + if trading_api.is_trader_existing_and_enabled(exchange_manager): + is_simulated = trading_api.is_trader_simulated(exchange_manager) + trades = trading_api.get_trade_history( + exchange_manager, + quote=None, + symbol=symbol, + since=None, + as_dict=True + ) + + if is_simulated: + simulated_trades.extend(trades) + else: + real_trades.extend(trades) + + # Sort each list by timestamp (newest first) + def get_timestamp(trade): + if isinstance(trade, dict): + return trade.get('timestamp', trade.get('executed_time', 0)) + return getattr(trade, 'timestamp', getattr(trade, 'executed_time', 0)) + + real_trades.sort(key=get_timestamp, reverse=True) + simulated_trades.sort(key=get_timestamp, reverse=True) + + # Limit each list + real_trades = real_trades[:limit] + simulated_trades = simulated_trades[:limit] + + return { + "real": real_trades, + "simulated": simulated_trades, + "count": len(real_trades) + len(simulated_trades) + } + except Exception as e: + return {"error": str(e)} + + +async def _get_reference_market() -> dict: + """Get reference market currency.""" + try: + reference_market = trading_api.get_reference_market( + interfaces.AbstractInterface.bot_api.get_global_config() + ) + return { + "reference_market": reference_market + } + except Exception as e: + return {"error": str(e)} diff --git a/Services/Interfaces/web_interface/__init__.py b/Services/Interfaces/web_interface/__init__.py index 0cfeefb11..00382d624 100644 --- a/Services/Interfaces/web_interface/__init__.py +++ b/Services/Interfaces/web_interface/__init__.py @@ -41,6 +41,7 @@ def register_notifier(notification_key, notifier): GENERAL_NOTIFICATION_KEY = "general_notifications" BACKTESTING_NOTIFICATION_KEY = "backtesting_notifications" DATA_COLLECTOR_NOTIFICATION_KEY = "data_collector_notifications" +SOCIAL_DATA_COLLECTOR_NOTIFICATION_KEY = "social_data_collector_notifications" STRATEGY_OPTIMIZER_NOTIFICATION_KEY = "strategy_optimizer_notifications" DASHBOARD_NOTIFICATION_KEY = "dashboard_notifications" @@ -116,6 +117,10 @@ def send_data_collector_status(**kwargs): _send_notification(DATA_COLLECTOR_NOTIFICATION_KEY, **kwargs) +def send_social_data_collector_status(**kwargs): + _send_notification(SOCIAL_DATA_COLLECTOR_NOTIFICATION_KEY, **kwargs) + + def send_strategy_optimizer_status(**kwargs): _send_notification(STRATEGY_OPTIMIZER_NOTIFICATION_KEY, **kwargs) diff --git a/Services/Interfaces/web_interface/constants.py b/Services/Interfaces/web_interface/constants.py index 6dc86c057..a1406e10b 100644 --- a/Services/Interfaces/web_interface/constants.py +++ b/Services/Interfaces/web_interface/constants.py @@ -44,6 +44,7 @@ # data collector BOT_TOOLS_DATA_COLLECTOR = "data_collector" +BOT_TOOLS_SOCIAL_DATA_COLLECTOR = "social_data_collector" PRODUCT_HUNT_ANNOUNCEMENT = "product_hunt_announcement" PRODUCT_HUNT_ANNOUNCEMENT_DAY = 1720594860 # Wednesday, July 10, 2024 7:01:00 AM UTC diff --git a/Services/Interfaces/web_interface/controllers/backtesting.py b/Services/Interfaces/web_interface/controllers/backtesting.py index dcf86f40d..59bc7ed37 100644 --- a/Services/Interfaces/web_interface/controllers/backtesting.py +++ b/Services/Interfaces/web_interface/controllers/backtesting.py @@ -109,6 +109,60 @@ def backtesting_run_id(): run_id = models.get_latest_backtesting_run_id(trading_mode) return flask.jsonify(run_id) + @blueprint.route("/social_data_collector") + @blueprint.route('/social_data_collector', methods=['GET', 'POST']) + @login.login_required_when_activated + def social_data_collector(): + if not models.is_backtesting_enabled(): + return flask.redirect(flask.url_for("home")) + if flask.request.method == 'POST': + action_type = flask.request.args["action_type"] + success = False + reply = "Action failed" + if action_type == "start_collector": + details = flask.request.get_json() + success, reply = models.collect_social_data_file( + details["social_name"], + details["sources"], + details.get("startTimestamp"), + details.get("endTimestamp") + ) + if success: + web_interface.send_social_data_collector_status() + elif action_type == "stop_collector": + success, reply = models.stop_social_data_collector() + if success: + return util.get_rest_reply(flask.jsonify(reply)) + else: + return util.get_rest_reply(reply, 500) + + elif flask.request.method == 'GET': + if flask.request.args: + action_type_key = "action_type" + if action_type_key in flask.request.args: + target = flask.request.args[action_type_key] + if target == "available_services": + return flask.jsonify(models.get_available_social_services()) + elif target == "service_sources": + service_name = flask.request.args.get('service_name') + return flask.jsonify(models.get_service_sources(service_name)) + elif target == "status": + status, progress = models.get_social_data_collector_status() + return flask.jsonify({"status": status, "progress": progress}) + + # Render the social data collector page + origin_page = None + if flask.request.args: + from_key = "from" + if from_key in flask.request.args: + origin_page = flask.request.args[from_key] + + available_services = models.get_available_social_services() + return flask.render_template('social_data_collector.html', + available_services=available_services, + origin_page=origin_page, + alert={}) + @blueprint.route("/data_collector") @blueprint.route('/data_collector', methods=['GET', 'POST']) diff --git a/Services/Interfaces/web_interface/models/__init__.py b/Services/Interfaces/web_interface/models/__init__.py index 23a7cac39..72a2354d4 100644 --- a/Services/Interfaces/web_interface/models/__init__.py +++ b/Services/Interfaces/web_interface/models/__init__.py @@ -55,6 +55,11 @@ stop_data_collector, collect_data_file, save_data_file, + get_available_social_services, + get_service_sources, + get_social_data_collector_status, + collect_social_data_file, + stop_social_data_collector, ) from tentacles.Services.Interfaces.web_interface.models.commands import ( schedule_delayed_command, @@ -305,6 +310,11 @@ "stop_data_collector", "collect_data_file", "save_data_file", + "get_available_social_services", + "get_service_sources", + "get_social_data_collector_status", + "collect_social_data_file", + "stop_social_data_collector", "schedule_delayed_command", "restart_bot", "is_rebooting", diff --git a/Services/Interfaces/web_interface/models/backtesting.py b/Services/Interfaces/web_interface/models/backtesting.py index 30a90ce54..5b71eebca 100644 --- a/Services/Interfaces/web_interface/models/backtesting.py +++ b/Services/Interfaces/web_interface/models/backtesting.py @@ -36,6 +36,7 @@ import octobot_backtesting.collectors as collectors import octobot_services.interfaces.util as interfaces_util import octobot_services.enums as services_enums +import octobot_services.api as services_api import octobot_trading.constants as trading_constants import octobot_trading.enums as trading_enums import octobot_trading.api as trading_api @@ -44,6 +45,7 @@ import tentacles.Services.Interfaces.web_interface.models.trading as trading_model import tentacles.Services.Interfaces.web_interface.models.profiles as profiles_model import tentacles.Services.Interfaces.web_interface.models.configuration as configuration_model +import tentacles.Backtesting.collectors.social.social_history_collector.social_history_collector STOPPING_TIMEOUT = 30 @@ -371,6 +373,101 @@ def stop_data_collector(): return success, message +def collect_social_data_file(social_name, sources, start_timestamp=None, end_timestamp=None): + """ + Start collecting social data from a backtestable feed. + + :param social_name: Name of the feed (e.g., "CoindeskServiceFeed") + :param sources: List of sources/topics to collect (e.g., ["topic_news", "topic_marketcap"]) + :param start_timestamp: Start timestamp in milliseconds + :param end_timestamp: End timestamp in milliseconds + :return: (success, message) tuple + """ + if not is_backtesting_enabled(): + return False, "Backtesting is disabled." + if not social_name: + return False, "Please select a service." + if not sources: + return False, "Please select at least one source/topic." + if start_timestamp is None: + return False, "Start date is required for social history collection." + if web_interface_root.WebInterface.tools[constants.BOT_TOOLS_SOCIAL_DATA_COLLECTOR] is None or \ + backtesting_api.is_data_collector_finished( + web_interface_root.WebInterface.tools[constants.BOT_TOOLS_SOCIAL_DATA_COLLECTOR]): + _background_collect_social_historical_data(social_name, sources, start_timestamp, end_timestamp) + return True, f"Social data collection started for {social_name}." + else: + return False, f"Can't collect data for {social_name} (Social data collector is already running)" + + +def stop_social_data_collector(): + """Stop the running social data collector.""" + success = False + message = "Failed to stop social data collector" + if web_interface_root.WebInterface.tools[constants.BOT_TOOLS_SOCIAL_DATA_COLLECTOR] is not None: + success = interfaces_util.run_in_bot_main_loop( + backtesting_api.stop_data_collector( + web_interface_root.WebInterface.tools[constants.BOT_TOOLS_SOCIAL_DATA_COLLECTOR] + ) + ) + message = "Social data collector stopped" + web_interface_root.WebInterface.tools[constants.BOT_TOOLS_SOCIAL_DATA_COLLECTOR] = None + return success, message + + +def _background_collect_social_historical_data(social_name, sources, start_timestamp, end_timestamp): + """Start social data collection in a background thread.""" + data_collector_instance = backtesting_api.social_historical_data_collector_factory( + social_name=social_name, + tentacles_setup_config=interfaces_util.get_bot_api().get_edited_tentacles_config(), + sources=sources, + symbols=None, # Usually not applicable for social services + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + config=interfaces_util.get_bot_api().get_edited_config(dict_only=True), + ) + web_interface_root.WebInterface.tools[constants.BOT_TOOLS_SOCIAL_DATA_COLLECTOR] = data_collector_instance + coro = _start_social_collect_and_notify(data_collector_instance) + threading.Thread(target=asyncio.run, args=(coro,), name=f"SocialDataCollector{social_name}").start() + + +def get_available_social_services(): + try: + feeds = services_api.get_available_backtestable_feeds() + return sorted(feed.get_name() for feed in feeds) + except ImportError: + return [] + + +def get_service_sources(service_name): + try: + feeds = services_api.get_available_backtestable_feeds() + for feed_class in feeds: + if feed_class.get_name() == service_name: + return list(feed_class.get_historical_sources()) + return [] + except ImportError: + return [] + + +def get_social_data_collector_status(): + """Get the status of the social data collector.""" + progress = {"current_step": 0, "total_steps": 0, "current_step_percent": 0} + if web_interface_root.WebInterface.tools[constants.BOT_TOOLS_SOCIAL_DATA_COLLECTOR] is not None: + data_collector = web_interface_root.WebInterface.tools[constants.BOT_TOOLS_SOCIAL_DATA_COLLECTOR] + if backtesting_api.is_data_collector_in_progress(data_collector): + current_step, total_steps, current_step_percent = \ + backtesting_api.get_data_collector_progress(data_collector) + progress["current_step"] = current_step + progress["total_steps"] = total_steps + progress["current_step_percent"] = current_step_percent + return "collecting", progress + if backtesting_api.is_data_collector_finished(data_collector): + return "finished", progress + return "starting", progress + return "not started", progress + + def create_snapshot_data_collector(exchange_id, start_timestamp, end_timestamp, profile_id=None): exchange_manager = trading_api.get_exchange_manager_from_exchange_id(exchange_id) symbols = trading_api.get_trading_symbols(exchange_manager) @@ -477,6 +574,18 @@ async def _start_collect_and_notify(data_collector_instance): await web_interface_root.add_notification(notification_level, f"Data collection", message) +async def _start_social_collect_and_notify(data_collector_instance): + success = False + message = "finished" + try: + await backtesting_api.initialize_and_run_data_collector(data_collector_instance) + success = True + except Exception as e: + message = f"error: {e}" + notification_level = services_enums.NotificationLevel.SUCCESS if success else services_enums.NotificationLevel.ERROR + await web_interface_root.add_notification(notification_level, f"Social data collection", message) + + def _background_collect_exchange_historical_data(exchange, exchange_type, symbols, time_frames, start_timestamp, end_timestamp): data_collector_instance = backtesting_api.exchange_historical_data_collector_factory( diff --git a/Services/Interfaces/web_interface/static/js/common/social_data_collector_util.js b/Services/Interfaces/web_interface/static/js/common/social_data_collector_util.js new file mode 100644 index 000000000..ca9d5eed1 --- /dev/null +++ b/Services/Interfaces/web_interface/static/js/common/social_data_collector_util.js @@ -0,0 +1,78 @@ +/* + * 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. + */ + +function lock_social_collector_ui(lock=true){ + if(lock){ + $(`#${socialCollectorMainProgressBar}`).show(); + // reset progress bar + $("#social_total_progess_bar_anim").css('width', 0+'%').attr("aria-valuenow", 0); + }else if(socialCollectorHideProgressBarWhenFinished){ + $(`#${socialCollectorMainProgressBar}`).hide(); + } + $('#collect_social_data').prop('disabled', lock); + $('#stop_collect_social_data').prop('disabled', !lock); +} + +function _refreshSocialDataCollectorStatus(socket){ + socket.emit('social_data_collector_status'); +} + +function updateSocialDataCollectorProgress(current_progress, total_progress){ + if(current_progress === 0){ + $("#social_progress_bar_anim-container").hide(); + }else{ + $("#social_progress_bar_anim-container").show(); + } + $("#social_current_progess_bar_anim").css('width', (current_progress === 0 ? 100 : current_progress)+'%').attr("aria-valuenow", current_progress); + $("#social_total_progess_bar_anim").css('width', total_progress+'%').attr("aria-valuenow", total_progress); +} + +function init_social_data_collector_status_websocket(){ + const socket = get_websocket("/social_data_collector"); + socket.on('social_data_collector_status', function(social_data_collector_status_data) { + _handle_social_data_collector_status(social_data_collector_status_data, socket); + }); +} + +function _handle_social_data_collector_status(social_data_collector_status_data, socket){ + const social_data_collector_status = social_data_collector_status_data["status"]; + const progress_data = social_data_collector_status_data["progress"]; + const current_progress = progress_data["current_step_percent"] || 0; + const total_steps = progress_data["total_steps"] || 1; + const current_step = progress_data["current_step"] || 0; + const total_progress = total_steps > 0 ? Math.round((current_step / total_steps) * 100) : 0; + + if(social_data_collector_status === "collecting" || social_data_collector_status === "starting"){ + lock_social_collector_ui(true); + updateSocialDataCollectorProgress(current_progress, total_progress); + SocialDataCollectorCollectingCallbacks.forEach((callback) => callback()); + // re-schedule progress refresh + setTimeout(function () {_refreshSocialDataCollectorStatus(socket);}, 100); + } + else{ + lock_social_collector_ui(false); + SocialDataCollectorDoneCallbacks.forEach((callback) => callback()); + } + socialCollectorBacktestingStatus = social_data_collector_status; +} + +const SocialDataCollectorDoneCallbacks = []; +const SocialDataCollectorCollectingCallbacks = []; +let socialCollectorBacktestingStatus = undefined; +let socialCollectorHideProgressBarWhenFinished = true; +let socialCollectorMainProgressBar = "social_collector_operation"; diff --git a/Services/Interfaces/web_interface/static/js/components/backtesting.js b/Services/Interfaces/web_interface/static/js/components/backtesting.js index 1025e0eec..b6125a358 100644 --- a/Services/Interfaces/web_interface/static/js/components/backtesting.js +++ b/Services/Interfaces/web_interface/static/js/components/backtesting.js @@ -67,13 +67,15 @@ function handle_file_selection(){ const symbols = checkbox.attr("symbols"); const data_file = checkbox.attr("data-file"); checkbox.prop('checked', true); - // uncheck same symbols from other rows if any - $("#dataFilesTable").find("input[type='checkbox']:checked").each(function(){ - if($(this).attr("symbols") === symbols && !($(this).attr("data-file") === data_file)){ - $(this).closest('tr').removeClass(selected_item_class); - $(this).prop('checked', false); - } - }); + // uncheck same symbols from other rows if any (skip for social files to allow multiple social selection) + if (row_element.attr("data-file-type") !== "social") { + $("#dataFilesTable").find("input[type='checkbox']:checked").each(function(){ + if($(this).attr("symbols") === symbols && !($(this).attr("data-file") === data_file)){ + $(this).closest('tr').removeClass(selected_item_class); + $(this).prop('checked', false); + } + }); + } } if($("#dataFilesTable").find("input[type='checkbox']:checked").length > 1){ syncDataOnlyDiv.removeClass(hidden_class); diff --git a/Services/Interfaces/web_interface/static/js/components/social_data_collector.js b/Services/Interfaces/web_interface/static/js/components/social_data_collector.js new file mode 100644 index 000000000..a04ad64c0 --- /dev/null +++ b/Services/Interfaces/web_interface/static/js/components/social_data_collector.js @@ -0,0 +1,177 @@ +/* + * 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. + */ + +function start_social_collector(){ + lock_social_collector_ui(); + const request = {}; + request["social_name"] = $('#socialNameSelect').val(); + request["sources"] = $('#sourcesSelect').val(); + request["startTimestamp"] = $("#startDate").val() ? new Date($("#startDate").val()).getTime() : null; + request["endTimestamp"] = $("#endDate").val() ? new Date($("#endDate").val()).getTime() : null; + const update_url = $("#collect_social_data").attr(update_url_attr); + send_and_interpret_bot_update(request, update_url, $(this), social_collector_success_callback, social_collector_error_callback); +} + +function stop_social_collector(){ + const update_url = $("#stop_collect_social_data").attr(update_url_attr); + send_and_interpret_bot_update({}, update_url, $(this), social_collector_success_callback, social_collector_error_callback); +} + +function social_collector_success_callback(updated_data, update_url, dom_root_element, msg, status){ + create_alert("success", msg, ""); +} + +function social_collector_error_callback(updated_data, update_url, dom_root_element, result, status, error){ + create_alert("error", result.responseText, ""); + lock_social_collector_ui(false); +} + +function check_social_date_input(){ + const startDate = new Date($("#startDate").val()); + const enddate = new Date($("#endDate").val()); + const startDateMax = new Date( $("#startDate")[0].max); + const endDateMin = new Date( $("#endDate")[0].min); + if(isNaN(startDate)){ + create_alert("error", "Start date is required for social history collection.", ""); + return false; + } + if((!isNaN(startDate) && startDate > startDateMax) || (!isNaN(enddate) && enddate < endDateMin)){ + create_alert("error", "Invalid date range.", ""); + return false; + } + return true; +} + +function update_available_services_list(url){ + $.get(url, {}, function(data, status){ + const serviceSelect = $("#socialNameSelect"); + serviceSelect.empty(); // remove old options + const serviceSelectBox = serviceSelect[0]; + $.each(data, function(key,value) { + serviceSelectBox.append(new Option(value,value)); + }); + if (data && data.length > 0) { + serviceSelect.val(data[0]); + } + serviceSelect.selectpicker('refresh'); + serviceSelect.trigger('change'); + }); +} + +function update_sources_list(service_name){ + const sourcesSelect = $("#sourcesSelect"); + sourcesSelect.empty(); + const sourcesSelectBox = sourcesSelect[0]; + + // Fetch service-specific sources from backend + const url = $("#sourcesSelect").attr(update_url_attr); + if (url && service_name) { + $.get(url, {service_name: service_name}, function(data, status){ + if (data && data.length > 0) { + $.each(data, function(key,value) { + sourcesSelectBox.append(new Option(value,value)); + }); + } else { + // Fallback to common sources if service doesn't provide specific ones + const commonSources = ["topic_news", "topic_marketcap"]; + $.each(commonSources, function(key,value) { + sourcesSelectBox.append(new Option(value,value)); + }); + } + sourcesSelect.trigger('change'); + }); + } else { + // Fallback to common sources + const commonSources = ["topic_news", "topic_marketcap"]; + $.each(commonSources, function(key,value) { + sourcesSelectBox.append(new Option(value,value)); + }); + sourcesSelect.trigger('change'); + } +} + +function handleSocialSelects(){ + createSocialSelect2(); + + // Load available services on page load + update_available_services_list($('#socialNameSelect').attr(update_url_attr)); + + $('#socialNameSelect').on('change', function() { + update_sources_list($('#socialNameSelect').val()); + }); + + $('#collect_social_data').click(function(){ + if(check_social_date_input()){ + start_social_collector(); + } + }); + + $('#stop_collect_social_data').click(function(){ + stop_social_collector(); + }); + + $("#endDate").on('change', function(){ + let endDate = new Date(this.value); + if(!isNaN(endDate)){ + const endDateMax = new Date(); + endDateMax.setDate(endDateMax.getDate() - 1); + endDate.setDate(endDate.getDate() - 1); + if(endDate > endDateMax){ + this.value = endDateMax.toISOString().split("T")[0]; + endDate = endDateMax; + } + $("#startDate")[0].max = endDate.toISOString().split("T")[0]; + } + }); + + $("#startDate").on('change', function(){ + const startDate = new Date(this.value); + if(!isNaN(startDate)){ + const startDateMax = new Date(); + startDateMax.setDate(startDateMax.getDate() - 2); + startDate.setDate(startDate.getDate() + 1); + $("#endDate")[0].min = startDate.toISOString().split("T")[0]; + } + }); + + const endDateMax = new Date(); + endDateMax.setDate(endDateMax.getDate() - 1); + $("#endDate")[0].max = endDateMax.toISOString().split("T")[0]; + const startDateMax = new Date(); + startDateMax.setDate(startDateMax.getDate() - 2); + $("#startDate")[0].max = startDateMax.toISOString().split("T")[0]; +} + +function createSocialSelect2(){ + $("#sourcesSelect").select2({ + closeOnSelect: false, + placeholder: "Sources/Topics" + }); +} + +$(document).ready(function() { + $('#collect_social_data').attr('disabled', true); + $('#stop_collect_social_data').attr('disabled', true); + // Always show date range for social collectors (timestamps are required) + $("#social_collector_date_range").show(); + handleSocialSelects(); + SocialDataCollectorDoneCallbacks.push(function() { + // Reload or update UI when collection is done + }); + init_social_data_collector_status_websocket(); +}); diff --git a/Services/Interfaces/web_interface/templates/backtesting.html b/Services/Interfaces/web_interface/templates/backtesting.html index 45294cdac..26f4741af 100644 --- a/Services/Interfaces/web_interface/templates/backtesting.html +++ b/Services/Interfaces/web_interface/templates/backtesting.html @@ -27,6 +27,11 @@

Backtesting class="btn btn-outline-info waves-effect"> Get historical data + + Download social data + @@ -44,7 +49,7 @@

Backtesting

{% for file, description in data_files %} - + + {% if description.data_type == "social" %} + + {% else %} + {% endif %} {% else %} @@ -122,6 +131,7 @@

Get historical data + Download social data
{% endif %} diff --git a/Services/Interfaces/web_interface/templates/data_collector.html b/Services/Interfaces/web_interface/templates/data_collector.html index ed011754d..8af886481 100644 --- a/Services/Interfaces/web_interface/templates/data_collector.html +++ b/Services/Interfaces/web_interface/templates/data_collector.html @@ -163,11 +163,15 @@ {% for file, description in data_files %} - + {% if description.start_timestamp %} + {% if description.data_type == "social" %} + + {% else %} + {% endif %} {% else %} diff --git a/Services/Interfaces/web_interface/templates/social_data_collector.html b/Services/Interfaces/web_interface/templates/social_data_collector.html new file mode 100644 index 000000000..9b4bccfc2 --- /dev/null +++ b/Services/Interfaces/web_interface/templates/social_data_collector.html @@ -0,0 +1,122 @@ +{% extends "layout.html" %} +{% set active_page = "backtesting" %} +{% block body %} + +
+ +
+

+ + + + + +  Download social data +

+
+
+
+
+
+ Service : +
+
+ +
+
+
+
+
+
+
+ Start Date : +
+
+ +
+
+
+
+
+
+ End Date : +
+
+ +
+
+
+
+
+
+
+ Source(s) : +
+
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+ +
+ +
+{% endblock %} + +{% block additional_scripts %} + + +{% if alert %} + +{% endif %} +{% endblock additional_scripts %} diff --git a/Services/Interfaces/web_interface/web.py b/Services/Interfaces/web_interface/web.py index d5b6c4875..592a50f3c 100644 --- a/Services/Interfaces/web_interface/web.py +++ b/Services/Interfaces/web_interface/web.py @@ -58,6 +58,7 @@ class WebInterface(services_interfaces.AbstractWebInterface): constants.BOT_TOOLS_BACKTESTING_SOURCE: None, constants.BOT_TOOLS_STRATEGY_OPTIMIZER: None, constants.BOT_TOOLS_DATA_COLLECTOR: None, + constants.BOT_TOOLS_SOCIAL_DATA_COLLECTOR: None, constants.BOT_PREPARING_BACKTESTING: False, } diff --git a/Services/Interfaces/web_interface/websockets/__init__.py b/Services/Interfaces/web_interface/websockets/__init__.py index 0afa8ccd4..e4a057808 100644 --- a/Services/Interfaces/web_interface/websockets/__init__.py +++ b/Services/Interfaces/web_interface/websockets/__init__.py @@ -25,6 +25,7 @@ ) from tentacles.Services.Interfaces.web_interface.websockets import data_collector +from tentacles.Services.Interfaces.web_interface.websockets import social_data_collector from tentacles.Services.Interfaces.web_interface.websockets import backtesting from tentacles.Services.Interfaces.web_interface.websockets import dashboard from tentacles.Services.Interfaces.web_interface.websockets import notifications @@ -34,6 +35,9 @@ from tentacles.Services.Interfaces.web_interface.websockets.data_collector import ( DataCollectorNamespace, ) +from tentacles.Services.Interfaces.web_interface.websockets.social_data_collector import ( + SocialDataCollectorNamespace, +) from tentacles.Services.Interfaces.web_interface.websockets.backtesting import ( BacktestingNamespace, ) @@ -53,6 +57,7 @@ "websocket_with_login_required_when_activated", "BacktestingNamespace", "DataCollectorNamespace", + "SocialDataCollectorNamespace", "DashboardNamespace", "NotificationsNamespace", "StrategyOptimizerNamespace", diff --git a/Services/Interfaces/web_interface/websockets/social_data_collector.py b/Services/Interfaces/web_interface/websockets/social_data_collector.py new file mode 100644 index 000000000..11b936f30 --- /dev/null +++ b/Services/Interfaces/web_interface/websockets/social_data_collector.py @@ -0,0 +1,52 @@ +# 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 flask_socketio + +import tentacles.Services.Interfaces.web_interface as web_interface +import tentacles.Services.Interfaces.web_interface.models as models +import tentacles.Services.Interfaces.web_interface.websockets as websockets + + +class SocialDataCollectorNamespace(websockets.AbstractWebSocketNamespaceNotifier): + + @staticmethod + def _get_social_data_collector_status(): + social_data_collector_status, progress = models.get_social_data_collector_status() + return {"status": social_data_collector_status, "progress": progress} + + @websockets.websocket_with_login_required_when_activated + def on_social_data_collector_status(self): + flask_socketio.emit("social_data_collector_status", self._get_social_data_collector_status()) + + def all_clients_send_notifications(self, **kwargs) -> bool: + if self._has_clients(): + try: + self.socketio.emit("social_data_collector_status", self._get_social_data_collector_status(), namespace=self.namespace) + return True + except Exception as e: + self.logger.exception(e, True, f"Error when sending social_data_collector_status: {e}") + return False + + @websockets.websocket_with_login_required_when_activated + def on_connect(self): + super().on_connect() + self.on_social_data_collector_status() + + +notifier = SocialDataCollectorNamespace('/social_data_collector') +web_interface.register_notifier(web_interface.SOCIAL_DATA_COLLECTOR_NOTIFICATION_KEY, notifier) +websockets.namespaces.append(notifier) diff --git a/Services/Services_bases/coindesk_service/coindesk.py b/Services/Services_bases/coindesk_service/coindesk.py index c1f5b74a7..ab0f9b8f5 100644 --- a/Services/Services_bases/coindesk_service/coindesk.py +++ b/Services/Services_bases/coindesk_service/coindesk.py @@ -13,11 +13,21 @@ # # 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_services.constants as services_constants import octobot_services.services as services +import tentacles.Services.Services_bases.coindesk_service.models as coindesk_models + -class CoindeskService(services.AbstractService): +class CoindeskService(services.AbstractService): + API_RATE_LIMIT_SECONDS = 10 + @staticmethod def is_setup_correctly(config): return True @@ -40,3 +50,147 @@ async def prepare(self) -> None: def get_successful_startup_message(self): return "", True + + def _get_coindesk_language(self): + """Get language from config""" + return self.config.get(services_constants.CONFIG_COINDESK_LANGUAGE, "en") + + def _datetime_to_ms_timestamp(self, dt): + """Convert datetime to milliseconds timestamp""" + # Handle datetime object (most common case) + if isinstance(dt, datetime.datetime): + # Convert to UTC if timezone-naive + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + return int(dt.timestamp() * 1000) + + # Handle Unix timestamp (seconds or milliseconds) + if isinstance(dt, (int, float)): + return int(dt * 1000) if dt < 1e10 else int(dt) + + # Handle datetime string (fallback) + if isinstance(dt, str): + try: + # Try ISO format + dt_obj = datetime.datetime.fromisoformat(dt.replace('Z', '+00:00')) + if dt_obj.tzinfo is None: + dt_obj = dt_obj.replace(tzinfo=datetime.timezone.utc) + return int(dt_obj.timestamp() * 1000) + except ValueError: + self.logger.warning(f"Could not parse datetime: {dt}") + return None + + return None + + def _convert_news_to_event(self, news_item: coindesk_models.CoindeskNews, source: str) -> dict: + """Convert CoindeskNews dataclass to event dict""" + timestamp_ms = self._datetime_to_ms_timestamp(news_item.published_on) + if timestamp_ms is None: + return None + + return { + "timestamp": timestamp_ms, + "payload": dataclasses.asdict(news_item), + "channel": source or services_constants.COINDESK_TOPIC_NEWS, + "symbol": "" + } + + def _convert_marketcap_to_event(self, marketcap_item: coindesk_models.CoindeskMarketcap, source: str) -> dict: + """Convert CoindeskMarketcap dataclass to event dict""" + timestamp_ms = self._datetime_to_ms_timestamp(marketcap_item.timestamp) + if timestamp_ms is None: + return None + + return { + "timestamp": timestamp_ms, + "payload": dataclasses.asdict(marketcap_item), + "channel": source or services_constants.COINDESK_TOPIC_MARKETCAP, + "symbol": "" + } + + def _get_news_api_url(self, limit: int = 1000): + """Get news API URL""" + lang = self._get_coindesk_language() + return f"https://data-api.coindesk.com/news/v1/article/list?lang={lang}&limit={limit}" + + def _get_marketcap_api_url(self, limit: int = 2000): + """Get marketcap API URL""" + return f"https://data-api.coindesk.com/overview/v1/historical/marketcap/all/assets/days?limit={limit}&response_format=JSON" + + async def _fetch_news_batch(self, session: aiohttp.ClientSession, limit: int = 1000) -> list: + """Fetch a batch of news articles""" + try: + async with session.get(self._get_news_api_url(limit)) as response: + if response.status != 200: + self.logger.error(f"Coindesk news API request failed with status: {response.status}") + return [] + + news_data = await response.json() + articles = news_data.get("Data", []) + + if not articles: + return [] + + 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(coindesk_models.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 + )) + return values + except Exception as e: + self.logger.exception(e, True, f"Error fetching Coindesk news: {e}") + return [] + + async def _fetch_marketcap_batch(self, session: aiohttp.ClientSession, limit: int = 2000) -> list: + """Fetch a batch of marketcap data""" + try: + async with session.get(self._get_marketcap_api_url(limit)) as response: + if response.status != 200: + self.logger.error(f"Coindesk marketcap API request failed with status: {response.status}") + return [] + + market_cap_data = await response.json() + entries = market_cap_data.get("Data", []) + + if not entries: + return [] + + values = [] + for entry in entries: + values.append(coindesk_models.CoindeskMarketcap( + timestamp=entry["TIMESTAMP"], + open=entry["OPEN"], + close=entry["CLOSE"], + high=entry["HIGH"], + low=entry["LOW"], + top_tier_volume=entry["TOP_TIER_VOLUME"] + )) + return values + except Exception as e: + self.logger.exception(e, True, f"Error fetching Coindesk marketcap: {e}") + return [] + diff --git a/Services/Services_bases/coindesk_service/models.py b/Services/Services_bases/coindesk_service/models.py new file mode 100644 index 000000000..8a8d66d4b --- /dev/null +++ b/Services/Services_bases/coindesk_service/models.py @@ -0,0 +1,50 @@ +# 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 datetime +import dataclasses + +@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 \ No newline at end of file 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 70a8cb86c..47b62b9a3 100644 --- a/Services/Services_bases/gpt_service/gpt.py +++ b/Services/Services_bases/gpt_service/gpt.py @@ -20,10 +20,23 @@ import openai import logging import datetime +import json + +from openai.lib._pydantic import to_strict_json_schema + +MCP_AVAILABLE = False +try: + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + MCP_AVAILABLE = True +except ImportError: + MCP_AVAILABLE = False import octobot_services.constants as services_constants import octobot_services.services as services import octobot_services.errors as errors +import octobot_services.interfaces.util as interfaces_util +from octobot_services.services.abstract_ai_service import AbstractAIService import octobot_commons.constants as commons_constants import octobot_commons.enums as commons_enums @@ -41,158 +54,920 @@ "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 + # Empty for now - gpt-5 base models should be checked via _is_of_series instead + # This list is for exact model name prefixes only ] 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 GPTService(services.AbstractService): + +class LLMService(services.AbstractAIService): BACKTESTING_ENABLED = True - DEFAULT_MODEL = "gpt-3.5-turbo" + + DEFAULT_MODEL = None NO_TOKEN_LIMIT_VALUE = -1 + HTTP_TIMEOUT = 300.0 # HTTP client timeout in seconds def get_fields_description(self): if self._env_secret_key is None: - return { + fields = { 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_MODEL_FAST: ( + "Model for 'fast' policy (e.g. analysts, debators). Leave empty to use main model." + ), + services_constants.CONFIG_LLM_MODEL_REASONING: ( + "Model for 'reasoning' policy (e.g. judge, distribution). Leave empty to use main model." + ), + 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." + ), + 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." + ), + services_constants.CONFIG_LLM_MCP_SERVERS: ( + "List of MCP (Model Context Protocol) server configurations. " + "Each server config should include: name (optional), transport ('stdio', 'http', or 'sse'), " + "and either 'command'/'args' for stdio or 'url' for http/sse. " + "Example: [{'name': 'filesystem', 'transport': 'stdio', 'command': 'npx', " + "'args': ['-y', '@modelcontextprotocol/server-filesystem']}]" + ), + services_constants.CONFIG_LLM_AUTO_INJECT_MCP_TOOLS: ( + "Automatically inject discovered MCP tools into all completions. " + "When enabled, tools from configured MCP servers are automatically available. " + "Default: True." + ), } + return fields return {} def get_default_value(self): if self._env_secret_key is None: return { - 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_MODEL_FAST: "", + services_constants.CONFIG_LLM_MODEL_REASONING: "", + 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: "", + services_constants.CONFIG_LLM_MCP_SERVERS: [], + services_constants.CONFIG_LLM_AUTO_INJECT_MCP_TOOLS: True, } 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 + + self._client: typing.Optional[openai.AsyncOpenAI] = None + + # MCP discovered tools cache + self._mcp_tools: list[dict] = [] + self._mcp_clients: list[typing.Any] = [] + self._octobot_mcp_tools: typing.Optional[list] = 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_models_config(self): + """Load fast/reasoning model config for policy-based model selection.""" + self.models_config = {} + try: + svc_config = self.config[services_constants.CONFIG_CATEGORY_SERVICES].get(self.get_type()) or {} + fast_model = svc_config.get(services_constants.CONFIG_LLM_MODEL_FAST) + reasoning_model = svc_config.get(services_constants.CONFIG_LLM_MODEL_REASONING) + if fast_model and not fields_utils.has_invalid_default_config_value(fast_model): + self.models_config["fast"] = fast_model + if reasoning_model and not fields_utils.has_invalid_default_config_value(reasoning_model): + self.models_config["reasoning"] = reasoning_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 + + 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 + + def _should_auto_inject_mcp_tools(self) -> bool: + """Check if MCP tools should be auto-injected.""" + try: + config_auto_inject = self.config[services_constants.CONFIG_CATEGORY_SERVICES][ + self.get_type() + ].get(services_constants.CONFIG_LLM_AUTO_INJECT_MCP_TOOLS) + if config_auto_inject is not None: + if isinstance(config_auto_inject, str): + return config_auto_inject.lower() in ("true", "1", "yes") + return bool(config_auto_inject) + except (KeyError, TypeError): + pass + return True # Default to True + + def _load_mcp_servers_from_config(self) -> list: + """Load MCP server configurations from config. + + Returns: + List of MCP server configuration dicts + """ + try: + mcp_servers = self.config[services_constants.CONFIG_CATEGORY_SERVICES][ + self.get_type() + ].get(services_constants.CONFIG_LLM_MCP_SERVERS) + if mcp_servers is not None: + # Try to convert to list using duck typing + try: + return list(mcp_servers) + except TypeError: + pass + except (KeyError, TypeError): + pass + return [] + + def _extract_tool_attr(self, tool, attr_name: str, default=''): + """Extract attribute from tool (object or dict) using duck typing. + + Args: + tool: Tool object (dict or object with attributes) + attr_name: Name of the attribute to extract + default: Default value if attribute not found + + Returns: + Attribute value or default + """ + try: + return tool.get(attr_name, default) + except AttributeError: + try: + return getattr(tool, attr_name, default) + except AttributeError: + return default + + def _extract_tools_list(self, tools_result): + """Extract tools list from various response formats using duck typing. + + Args: + tools_result: Response from list_tools() (object, dict, or list) + + Returns: + List of tools + """ + try: + return tools_result.tools + except AttributeError: + pass + + try: + return tools_result['tools'] + except (TypeError, KeyError): + pass + + try: + return list(tools_result) + except TypeError: + pass + + return [] + + def _convert_mcp_tool_to_openai(self, mcp_tool: dict) -> dict: + """Convert MCP tool format to OpenAI function format. + + Args: + mcp_tool: MCP tool dict with 'name', 'description', 'inputSchema' + + Returns: + OpenAI tool format dict with 'type' and 'function' keys + """ + tool_name = mcp_tool.get("name", "") + tool_description = mcp_tool.get("description", "") + input_schema = mcp_tool.get("inputSchema", {}) + parameters = input_schema.copy() if input_schema else {} + + return { + "type": "function", + "function": { + "name": tool_name, + "description": tool_description, + "parameters": parameters, + } + } + + async def _discover_mcp_tools(self) -> list: + """Discover tools from configured MCP servers. + + Returns: + List of OpenAI-formatted tool definitions + """ + if not MCP_AVAILABLE: + self.logger.warning( + "MCP Python SDK not available. Install with: pip install mcp" + ) + return [] + + mcp_servers = self._load_mcp_servers_from_config() + if not mcp_servers: + return [] + + discovered_tools = [] + + for server_config in mcp_servers: + server_name = server_config.get("name", "unknown") + transport = server_config.get("transport", "stdio") + + try: + if transport != "stdio": + # Only stdio transport is currently supported + if transport in ("http", "sse"): + self.logger.warning( + f"MCP server '{server_name}': {transport} transport not yet implemented. " + "Only stdio transport is currently supported." + ) + else: + self.logger.warning( + f"MCP server '{server_name}': Unknown transport '{transport}'. " + "Supported: 'stdio', 'http', 'sse'" + ) + continue + + # Handle stdio transport + command = server_config.get("command") + if not command: + self.logger.warning( + f"MCP server '{server_name}': stdio transport requires 'command'" + ) + continue + + # Ensure args is a list + args = server_config.get("args", []) + try: + args = list(args) + except TypeError: + args = [] + + # Create stdio server parameters + server_params = StdioServerParameters( + command=command, + args=args, + ) + + # Connect and discover tools + async with stdio_client(server_params) as (read, write): + async with ClientSession(read, write) as session: + await session.initialize() + + # List available tools + tools_result = await session.list_tools() + + # Extract tools list using duck typing + tools_list = self._extract_tools_list(tools_result) + + for mcp_tool in tools_list: + # Extract tool information using duck typing + tool_name = self._extract_tool_attr(mcp_tool, 'name', '') + if not tool_name: + continue + + tool_description = self._extract_tool_attr(mcp_tool, 'description', '') + tool_schema = self._extract_tool_attr(mcp_tool, 'inputSchema', {}) + + openai_tool = self._convert_mcp_tool_to_openai({ + "name": tool_name, + "description": tool_description or "", + "inputSchema": tool_schema or {}, + }) + discovered_tools.append(openai_tool) + self.logger.debug( + f"Discovered MCP tool '{tool_name}' from server '{server_name}'" + ) + + except Exception as err: + self.logger.warning( + f"Failed to discover tools from MCP server '{server_name}': {err}. " + "Continuing without tools from this server." + ) + continue + + if discovered_tools: + self.logger.info( + f"Discovered {len(discovered_tools)} tools from {len(mcp_servers)} MCP server(s)" + ) + + return discovered_tools + + async def _discover_octobot_mcp_tools(self) -> list: + if self._octobot_mcp_tools is not None: + return self._octobot_mcp_tools + + if not MCP_AVAILABLE: + self.logger.warning( + "MCP Python SDK not available. Install with: pip install mcp" + ) + discovered_tools = [] + self._octobot_mcp_tools = discovered_tools + return discovered_tools + + try: + import tentacles.Services.Interfaces.mcp_interface.mcp_interface as mcp_interface_module + MCPInterface = mcp_interface_module.MCPInterface + + bot_api = interfaces_util.get_bot_api() + if not bot_api: + self.logger.warning("bot_api not available, cannot discover OctoBot MCP tools") + discovered_tools = [] + self._octobot_mcp_tools = discovered_tools + return discovered_tools + + mcp_interface = bot_api.get_interface(MCPInterface) + if not mcp_interface: + self.logger.debug("MCPInterface not found or not running") + discovered_tools = [] + self._octobot_mcp_tools = discovered_tools + return discovered_tools + + connection_info = mcp_interface.get_connection_info() + transport = connection_info.get("transport") + + if transport != "http": + self.logger.warning(f"Unsupported transport type: {transport}. Only HTTP transport is supported.") + discovered_tools = [] + self._octobot_mcp_tools = discovered_tools + return discovered_tools + + # For HTTP transport, we can connect + url = connection_info.get("url") + if not url: + self.logger.warning("MCP interface HTTP URL not available") + discovered_tools = [] + self._octobot_mcp_tools = discovered_tools + return discovered_tools + + # Try to connect via HTTP and discover tools + # Note: HTTP transport support may vary by MCP SDK version + self.logger.warning( + f"HTTP transport for OctoBot MCP not yet fully implemented. " + f"URL: {url}. " + "HTTP client connection needs to be implemented." + ) + discovered_tools = [] + self._octobot_mcp_tools = discovered_tools + return discovered_tools + + except ImportError: + self.logger.debug("MCPInterface not available (interface may not be installed)") + discovered_tools = [] + self._octobot_mcp_tools = discovered_tools + return discovered_tools + except Exception as err: + self.logger.warning( + f"Failed to discover OctoBot MCP tools: {err}. " + "Continuing without OctoBot MCP tools." + ) + discovered_tools = [] + self._octobot_mcp_tools = discovered_tools + return discovered_tools @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( + @staticmethod + def handle_tool_calls( + tool_calls: typing.List[dict], + tool_executor: typing.Callable[[str, dict], typing.Any], + ) -> typing.List[dict]: + """ + Execute tool calls and format results for LLM message continuation. + + Takes a list of tool calls from an LLM response, executes them using + the provided tool_executor callback, and returns formatted tool result + messages ready to append to the conversation. + + Args: + tool_calls: List of tool call dicts from LLM response, each with: + - "id": Tool call ID + - "function": Dict with "name" and "arguments" keys + tool_executor: Callback function that executes a tool. + Signature: (tool_name: str, arguments: dict) -> Any + Should return the tool execution result (will be JSON-serialized). + + Returns: + List of tool result message dicts, each with: + - "tool_call_id": The original tool call ID + - "role": "tool" + - "name": Tool function name + - "content": JSON-serialized tool result + """ + tool_results = [] + + for tool_call in tool_calls: + function_info = tool_call.get("function", {}) + function_name = function_info.get("name") + arguments_str = function_info.get("arguments", "{}") + + # Parse arguments JSON + try: + arguments = json.loads(arguments_str) + except (json.JSONDecodeError, TypeError): + arguments = {} + + # Execute tool + try: + result = tool_executor(function_name, arguments) + except Exception as e: + # Handle errors gracefully + result = {"error": str(e)} + + # Format tool result message + tool_results.append({ + "tool_call_id": tool_call.get("id"), + "role": "tool", + "name": function_name, + "content": json.dumps(result), + }) + + return tool_results + + async def _prepare_tools_for_api( self, - messages, - model=None, - max_tokens=3000, - 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}" - ) - return signal - return await self._get_signal_from_gpt(messages, model, max_tokens, n, stop, temperature) + tools: typing.Optional[list], + use_octobot_mcp: typing.Optional[bool], + ) -> list: + """Prepare and merge tools for API call. + + Args: + tools: Optional list of tool definitions. + use_octobot_mcp: Optional bool to include OctoBot MCP server tools. + + Returns: + List of effective tools (merged with MCP tools if enabled). + """ + effective_tools = list(tools) if tools is not None else [] + + # Auto-inject MCP tools if enabled + if self._should_auto_inject_mcp_tools() and self._mcp_tools: + effective_tools = effective_tools + self._mcp_tools.copy() + + # Add OctoBot MCP tools if requested + if use_octobot_mcp: + octobot_mcp_tools = await self._discover_octobot_mcp_tools() + if octobot_mcp_tools: + effective_tools = effective_tools + octobot_mcp_tools + + return effective_tools - def _get_client(self) -> openai.AsyncOpenAI: - return openai.AsyncOpenAI( - api_key=self._get_api_key(), - base_url=self._get_base_url(), + def _ensure_json_in_messages(self, messages: list) -> list: + """Ensure the word 'json' appears in at least one message. + + When using response_format with type 'json_object', the API requires + the word 'json' to appear somewhere in the messages. This method + checks and adds it if needed. + + Args: + messages: List of message dicts with 'role' and 'content' keys. + + Returns: + Messages list (potentially modified) with 'json' word present. + """ + # Check if 'json' appears in any message content (case-insensitive) + has_json = any( + "json" in str(msg.get("content", "")).lower() + for msg in messages ) - - 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) + + if not has_json: + # Find the first system message, or create one + system_msg_index = None + for i, msg in enumerate(messages): + if msg.get("role") == "system": + system_msg_index = i + break + + if system_msg_index is not None: + # Append to existing system message + existing_content = messages[system_msg_index].get("content", "") + messages[system_msg_index] = { + "role": "system", + "content": f"{existing_content}\n\nYou must respond with valid JSON." + } + else: + # Prepend a new system message + messages.insert(0, { + "role": "system", + "content": "You must respond with valid JSON." + }) + + return messages + + def _prepare_json_response_format( + self, + json_output: bool, + response_schema: typing.Optional[typing.Any], + ) -> typing.Optional[dict]: + """Prepare JSON response format for API call. + + Args: + json_output: Whether to return JSON format. + response_schema: Optional Pydantic model or JSON schema dict. + + Returns: + Response format dict or None if json_output is False. + """ + if not json_output: + return None + + if response_schema is not None: + use_strict = False try: - int(model[len(series)]) - return True - except ValueError: - return False - return False + schema = to_strict_json_schema(response_schema) + try: + # __strict_json_schema__ only exists on AgentBaseModel + use_strict = schema.__strict_json_schema__ + except AttributeError: + pass + except AttributeError: + # response_schema is already a dict (no model_json_schema method) + schema = response_schema + + return { + "type": "json_schema", + "json_schema": { + "name": schema.get("title", "response"), + "schema": schema, + "strict": use_strict, + } + } + else: + # Fallback to basic JSON object format + return {"type": "json_object"} - def _is_minimal_params_model(self, model: str) -> bool: - for minimal_params_series in MINIMAL_PARAMS_SERIES_MODELS: - if self._is_of_series(model, minimal_params_series): - return True - for minimal_params_model in MINIMAL_PARAMS_MODELS: - if model.startswith(minimal_params_model): - return True - return False + def _prepare_chat_completion_kwargs( + self, + messages: list, + model: str, + max_tokens: int, + n: int, + stop, + temperature: float, + supports_params: bool, + has_reasoning_config: bool, + reasoning_effort: typing.Optional[str], + effective_tools: list, + tool_choice: typing.Optional[typing.Union[str, dict]], + json_output: bool, + response_schema: typing.Optional[typing.Any], + ) -> dict: + """Prepare API kwargs for chat completion call. + + Args: + messages: List of message dicts. + model: Model name. + max_tokens: Maximum tokens in response. + n: Number of completions. + stop: Stop sequences. + temperature: Sampling temperature. + supports_params: Whether model supports all parameters. + has_reasoning_config: Whether reasoning is configured. + reasoning_effort: Reasoning effort level if configured. + effective_tools: List of tools to include. + tool_choice: Tool choice setting. + json_output: Whether to return JSON format. + response_schema: Optional response schema. + + Returns: + Complete api_kwargs dict for chat.completions.create. + """ + api_kwargs = { + "model": model, + "max_completion_tokens": max_tokens, + "n": n, + "stop": stop, + "temperature": temperature if supports_params else openai.NOT_GIVEN, + "messages": messages, + } + + # Add reasoning_effort if configured + if has_reasoning_config: + api_kwargs["reasoning_effort"] = reasoning_effort + + # Add tools to API call if provided + if effective_tools: + api_kwargs["tools"] = effective_tools + # Set tool_choice if provided, otherwise default to "auto" + if tool_choice is not None: + api_kwargs["tool_choice"] = tool_choice + elif not json_output: # Don't set tool_choice if json_output is True + api_kwargs["tool_choice"] = "auto" + + # Add JSON response format if needed + response_format = self._prepare_json_response_format(json_output, response_schema) + if response_format: + api_kwargs["response_format"] = response_format + # When using json_object format, ensure "json" appears in messages + if response_format.get("type") == "json_object": + messages = self._ensure_json_in_messages(messages) + api_kwargs["messages"] = messages + + return api_kwargs - async def _get_signal_from_gpt( + def _process_chat_completion_response( + self, + completions, + model: str, + ) -> typing.Union[str, dict]: + """Process chat completion response and extract content/tool_calls. + + Args: + completions: Completions object from API. + model: Model name for error messages. + + Returns: + str: Content string if no tool calls. + dict: Dict with 'content' and 'tool_calls' keys if tool calls present. + + Raises: + InvalidRequestError: If response is empty or has no content. + """ + if completions.usage is not None: + self._update_token_usage(completions.usage.total_tokens) + + if not completions.choices: + raise errors.InvalidRequestError( + f"Empty response from model {model}: no choices returned" + ) + + message = completions.choices[0].message + message_content = message.content + + # Check for tool calls + tool_calls = None + if hasattr(message, 'tool_calls') and message.tool_calls: + tool_calls = [] + for tool_call in message.tool_calls: + tool_calls.append({ + "id": tool_call.id, + "type": tool_call.type, + "function": { + "name": tool_call.function.name, + "arguments": tool_call.function.arguments, + } + }) + + # If tool calls are present, return structured response + if tool_calls: + return { + "content": message_content, + "tool_calls": tool_calls + } + + # If no content and no tool calls, raise error + 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 + + @AbstractAIService.retry_llm_completion() + async def get_completion( self, messages, model=None, - max_tokens=3000, + max_tokens=10000, n=1, stop=None, - temperature=0.5 - ): + temperature=0.5, + json_output=False, + response_schema=None, + reasoning_effort: typing.Optional[str] = None, + show_reasoning: typing.Optional[bool] = None, + tools: typing.Optional[list] = None, + tool_choice: typing.Optional[typing.Union[str, dict]] = None, + use_octobot_mcp: typing.Optional[bool] = None, + ) -> typing.Union[str, dict, None]: + """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. + 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. + tools: Optional list of tool definitions for function calling. + Each tool should be a dict with 'type' and 'function' keys. + tool_choice: Optional control for tool usage. Can be "auto", "none", + or a dict specifying a specific tool. + use_octobot_mcp: Optional bool to include OctoBot MCP server tools. + If True, automatically discovers and includes tools from OctoBot MCP interface. + If None, uses default behavior (does not include OctoBot MCP). + If False, explicitly excludes OctoBot MCP tools. + + Returns: + str: Content when no tools are used or tool_choice is "none" + dict: When tools are used and model makes tool calls, returns dict with: + - "content": str | None (may be None if only tool calls) + - "tool_calls": list of tool call dicts with id, type, function keys + 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 + # BUT skip if tools are involved (Responses API may not support tools) + # AND only if the model/endpoint supports Responses API + if has_reasoning_config and show_reasoning and not tools and self._should_use_responses_api(model): + 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." ) - completions = await self._get_client().chat.completions.create( + + effective_tools = await self._prepare_tools_for_api(tools, use_octobot_mcp) + + api_kwargs = self._prepare_chat_completion_kwargs( + messages=messages, model=model, - max_completion_tokens=max_tokens, + max_tokens=max_tokens, n=n, stop=stop, - temperature=temperature if supports_params else openai.NOT_GIVEN, - messages=messages + temperature=temperature, + supports_params=supports_params, + has_reasoning_config=has_reasoning_config, + reasoning_effort=reasoning_effort, + effective_tools=effective_tools, + tool_choice=tool_choice, + json_output=json_output, + response_schema=response_schema, ) - self._update_token_usage(completions.usage.total_tokens) - return completions.choices[0].message.content + + completions = await self._get_client().chat.completions.create(**api_kwargs) + return self._process_chat_completion_response(completions, model) except ( - openai.BadRequestError, openai.UnprocessableEntityError # error in request - )as err: + 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'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.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}") @@ -202,6 +977,717 @@ async def _get_signal_from_gpt( f"Unexpected error when running request with model {model}: {err}" ) from err + def _execute_tool_calls_and_append( + self, + tool_calls: list, + tool_executor: typing.Callable[[str, dict], typing.Any], + conversation_messages: list, + ) -> None: + """Execute tool calls and append results to conversation messages. + + Args: + tool_calls: List of tool call dicts. + tool_executor: Callback function to execute tools. + conversation_messages: List of messages to append tool results to (modified in place). + """ + # Execute tools and get formatted results + tool_results = self.handle_tool_calls(tool_calls, tool_executor) + + # Append tool results to conversation + conversation_messages.extend(tool_results) + + @AbstractAIService.retry_llm_completion() + async def get_completion_with_tools( + self, + messages: list, + tool_executor: typing.Optional[typing.Callable[[str, dict], typing.Any]] = None, + model: typing.Optional[str] = None, + max_tokens: int = 10000, + n: int = 1, + stop: typing.Optional[typing.Union[str, list]] = None, + temperature: float = 0.5, + json_output: bool = False, + response_schema: typing.Optional[typing.Any] = None, + tools: typing.Optional[list] = None, + tool_choice: typing.Optional[typing.Union[str, dict]] = None, + use_octobot_mcp: typing.Optional[bool] = None, + max_tool_iterations: int = 3, + return_tool_calls: bool = False, + ) -> typing.Any: + """ + Get a completion from the LLM with automatic tool calling orchestration. + + This method handles the tool calling loop automatically: + 1. Calls get_completion with the provided parameters + 2. If the response contains tool_calls, executes them using tool_executor + 3. Appends tool results to messages and calls get_completion again + 4. Repeats until no tool_calls are present or max_tool_iterations is reached + 5. Returns the final parsed response + + Args: + messages: List of message dicts with 'role' and 'content' keys. + tool_executor: Optional callback function to execute tools. + Signature: (tool_name: str, arguments: dict) -> Any + If None, tool calls will not be executed (response returned as-is). + model: Model to use (defaults to service's default model). + max_tokens: Maximum tokens in the response. + n: Number of completions to generate. + stop: Stop sequences. + temperature: Sampling temperature (0-2). + json_output: Whether to parse response as JSON. + response_schema: Optional Pydantic model or JSON schema dict + for structured output validation. + tools: Optional list of tool definitions for function calling. + Each tool should be a dict with 'type' and 'function' keys. + tool_choice: Optional control for tool usage. Can be "auto", "none", + or a dict specifying a specific tool. + use_octobot_mcp: Optional bool to include OctoBot MCP server tools. + If True, automatically discovers and includes tools from OctoBot MCP interface. + If None, uses default behavior (does not include OctoBot MCP). + If False, explicitly excludes OctoBot MCP tools. + max_tool_iterations: Maximum number of tool calling rounds (default: 3). + Prevents infinite loops if LLM keeps requesting tools. + + Returns: + Final parsed response: + - dict: If json_output=True, returns parsed JSON dict + - str: If json_output=False, returns the content string + - If tool_executor is None and tool_calls are present, returns dict with tool_calls + + Raises: + InvalidRequestError: If the request is malformed. + RateLimitError: If rate limits are exceeded. + ValueError: If max_tool_iterations is exceeded or tool_executor is None when tool_calls are present. + """ + # Create a copy of messages to avoid mutating the original + conversation_messages = list(messages) + + for iteration in range(max_tool_iterations): + # Call get_completion + response = await self.get_completion( + messages=conversation_messages, + model=model, + max_tokens=max_tokens, + n=n, + stop=stop, + temperature=temperature, + json_output=False, # Don't parse JSON yet, need to check for tool_calls + response_schema=response_schema, + tools=tools, + tool_choice=tool_choice, + use_octobot_mcp=use_octobot_mcp, + ) + + # Check if response contains tool_calls + if isinstance(response, dict) and response.get("tool_calls"): + tool_calls = response.get("tool_calls", []) + + # If return_tool_calls=True, normalize and return first tool call + if return_tool_calls and tool_calls: + tool_call = tool_calls[0] # Take first tool call + function_info = tool_call.get("function", {}) + tool_name = function_info.get("name") + arguments_str = function_info.get("arguments", "{}") + + # Parse arguments JSON + try: + arguments = json.loads(arguments_str) + except (json.JSONDecodeError, TypeError): + arguments = {} + + return { + "tool_name": tool_name, + "arguments": arguments + } + + # If no tool_executor provided, return response as-is + if tool_executor is None: + if json_output: + return self.parse_completion_response(response, json_output=True) + return response + + # Execute tools and append results to conversation + self._execute_tool_calls_and_append(tool_calls, tool_executor, conversation_messages) + + # Continue loop to call LLM again with tool results + continue + + # No tool_calls, we have the final response + # Parse it according to json_output setting + return self.parse_completion_response(response, json_output=json_output) + + # Max iterations reached - this shouldn't happen in normal operation + # Return the last response we got + if isinstance(response, dict) and response.get("tool_calls"): + raise ValueError( + f"Maximum tool calling iterations ({max_tool_iterations}) reached. " + "The LLM may be stuck in a loop requesting tools. " + "Consider increasing max_tool_iterations or checking tool implementations." + ) + + # Fallback: parse and return the last response + if return_tool_calls: + # When return_tool_calls=True, no tool calls were found, return None + return None + return self.parse_completion_response(response, json_output=json_output) + + def _get_client(self) -> openai.AsyncOpenAI: + """Get or create a cached AsyncOpenAI client instance. + + The client is cached and reused for connection pooling efficiency. + + Returns: + openai.AsyncOpenAI: Cached client instance + """ + if self._client is None: + self._client = openai.AsyncOpenAI( + api_key=self._get_api_key(), + base_url=self._get_base_url(), + timeout=self.HTTP_TIMEOUT, + ) + return self._client + + def _convert_messages_to_responses_input(self, messages: list) -> list: + """Convert chat messages to Responses API input format. + + Args: + messages: List of message dicts with 'role' and 'content' keys. + + Returns: + List of input items for Responses API, each with 'type' and 'text' keys. + """ + input_items = [] + for msg in messages: + role = msg.get("role", "user") + + # Skip tool role messages - Responses API doesn't support them in input format + if role == "tool": + continue + + # Get content, handling None values + content = msg.get("content") + if content is None: + # If content is None, skip this message unless it's a system message + # (system messages might have empty content but should still be included) + if role == "system": + content = "" + else: + # Skip messages with None content (e.g., assistant messages with only tool_calls) + continue + + # Ensure content is a string + if not isinstance(content, str): + content = str(content) if content is not None else "" + + # Skip empty content messages (except system messages) + if not content and role != "system": + continue + + 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 + }) + + return input_items + + def _validate_responses_input_items(self, input_items: list, model: str) -> list: + """Validate and filter input items for Responses API. + + Args: + input_items: List of input items to validate. + model: Model name for error messages. + + Returns: + List of validated input items. + + Raises: + InvalidRequestError: If no valid items remain after validation. + """ + validated_input_items = [] + for item in input_items: + # Check that item has required "type" field + if not isinstance(item, dict) or "type" not in item: + self.logger.warning(f"Skipping invalid input item (missing type): {item}") + continue + + item_type = item.get("type") + + # Validate input_text items + if item_type == "input_text": + if "text" not in item: + self.logger.warning(f"Skipping invalid input_text item (missing text): {item}") + continue + text = item.get("text") + # Ensure text is a string and not None + if not isinstance(text, str): + if text is None: + self.logger.warning(f"Skipping input_text item with None text: {item}") + continue + text = str(text) + item["text"] = text + # Skip empty text items (except if it's the only item, which shouldn't happen) + if not text.strip() and len(validated_input_items) > 0: + self.logger.debug(f"Skipping empty input_text item: {item}") + continue + + # Add validated item + validated_input_items.append(item) + + # Ensure we have at least one valid input item + if not validated_input_items: + raise errors.InvalidRequestError( + f"No valid input items after validation for model {model}. " + "All messages may have been filtered out." + ) + + return validated_input_items + + def _extract_responses_api_result(self, response, model: str) -> typing.Union[str, dict]: + """Extract content and reasoning from Responses API response. + + Args: + response: Response object from Responses API. + model: Model name for logging and error messages. + + Returns: + str: Content string if no reasoning available. + dict: Dict with 'content' and 'reasoning' keys if reasoning is available. + + Raises: + InvalidRequestError: If content is empty. + """ + # 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 + + 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 + input_items = self._convert_messages_to_responses_input(messages) + + # Validate and filter input items + validated_input_items = self._validate_responses_input_items(input_items, model) + + responses_kwargs = { + "model": model, + "input": validated_input_items, + "max_output_tokens": max_tokens, + } + + if reasoning_effort and reasoning_effort in REASONING_EFFORT_VALUES: + responses_kwargs["reasoning"] = {"effort": reasoning_effort} + + response = await self._get_client().responses.create(**responses_kwargs) + return self._extract_responses_api_result(response, model) + + 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 openai.APIError as err: + # Handle OpenAI/Mistral API errors with more detail + error_code = getattr(err, 'status_code', None) + error_message = str(err) + error_body = getattr(err, 'body', None) + + # Log detailed error information + self.logger.error( + f"Responses API error for model {model}: " + f"code={error_code}, message={error_message}, body={error_body}" + ) + + # Check for specific error types that indicate Responses API is not supported + if error_code == 400: + error_str = str(err).lower() + if "cannot determine type" in error_str or "type of 'item'" in error_str: + self.logger.warning( + f"Responses API format not supported by endpoint for model {model}. " + "This may indicate the endpoint doesn't support Responses API. " + "Falling back to Chat Completions API." + ) + else: + self.logger.warning( + f"Responses API request error for {model}: {err}. " + "Falling back to Chat Completions API." + ) + else: + self.logger.warning( + f"Responses API error for {model} (code {error_code}): {err}. " + "Falling back to Chat Completions API." + ) + + # Raise as InvalidRequestError to trigger fallback + raise errors.InvalidRequestError( + f"Error when using Responses API with model {model}: {err}" + ) from err + except Exception as err: + self.logger.error( + f"Unexpected error using Responses API for model {model}: {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) + try: + int(model[len(series)]) + return True + except ValueError: + return False + return False + + def _is_minimal_params_model(self, model: str) -> bool: + for minimal_params_series in MINIMAL_PARAMS_SERIES_MODELS: + if self._is_of_series(model, minimal_params_series): + return True + for minimal_params_model in MINIMAL_PARAMS_MODELS: + if model.startswith(minimal_params_model): + return True + return False + + def _should_use_responses_api(self, model: str) -> bool: + """Check if Responses API should be used for the given model. + + The Responses API may not be supported by: + - GGUF models (local quantized models) + - Ollama endpoints + - Other local/compatible API servers + + Args: + model: Model name to check. + + Returns: + bool: True if Responses API should be attempted, False otherwise. + """ + if not model: + return False + + model_lower = model.lower() + + # Skip Responses API for GGUF models (local quantized models) + if "gguf" in model_lower: + self.logger.debug( + f"Skipping Responses API for GGUF model {model} " + "(Responses API not supported by local GGUF models)" + ) + return False + return True + + @staticmethod + def is_setup_correctly(config): + return True + + @staticmethod + def get_is_enabled(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: + 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 + + 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.CONFIG_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_models_config() + self._load_token_limit_from_config() + + if 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.debug(f"Fetched {len(fetched_models.data)} models") + self.models = [d.id for d in fetched_models.data] + else: + self.logger.warning("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." + ) + + # Discover MCP tools if configured + try: + self._mcp_tools = await self._discover_mcp_tools() + except Exception as err: + self.logger.warning( + f"Error discovering MCP tools: {err}. Continuing without MCP tools." + ) + self._mcp_tools = [] + except openai.AuthenticationError as err: + 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 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): + # Clean up cached OpenAI client + if self._client is not None: + try: + # AsyncOpenAI has a close() method to properly close the underlying aiohttp session + if hasattr(self._client, 'close'): + await self._client.close() + except Exception as err: + self.logger.debug(f"Error closing OpenAI client: {err}") + finally: + self._client = None + + # Clean up MCP clients if any + for client in self._mcp_clients: + try: + if hasattr(client, 'close'): + await client.close() + except Exception as err: + self.logger.debug(f"Error closing MCP client: {err}") + self._mcp_clients.clear() + self._mcp_tools.clear() + + +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, exchange: str, @@ -211,7 +1697,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 +1714,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 +1734,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 +1744,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 +1793,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 +1830,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.CONFIG_OPENAI_SECRET_KEY] - return bool(config_key) and config_key not in commons_constants.DEFAULT_CONFIG_VALUES + return ( + bool(config_key) + and config_key not in commons_constants.DEFAULT_CONFIG_VALUES + ) except KeyError: return False @@ -350,92 +1854,49 @@ 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.CONFIG_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.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 _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.CONFIG_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") - return - 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: - 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.info( + f"Skipping GPT - OpenAI models fetch as self.use_stored_signals_only() is True" + ) + # Still discover MCP tools even in stored signals mode + try: + self._mcp_tools = await self._discover_mcp_tools() + except Exception as err: 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." + f"Error discovering MCP tools: {err}. Continuing without MCP tools." ) + self._mcp_tools = [] + return + + # Call parent prepare to handle model loading and MCP discovery + await super().prepare() except openai.AuthenticationError as err: 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/Services/Services_bases/searxng_service/__init__.py b/Services/Services_bases/searxng_service/__init__.py new file mode 100644 index 000000000..32134f733 --- /dev/null +++ b/Services/Services_bases/searxng_service/__init__.py @@ -0,0 +1,21 @@ +# 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 .searxng import SearXNGService + +__all__ = [ + "SearXNGService", +] diff --git a/Services/Services_bases/searxng_service/metadata.json b/Services/Services_bases/searxng_service/metadata.json new file mode 100644 index 000000000..9146f3721 --- /dev/null +++ b/Services/Services_bases/searxng_service/metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.0.0", + "origin_package": "OctoBot-Default-Tentacles", + "tentacles": ["SearXNGService"], + "tentacles-requirements": [] +} diff --git a/Services/Services_bases/searxng_service/searxng.py b/Services/Services_bases/searxng_service/searxng.py new file mode 100644 index 000000000..efd98861b --- /dev/null +++ b/Services/Services_bases/searxng_service/searxng.py @@ -0,0 +1,320 @@ +# 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. +""" +SearXNG web search service implementation. + +SearXNG is a free, privacy-respecting metasearch engine. +See: https://github.com/searxng/searxng +""" +import aiohttp +from typing import Any, Dict, List, Optional, Sequence + +import octobot_services.constants as services_constants +import octobot_services.services as services + + +class SearXNGService(services.AbstractWebSearchService): + """ + SearXNG web search service. + + Connects to a self-hosted SearXNG instance to perform web searches. + SearXNG is a privacy-respecting metasearch engine that aggregates + results from multiple search engines. + + Configuration: + - url: Base URL of the SearXNG instance (e.g., "http://localhost") + - port: Port number (e.g., 8080) + - categories: Default search categories (e.g., ["general", "news"]) + - language: Default language code (e.g., "en") + - time_range: Default time range filter + - safe_search: Safe search level (0=off, 1=moderate, 2=strict) + - engines: Comma-separated list of engines to use + """ + + SEARXNG_DOCS_URL = "https://docs.searxng.org/" + SEARXNG_GITHUB_URL = "https://github.com/searxng/searxng" + + def __init__(self): + super().__init__() + self._base_url: Optional[str] = None + self._port: Optional[int] = None + self._default_categories: Optional[List[str]] = None + self._default_language: Optional[str] = None + self._default_time_range: Optional[str] = None + self._safe_search: int = 0 + self._default_engines: Optional[str] = None + + def _get_api_url(self) -> str: + """Build the full API URL from base URL and port.""" + if not self._base_url: + return "" + url = self._base_url.rstrip("/") + if self._port: + url = f"{url}:{self._port}" + return url + + async def _get(self, path: str, params: Dict[str, Any], timeout: float = 30) -> Dict[str, Any]: + """Make a GET request to the SearXNG API.""" + url = f"{self._get_api_url()}/{path.lstrip('/')}" + if not url or not self._base_url: + if self.logger: + self.logger.error("SearXNG URL not configured") + return {} + + try: + async with aiohttp.ClientSession() as session: + async with session.get( + url, + params=params, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as resp: + if resp.status != 200: + text = await resp.text() + if self.logger: + self.logger.error( + f"SearXNG API error {resp.status}: {text[:200]}" + ) + return {} + return await resp.json() + except aiohttp.ClientError as e: + if self.logger: + self.logger.error(f"SearXNG API request failed: {e}") + return {} + except Exception as e: + if self.logger: + self.logger.error(f"SearXNG API error: {e}") + return {} + + def get_type(self): + return services_constants.CONFIG_SEARXNG + + def get_endpoint(self): + return self + + @staticmethod + def is_setup_correctly(config): + return ( + services_constants.CONFIG_CATEGORY_SERVICES in config + and services_constants.CONFIG_SEARXNG + in config[services_constants.CONFIG_CATEGORY_SERVICES] + and services_constants.CONFIG_SERVICE_INSTANCE + in config[services_constants.CONFIG_CATEGORY_SERVICES][ + services_constants.CONFIG_SEARXNG + ] + ) + + def has_required_configuration(self): + return ( + services_constants.CONFIG_CATEGORY_SERVICES in self.config + and services_constants.CONFIG_SEARXNG + in self.config[services_constants.CONFIG_CATEGORY_SERVICES] + and self.check_required_config( + self.config[services_constants.CONFIG_CATEGORY_SERVICES][ + services_constants.CONFIG_SEARXNG + ] + ) + ) + + async def prepare(self): + """Initialize the SearXNG service with configuration.""" + searxng_config = self.config.get(services_constants.CONFIG_CATEGORY_SERVICES, {}).get( + services_constants.CONFIG_SEARXNG, {} + ) + + # Required: URL + self._base_url = ( + searxng_config.get(services_constants.CONFIG_SEARXNG_URL) or "" + ).strip() or None + + # Optional: Port + port_str = str(searxng_config.get(services_constants.CONFIG_SEARXNG_PORT) or "").strip() + self._port = int(port_str) if port_str.isdigit() else None + + # Optional: Default categories + categories = searxng_config.get(services_constants.CONFIG_SEARXNG_CATEGORIES) + if isinstance(categories, str): + self._default_categories = [c.strip() for c in categories.split(",") if c.strip()] + elif isinstance(categories, list): + self._default_categories = categories + else: + self._default_categories = None + + # Optional: Language + self._default_language = ( + searxng_config.get(services_constants.CONFIG_SEARXNG_LANGUAGE) or "" + ).strip() or None + + # Optional: Time range + self._default_time_range = ( + searxng_config.get(services_constants.CONFIG_SEARXNG_TIME_RANGE) or "" + ).strip() or None + + # Optional: Safe search (0, 1, or 2) + safe_str = str(searxng_config.get(services_constants.CONFIG_SEARXNG_SAFE_SEARCH) or "0").strip() + self._safe_search = int(safe_str) if safe_str.isdigit() else 0 + + # Optional: Engines + self._default_engines = ( + searxng_config.get(services_constants.CONFIG_SEARXNG_ENGINES) or "" + ).strip() or None + + # Test connection + if self._base_url: + try: + # SearXNG returns JSON when format=json is specified + test_result = await self._get("search", {"q": "test", "format": "json"}, timeout=10) + self._startup_healthy = bool(test_result) + self._startup_message = ( + f"SearXNG connected at {self._get_api_url()}" + if self._startup_healthy + else "" + ) + except Exception as e: + self._startup_healthy = False + self._startup_message = "" + if self.logger: + self.logger.warning(f"SearXNG connection test failed: {e}") + else: + self._startup_healthy = False + self._startup_message = "" + + def get_fields_description(self): + return { + services_constants.CONFIG_SEARXNG_URL: "SearXNG instance URL (e.g., http://localhost or https://searxng.example.com).", + services_constants.CONFIG_SEARXNG_PORT: "Port number (optional, e.g., 8080).", + services_constants.CONFIG_SEARXNG_CATEGORIES: "Default search categories (comma-separated, e.g., 'general,news').", + services_constants.CONFIG_SEARXNG_LANGUAGE: "Default language code (e.g., 'en', 'fr', 'de').", + services_constants.CONFIG_SEARXNG_TIME_RANGE: "Default time range filter (e.g., 'day', 'week', 'month', 'year').", + services_constants.CONFIG_SEARXNG_SAFE_SEARCH: "Safe search level: 0=off, 1=moderate, 2=strict.", + services_constants.CONFIG_SEARXNG_ENGINES: "Engines to use (comma-separated, e.g., 'google,bing,duckduckgo').", + } + + def get_default_value(self): + return { + services_constants.CONFIG_SEARXNG_URL: "http://localhost", + services_constants.CONFIG_SEARXNG_PORT: "8080", + services_constants.CONFIG_SEARXNG_CATEGORIES: "general", + services_constants.CONFIG_SEARXNG_LANGUAGE: "en", + services_constants.CONFIG_SEARXNG_TIME_RANGE: "", + services_constants.CONFIG_SEARXNG_SAFE_SEARCH: "0", + services_constants.CONFIG_SEARXNG_ENGINES: "", + } + + def get_required_config(self): + return [services_constants.CONFIG_SEARXNG_URL] + + @classmethod + def get_help_page(cls) -> str: + return cls.SEARXNG_DOCS_URL + + def get_website_url(self) -> str: + return self.SEARXNG_GITHUB_URL + + async def search( + self, + query: str, + max_results: Optional[int] = None, + categories: Optional[Sequence[str]] = None, + language: Optional[str] = None, + time_range: Optional[str] = None, + include_domains: Optional[Sequence[str]] = None, + exclude_domains: Optional[Sequence[str]] = None, + timeout: Optional[float] = None, + engines: Optional[str] = None, + safe_search: Optional[int] = None, + **kwargs, + ) -> services.WebSearchResponse: + params: Dict[str, Any] = { + "q": query, + "format": "json", + } + + cats = categories or self._default_categories + if cats: + params["categories"] = ",".join(cats) if isinstance(cats, (list, tuple)) else cats + + lang = language or self._default_language + if lang: + params["language"] = lang + + tr = time_range or self._default_time_range + if tr: + params["time_range"] = tr + + ss = safe_search if safe_search is not None else self._safe_search + params["safesearch"] = ss + + eng = engines or self._default_engines + if eng: + params["engines"] = eng + params.update(kwargs) + + request_timeout = timeout or self.DEFAULT_TIMEOUT + raw = await self._get("search", params, timeout=request_timeout) + + if not raw: + return services.WebSearchResponse(query=query) + + results: List[services.WebSearchResult] = [] + for r in raw.get("results", []): + if not isinstance(r, dict): + continue + + url = str(r.get("url", "")) + + if include_domains: + if not any(domain in url for domain in include_domains): + continue + if exclude_domains: + if any(domain in url for domain in exclude_domains): + continue + + results.append(services.WebSearchResult( + title=str(r.get("title", "")), + url=url, + content=str(r.get("content", "")), + score=float(r.get("score", 0)), + engine=r.get("engine"), + )) + + # Limit results if max_results specified + if max_results and len(results) >= max_results: + break + + return services.WebSearchResponse( + query=query, + results=results, + total_results=raw.get("number_of_results"), + ) + + async def search_news( + self, + query: str, + max_results: Optional[int] = None, + language: Optional[str] = None, + time_range: Optional[str] = None, + timeout: Optional[float] = None, + **kwargs, + ) -> services.WebSearchResponse: + return await self.search( + query=query, + max_results=max_results, + categories=["news"], + language=language, + time_range=time_range or "week", # Default to recent news + timeout=timeout, + **kwargs, + ) diff --git a/Services/Services_bases/tavily_service/tavily.py b/Services/Services_bases/tavily_service/tavily.py index 708a7870f..e4b2e18d7 100644 --- a/Services/Services_bases/tavily_service/tavily.py +++ b/Services/Services_bases/tavily_service/tavily.py @@ -34,7 +34,7 @@ TAVILY_API_BASE = "https://api.tavily.com" -class TavilyService(services.AbstractService): +class TavilyService(services.AbstractWebSearchService): TAVILY_DOCS_URL = "https://docs.tavily.com/documentation/api-reference/introduction" @@ -42,8 +42,6 @@ def __init__(self): super().__init__() self._api_key: Optional[str] = None self._headers: Dict[str, str] = {} - self._startup_message: str = "" - self._startup_healthy: bool = False async def _post(self, path: str, data: dict, timeout: float = 60) -> dict: if not self._api_key: @@ -190,6 +188,73 @@ def get_website_url(self) -> str: return self.TAVILY_DOCS_URL async def search( + self, + query: str, + max_results: Optional[int] = None, + categories: Optional[Sequence[str]] = None, + language: Optional[str] = None, + time_range: Optional[str] = None, + include_domains: Optional[Sequence[str]] = None, + exclude_domains: Optional[Sequence[str]] = None, + timeout: Optional[float] = None, + # Tavily-specific parameters + search_depth: Optional[str] = None, + topic: Optional[str] = None, + include_answer: Optional[Union[bool, str]] = None, + include_raw_content: Optional[Union[bool, str]] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + days: Optional[int] = None, + include_images: Optional[bool] = None, + country: Optional[str] = None, + auto_parameters: Optional[bool] = None, + include_favicon: Optional[bool] = None, + include_usage: Optional[bool] = None, + chunks_per_source: Optional[int] = None, + **kwargs, + ) -> services.WebSearchResponse: + tavily_response = await self.search_tavily( + query=query, + search_depth=search_depth, + topic=topic, + max_results=max_results, + include_answer=include_answer, + include_raw_content=include_raw_content, + include_domains=include_domains, + exclude_domains=exclude_domains, + time_range=time_range, + start_date=start_date, + end_date=end_date, + days=days, + include_images=include_images, + country=country, + auto_parameters=auto_parameters, + include_favicon=include_favicon, + include_usage=include_usage, + chunks_per_source=chunks_per_source, + timeout=timeout or 60.0, + ) + + web_results = [] + for tavily_result in tavily_response.results: + web_results.append(services.WebSearchResult( + title=tavily_result.title, + url=tavily_result.url, + content=tavily_result.content, + score=tavily_result.score, + raw_content=tavily_result.raw_content, + favicon=tavily_result.favicon, + )) + + return services.WebSearchResponse( + query=tavily_response.query, + results=web_results, + answer=tavily_response.answer, + response_time=tavily_response.response_time, + total_results=len(web_results), + ) + + async def search_tavily( self, query: str, search_depth: Optional[str] = None, @@ -249,6 +314,26 @@ async def search( raw = await self._post("search", data, timeout=min(timeout, 120)) return SearchResponse.from_dict(raw) + async def search_news( + self, + query: str, + max_results: Optional[int] = None, + language: Optional[str] = None, + time_range: Optional[str] = None, + timeout: Optional[float] = None, + **kwargs, + ) -> services.WebSearchResponse: + return await self.search( + query=query, + max_results=max_results, + categories=None, # Not used by Tavily + language=language, + time_range=time_range or "week", # Default to recent news + timeout=timeout, + topic="news", # Tavily-specific: use news topic + **kwargs, + ) + async def extract( self, urls: Union[List[str], str], diff --git a/Services/Services_bases/web_service/web.py b/Services/Services_bases/web_service/web.py index 4dd8666d2..d755b215d 100644 --- a/Services/Services_bases/web_service/web.py +++ b/Services/Services_bases/web_service/web.py @@ -27,8 +27,6 @@ class WebService(services.AbstractService): - BACKTESTING_ENABLED = True - def __init__(self): super().__init__() self.web_app = None 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 aaff0e91b..2bcd9b4f9 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 @@ -41,7 +41,10 @@ class AlternativeMeServiceFeed(service_feeds.AbstractServiceFeed): FEED_CHANNEL = AlternativeMeServiceFeedChannel REQUIRED_SERVICES = [Services_bases.AlternativeMeService] + BACKTESTING_ENABLED = True + API_RATE_LIMIT_SECONDS = 10 + DEFAULT_HISTORICAL_LIMIT = 1000 def __init__(self, config, main_async_loop, bot_id): super().__init__(config, main_async_loop, bot_id) @@ -66,7 +69,13 @@ 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 - async def _get_fear_and_greed_data(self, session: aiohttp.ClientSession, limit: typing.Optional[int] = 100) -> bool: + async def _get_fear_and_greed_data( + self, + session: aiohttp.ClientSession, + limit: typing.Optional[int] = 100, + start_timestamp: typing.Optional[float] = None, + end_timestamp: typing.Optional[float] = None, + ) -> 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: @@ -81,6 +90,11 @@ async def _get_fear_and_greed_data(self, session: aiohttp.ClientSession, limit: value=float(entry["value"]), value_classification=entry["value_classification"] ) for entry in data] + if start_timestamp is not None and end_timestamp is not None: + self.data_cache[services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED] = [ + item for item in self.data_cache[services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED] + if start_timestamp <= item.timestamp * 1000 <= end_timestamp + ] return True def get_data_cache(self, current_time: float, key: typing.Optional[str] = None): @@ -128,6 +142,48 @@ async def _start_service_feed(self): self.logger.exception(e, True, f"Error when initializing Alternative.me feed: {e}") return False + @classmethod + def get_historical_sources(cls) -> list: + return [services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED] + + async def get_historical_data( + self, + start_timestamp, + end_timestamp, + symbols=None, + source=None, + **kwargs + ) -> typing.AsyncIterator[list[dict]]: + """Fetch historical Fear and Greed data from Alternative.me API via internal fetch.""" + if source != services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED: + raise ValueError(f"Invalid source: {source}") + async with aiohttp.ClientSession() as session: + ok = await self._get_fear_and_greed_data( + session, + limit=self.DEFAULT_HISTORICAL_LIMIT, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + ) + await asyncio.sleep(self.API_RATE_LIMIT_SECONDS) + if not ok or not self.data_cache.get(services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED): + return + events = [] + for item in self.data_cache[services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED]: + ts_ms = int(item.timestamp * 1000) + events.append({ + "timestamp": ts_ms, + "channel": source or services_constants.ALTERNATIVE_ME_TOPIC_FEAR_AND_GREED, + "symbol": "", + "payload": { + "value": item.value, + "value_classification": item.value_classification, + "timestamp": datetime.datetime.fromtimestamp(item.timestamp, tz=datetime.timezone.utc).strftime("%m-%d-%Y"), + }, + }) + if events: + events.sort(key=lambda x: x["timestamp"]) + yield events + async def stop(self): await super().stop() if self.listener_task is not None: diff --git a/Services/Services_feeds/coindesk_service_feed/__init__.py b/Services/Services_feeds/coindesk_service_feed/__init__.py index 8ceaf4bb0..af15e9e8e 100644 --- a/Services/Services_feeds/coindesk_service_feed/__init__.py +++ b/Services/Services_feeds/coindesk_service_feed/__init__.py @@ -1,3 +1 @@ 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 index c973515b6..8903e07fc 100644 --- a/Services/Services_feeds/coindesk_service_feed/coindesk_feed.py +++ b/Services/Services_feeds/coindesk_service_feed/coindesk_feed.py @@ -16,8 +16,6 @@ import asyncio import aiohttp import typing -import datetime -import dataclasses import octobot_commons.enums as commons_enums import octobot_commons.constants as commons_constants @@ -25,50 +23,20 @@ import octobot_services.constants as services_constants import octobot_services.service_feeds as service_feeds import tentacles.Services.Services_bases as Services_bases +import tentacles.Services.Services_bases.coindesk_service.models as coindesk_models 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] - + + BACKTESTING_ENABLED = True + API_RATE_LIMIT_SECONDS = 10 + DEFAULT_HISTORICAL_LIMIT = 1000 def __init__(self, config, main_async_loop, bot_id): super().__init__(config, main_async_loop, bot_id) @@ -105,7 +73,12 @@ def _merge_cache_data(self, cache_key: str, new_values: list, id_getter: typing. 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 def _get_marketcap_data( + self, + session: aiohttp.ClientSession, + start_timestamp: typing.Optional[float] = None, + end_timestamp: typing.Optional[float] = None, + ) -> 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}") @@ -114,7 +87,7 @@ async def _get_marketcap_data(self, session: aiohttp.ClientSession) -> bool: market_cap_data = await response.json() new_values = [ - CoindeskMarketcap( + coindesk_models.CoindeskMarketcap( timestamp=entry["TIMESTAMP"], open=entry["OPEN"], close=entry["CLOSE"], @@ -126,14 +99,28 @@ async def _get_marketcap_data(self, session: aiohttp.ClientSession) -> bool: self.data_cache[services_constants.COINDESK_TOPIC_MARKETCAP] = self._merge_cache_data( services_constants.COINDESK_TOPIC_MARKETCAP, new_values, lambda x: x.timestamp ) + if start_timestamp is not None and end_timestamp is not None: + def _marketcap_ts_ms(item): + t = item.timestamp + return int(t.timestamp() * 1000) if hasattr(t, "timestamp") else int(t) + self.data_cache[services_constants.COINDESK_TOPIC_MARKETCAP] = [ + item for item in self.data_cache[services_constants.COINDESK_TOPIC_MARKETCAP] + if start_timestamp <= _marketcap_ts_ms(item) <= end_timestamp + ] 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: + async def _get_news_data( + self, + session: aiohttp.ClientSession, + limit: typing.Optional[int] = 10, + start_timestamp: typing.Optional[float] = None, + end_timestamp: typing.Optional[float] = None, + ) -> bool: + async with session.get(self._get_news_api_url(limit)) as response: if response.status != 200: self.logger.error(f"API request failed with status: {response.status}") return False @@ -151,7 +138,7 @@ async def _get_news_data(self, session: aiohttp.ClientSession) -> bool: category_data = article.get("CATEGORY_DATA", []) categories_str = str([cat["NAME"] for cat in category_data]) - values.append(CoindeskNews( + values.append(coindesk_models.CoindeskNews( id=article["ID"], guid=article["GUID"], published_on=article["PUBLISHED_ON"], @@ -178,6 +165,14 @@ async def _get_news_data(self, session: aiohttp.ClientSession) -> bool: self.data_cache[services_constants.COINDESK_TOPIC_NEWS] = self._merge_cache_data( services_constants.COINDESK_TOPIC_NEWS, values, lambda x: x.id ) + if start_timestamp is not None and end_timestamp is not None: + def _news_ts_ms(item): + t = item.published_on + return int(t.timestamp() * 1000) if hasattr(t, "timestamp") else int(t) + self.data_cache[services_constants.COINDESK_TOPIC_NEWS] = [ + item for item in self.data_cache[services_constants.COINDESK_TOPIC_NEWS] + if start_timestamp <= _news_ts_ms(item) <= end_timestamp + ] return True def get_data_cache(self, current_time: float, key: typing.Optional[str] = None): @@ -229,6 +224,66 @@ async def _start_service_feed(self): self.logger.exception(e, True, f"Error when initializing Coindesk feed: {e}") return False + async def get_historical_data( + self, + start_timestamp, + end_timestamp, + symbols=None, + source=None, + **kwargs + ) -> typing.AsyncIterator[list[dict]]: + """Fetch historical data from Coindesk API via the feed's internal fetch and service conversion.""" + if not self.services and self.REQUIRED_SERVICES: + self.services = [s.instance() for s in self.REQUIRED_SERVICES] + if not self.services: + return + service = self.services[0] + if source == services_constants.COINDESK_TOPIC_NEWS: + async with aiohttp.ClientSession() as session: + ok = await self._get_news_data( + session, + limit=self.DEFAULT_HISTORICAL_LIMIT, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + ) + await asyncio.sleep(self.API_RATE_LIMIT_SECONDS) + if not ok or not self.data_cache.get(services_constants.COINDESK_TOPIC_NEWS): + return + events = [ + service._convert_news_to_event(item, source or services_constants.COINDESK_TOPIC_NEWS) + for item in self.data_cache[services_constants.COINDESK_TOPIC_NEWS] + ] + events = [e for e in events if e is not None] + if events: + events.sort(key=lambda x: x["timestamp"]) + yield events + elif source == services_constants.COINDESK_TOPIC_MARKETCAP: + async with aiohttp.ClientSession() as session: + ok = await self._get_marketcap_data( + session, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp, + ) + await asyncio.sleep(self.API_RATE_LIMIT_SECONDS) + if not ok or not self.data_cache.get(services_constants.COINDESK_TOPIC_MARKETCAP): + return + events = [ + service._convert_marketcap_to_event( + item, source or services_constants.COINDESK_TOPIC_MARKETCAP + ) + for item in self.data_cache[services_constants.COINDESK_TOPIC_MARKETCAP] + ] + events = [e for e in events if e is not None] + if events: + events.sort(key=lambda x: x["timestamp"]) + yield events + else: + raise ValueError(f"Invalid source: {source}") + + @classmethod + def get_historical_sources(cls) -> list: + return [services_constants.COINDESK_TOPIC_NEWS, services_constants.COINDESK_TOPIC_MARKETCAP] + async def stop(self): await super().stop() if self.listener_task is not None: diff --git a/Services/Services_feeds/coingecko_service_feed/coingecko_feed.py b/Services/Services_feeds/coingecko_service_feed/coingecko_feed.py index cee14a032..21b7ee0a8 100644 --- a/Services/Services_feeds/coingecko_service_feed/coingecko_feed.py +++ b/Services/Services_feeds/coingecko_service_feed/coingecko_feed.py @@ -89,6 +89,8 @@ class CoingeckoServiceFeed(service_feeds.AbstractServiceFeed): FEED_CHANNEL = CoingeckoServiceFeedChannel REQUIRED_SERVICES = [Services_bases.CoingeckoService] + BACKTESTING_ENABLED = True + API_RATE_LIMIT_SECONDS = 10 def __init__(self, config, main_async_loop, bot_id): @@ -273,6 +275,78 @@ async def _start_service_feed(self): self.logger.exception(e, True, f"Error when initializing Coingecko feed: {e}") return False + @classmethod + def get_historical_sources(cls) -> list: + return [ + services_constants.COINGECKO_TOPIC_MARKETS, + services_constants.COINGECKO_TOPIC_TRENDING, + services_constants.COINGECKO_TOPIC_GLOBAL, + ] + + async def get_historical_data( + self, + start_timestamp, + end_timestamp, + symbols=None, + source=None, + **kwargs + ) -> typing.AsyncIterator[list[dict]]: + """Fetch historical/snapshot data from CoinGecko API (snapshot at collection time).""" + if source not in ( + services_constants.COINGECKO_TOPIC_MARKETS, + services_constants.COINGECKO_TOPIC_TRENDING, + services_constants.COINGECKO_TOPIC_GLOBAL, + ): + raise ValueError(f"Invalid source: {source}") + if self.coins_api is None: + self._initialize_api_client() + ts_ms = int(end_timestamp) if end_timestamp else int(start_timestamp) + if source == services_constants.COINGECKO_TOPIC_MARKETS: + ok = await self._get_markets_data(per_page=250) + if not ok or not self.data_cache.get(services_constants.COINGECKO_TOPIC_MARKETS): + return + events = [] + for coin in self.data_cache[services_constants.COINGECKO_TOPIC_MARKETS]: + payload = dataclasses.asdict(coin) if dataclasses.is_dataclass(coin) else coin + symbol = getattr(coin, "id", "") if not isinstance(coin, dict) else coin.get("id", "") + events.append({ + "timestamp": ts_ms, + "channel": source, + "symbol": symbol, + "payload": payload if isinstance(payload, dict) else dataclasses.asdict(coin), + }) + if events: + yield events + elif source == services_constants.COINGECKO_TOPIC_TRENDING: + ok = await self._get_trending_data() + if not ok or not self.data_cache.get(services_constants.COINGECKO_TOPIC_TRENDING): + return + events = [] + for coin in self.data_cache[services_constants.COINGECKO_TOPIC_TRENDING]: + payload = dataclasses.asdict(coin) if dataclasses.is_dataclass(coin) else coin + events.append({ + "timestamp": ts_ms, + "channel": source, + "symbol": getattr(coin, "id", "") or (coin.get("id", "") if isinstance(coin, dict) else ""), + "payload": payload if isinstance(payload, dict) else dataclasses.asdict(coin), + }) + if events: + yield events + elif source == services_constants.COINGECKO_TOPIC_GLOBAL: + ok = await self._get_global_data() + if not ok or self.data_cache.get(services_constants.COINGECKO_TOPIC_GLOBAL) is None: + return + global_obj = self.data_cache[services_constants.COINGECKO_TOPIC_GLOBAL] + payload = dataclasses.asdict(global_obj) if dataclasses.is_dataclass(global_obj) else global_obj + if not isinstance(payload, dict): + payload = dataclasses.asdict(global_obj) + yield [{ + "timestamp": ts_ms, + "channel": source, + "symbol": "", + "payload": payload, + }] + async def stop(self): await super().stop() if self.listener_task is not None: diff --git a/Services/Services_feeds/lunarcrush_service_feed/lunarcrush_feed.py b/Services/Services_feeds/lunarcrush_service_feed/lunarcrush_feed.py index e33c44c2f..711df0924 100644 --- a/Services/Services_feeds/lunarcrush_service_feed/lunarcrush_feed.py +++ b/Services/Services_feeds/lunarcrush_service_feed/lunarcrush_feed.py @@ -59,6 +59,8 @@ class LunarCrushServiceFeed(service_feeds.AbstractServiceFeed): FEED_CHANNEL = LunarCrushServiceFeedChannel REQUIRED_SERVICES = [Services_bases.LunarCrushService] + BACKTESTING_ENABLED = True + def __init__(self, config, main_async_loop, bot_id): super().__init__(config, main_async_loop, bot_id) self.lunarcrush_coins = [] @@ -143,6 +145,51 @@ async def _start_service_feed(self): self.logger.exception(e, True, f"Error when initializing LunarCrush feed: {e}") return False + @classmethod + def get_historical_sources(cls) -> list: + return [services_constants.LUNARCRUSH_COIN_METRICS] + + async def get_historical_data( + self, + start_timestamp, + end_timestamp, + symbols=None, + source=None, + **kwargs + ) -> typing.AsyncIterator[list[dict]]: + """Fetch historical coin metrics from LunarCrush time-series API.""" + if source != services_constants.LUNARCRUSH_COIN_METRICS: + raise ValueError(f"Invalid source: {source}") + coins = list(symbols) if symbols else list(self.lunarcrush_coins) + if not coins: + return + if not self.services and self.REQUIRED_SERVICES: + self.services = [s.instance() for s in self.REQUIRED_SERVICES] + headers = self.services[0].get_authentication_headers() if self.services else None + start_date = datetime.datetime.fromtimestamp(start_timestamp / 1000.0, tz=datetime.timezone.utc) + end_date = datetime.datetime.fromtimestamp(end_timestamp / 1000.0, tz=datetime.timezone.utc) + async with aiohttp.ClientSession(headers=headers) as session: + for coin in coins: + ok = await self._get_coin_data(session, coin, start_date, end_date) + if not ok or not self.data_cache.get(services_constants.LUNARCRUSH_COIN_METRICS) or coin not in self.data_cache[services_constants.LUNARCRUSH_COIN_METRICS]: + continue + events = [] + for entry in self.data_cache[services_constants.LUNARCRUSH_COIN_METRICS][coin]: + ts_ms = entry.time * 1000 + if start_timestamp <= ts_ms <= end_timestamp: + payload = dataclasses.asdict(entry) if dataclasses.is_dataclass(entry) else entry + if not isinstance(payload, dict): + payload = dataclasses.asdict(entry) + events.append({ + "timestamp": ts_ms, + "channel": source or services_constants.LUNARCRUSH_COIN_METRICS, + "symbol": coin, + "payload": payload, + }) + if events: + events.sort(key=lambda x: x["timestamp"]) + yield events + async def stop(self): await super().stop() if self.listener_task is not None: 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/ai_index_distribution.py b/Trading/Mode/ai_trading_mode/ai_index_distribution.py new file mode 100644 index 000000000..fed47d427 --- /dev/null +++ b/Trading/Mode/ai_trading_mode/ai_index_distribution.py @@ -0,0 +1,112 @@ +# 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 + +from tentacles.Agent.Trading.distribution_agent.constants import ( + INSTRUCTION_ACTION, + INSTRUCTION_SYMBOL, + INSTRUCTION_AMOUNT, + INSTRUCTION_WEIGHT, + ACTION_REDUCE_EXPOSURE, + ACTION_INCREASE_EXPOSURE, + ACTION_ADD_TO_DISTRIBUTION, + ACTION_REMOVE_FROM_DISTRIBUTION, + ACTION_UPDATE_RATIO, + ACTION_INCREASE_FIAT_RATIO, + ACTION_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..13ea977eb --- /dev/null +++ b/Trading/Mode/ai_trading_mode/ai_index_trading.py @@ -0,0 +1,548 @@ +# 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 typing +from datetime import datetime + +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_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.team import TradingAgentTeam + +# 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} + + 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, + 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 agents sequentially 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 + 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 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: {self.trading_mode.indexed_coins}" + ) + return + + self.logger.debug("All strategy data collected. Running AI agents...") + + try: + await self._run_agents() + except Exception as e: + self.logger.exception(f"Error running AI agents: {e}") + + async def _run_agents(self): + """ + 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 + """ + ai_service = await self._get_ai_service() + if ai_service is None: + self.logger.error("Failed to create AI service. Check AI configuration.") + return + + # Build state + state = self._build_agent_state() + + self.logger.debug("Running TradingAgentTeam for portfolio distribution analysis...") + + # Create and run the team + team = TradingAgentTeam(ai_service=ai_service) + + try: + distribution_output = await team.run_with_state(state) + except Exception as e: + self.logger.exception(f"TradingAgentTeam execution failed: {e}") + return + + # Structured logging of debate/judge outputs when present + if getattr(team, "last_debate_state", None): + debate_state = team.last_debate_state + if getattr(self.trading_mode, "log_ai_decisions", False): + self.logger.info( + "Debate state: %s", + str(debate_state), + ) + self.logger.debug( + "Debate history (%s entries), judge decisions (%s entries)", + len(debate_state.get("debate_history", [])), + len(debate_state.get("judge_decisions", [])), + ) + for entry in debate_state.get("judge_decisions", []): + self.logger.debug( + "Judge round %s: decision=%s reasoning=%s", + entry.get("round"), + entry.get("decision"), + (entry.get("reasoning") or "")[:200], + ) + + if distribution_output is None: + self.logger.warning("Agent team returned no distribution output.") + return + + self.logger.info( + 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() + + # Get all traded symbols from exchange manager + traded_symbols = trading_api.get_trading_symbols(self.exchange_manager, include_additional_pairs=True) + # Extract both base and quote currencies (cryptocurrencies) from symbols + traded_cryptocurrencies = list(set( + currency for symbol in traded_symbols + for currency in [symbol.base, symbol.quote] + )) + # Combine with indexed_coins to ensure all configured coins are included + indexed_cryptocurrencies = list(self.trading_mode.indexed_coins) if self.trading_mode.indexed_coins else [] + # Merge and deduplicate + cryptocurrencies = list(set(traded_cryptocurrencies + indexed_cryptocurrencies)) + + 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_ai_service(self): + ai_service = await services_api.get_ai_service( + is_backtesting=self.exchange_manager.is_backtesting + ) + if not ai_service: + self.logger.error("AIService not available, cannot perform AI analysis") + return None + return ai_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): + MODE_PRODUCER_CLASSES = [AIIndexTradingModeProducer] + MODE_CONSUMER_CLASSES = [AIIndexTradingModeConsumer] + + # AI-specific config keys + MODEL_KEY = "model" + TEMPERATURE_KEY = "temperature" + MAX_TOKENS_KEY = "max_tokens" + LOG_AI_DECISIONS_KEY = "log_ai_decisions" + + 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.", + ) + + self.UI.user_input( + self.LOG_AI_DECISIONS_KEY, + commons_enums.UserInputTypes.BOOLEAN, + inputs.get(self.LOG_AI_DECISIONS_KEY), + inputs, + title="Log debate/judge decisions at INFO (verbose).", + ) 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/team.py b/Trading/Mode/ai_trading_mode/team.py new file mode 100644 index 000000000..ed5d1edae --- /dev/null +++ b/Trading/Mode/ai_trading_mode/team.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. + +""" +AI Trading Agent Team. +Orchestrates Signal, Bull/Bear Research agents, Risk Judge, and Distribution agents for portfolio management. + +DAG Structure: + Signal ──┬──> Bull Research ──┐ + │ │ + └──────> Bear Research ──┼──> Risk Judge ───> Distribution + │ + └───────────────────┘ + +The Distribution agent receives inputs from Signal and Risk Judge. +""" +import typing + +import octobot_agents as agent + +from tentacles.Agent.Trading.signal_agent import ( + SignalAIAgentChannel, + SignalAIAgentProducer, +) +from tentacles.Agent.Trading.bull_bear_research_agent import ( + BullResearchAIAgentChannel, + BullResearchAIAgentProducer, + BearResearchAIAgentChannel, + BearResearchAIAgentProducer, +) +from tentacles.Agent.Trading.risk_judge_agent import RiskJudgeAIAgentProducer +from octobot_agents.team.judge.channels.judge_agent import AIJudgeAgentChannel +from tentacles.Agent.Trading.distribution_agent import ( + DistributionAIAgentChannel, + DistributionAIAgentProducer, + DistributionOutput, +) +from tentacles.Agent.Critic.default_critic_agent import DefaultAICriticAgentProducer +from tentacles.Agent.Memory.default_memory_agent import DefaultAIMemoryAgentProducer +from tentacles.Agent.Manager.default_manager_agent import AIToolsTeamManagerAgentProducer + + +class TradingAgentTeamChannel(agent.AbstractAgentsTeamChannel): + """Channel for TradingAgentTeam outputs.""" + OUTPUT_SCHEMA = DistributionOutput + + +class TradingAgentTeamConsumer(agent.AbstractAgentsTeamChannelConsumer): + """Consumer for TradingAgentTeam outputs.""" + pass + + +class TradingAgentTeam(agent.AbstractSyncAgentsTeamChannelProducer): + """ + Sync team that orchestrates trading agents for portfolio distribution. + + Execution flow: + 1. Signal agent analyzes cryptocurrencies and generates signals + 2. Bull and Bear research agents debate the market outlook + 3. Risk judge evaluates the debate and provides risk assessment + 4. 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 + + CriticAgentClass = DefaultAICriticAgentProducer + MemoryAgentClass = DefaultAIMemoryAgentProducer + ManagerAgentClass = AIToolsTeamManagerAgentProducer + + 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, + ) + + bull_producer = BullResearchAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + + bear_producer = BearResearchAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + + risk_judge_producer = RiskJudgeAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + + distribution_producer = DistributionAIAgentProducer( + channel=None, + model=model, + max_tokens=max_tokens, + temperature=temperature, + ) + # Store reference for result lookup + self.distribution_producer = distribution_producer + + agents = [signal_producer, bull_producer, bear_producer, risk_judge_producer, distribution_producer] + + # Define relations: + # Signal -> Bull Research (Bull needs signal data) + # Signal -> Bear Research (Bear needs signal data) + # Bull Research -> Risk Judge (Judge evaluates bull arguments) + # Bear Research -> Risk Judge (Judge evaluates bear arguments) + # Signal -> Distribution (Distribution needs signal outputs) + # Risk Judge -> Distribution (Distribution needs risk assessment) + relations = [ + (SignalAIAgentChannel, BullResearchAIAgentChannel), + (SignalAIAgentChannel, BearResearchAIAgentChannel), + (BullResearchAIAgentChannel, AIJudgeAgentChannel), + (BearResearchAIAgentChannel, AIJudgeAgentChannel), + (SignalAIAgentChannel, DistributionAIAgentChannel), + (AIJudgeAgentChannel, DistributionAIAgentChannel), + ] + + super().__init__( + channel=channel, + agents=agents, + relations=relations, + ai_service=ai_service, + team_name=self.TEAM_NAME, + team_id=team_id, + self_improving=True, + ) + + 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 using the actual agent name + distribution_result = results.get(self.distribution_producer.name) + if distribution_result is None: + return None + + # Handle nested result format from tools-driven manager + # The result is wrapped as {"agent_name": "...", "result": actual_output} + if isinstance(distribution_result, dict) and "result" in distribution_result: + actual_result = distribution_result["result"] + else: + actual_result = distribution_result + + # The actual result should contain distribution_output + if isinstance(actual_result, dict) and "distribution_output" in actual_result: + return actual_result["distribution_output"] + + # Direct DistributionOutput object + return actual_result 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
@@ -55,7 +60,11 @@

Backtesting data-end-timestamp="{{description.end_timestamp}}"> {{description.start_date}} to {{description.end_date}}

-Full{{description.date}} {{description.candles_length}}
{{", ".join(description.symbols)}}{{description.start_date}} to {{description.end_date}}-Full{{description.date}} {{description.candles_length}}