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 @@
@@ -55,7 +60,11 @@ Backtesting data-end-timestamp="{{description.end_timestamp}}"> {{description.start_date}} to {{description.end_date}} |
+ {% if description.data_type == "social" %}
+ - | + {% else %}Full | + {% endif %} {% else %}{{description.date}} | {{description.candles_length}} | @@ -122,6 +131,7 @@|
| {{", ".join(description.symbols)}} | {% if description.start_timestamp %}{{description.start_date}} to {{description.end_date}} | + {% if description.data_type == "social" %} +- | + {% else %}Full | + {% endif %} {% else %}{{description.date}} | {{description.candles_length}} | 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 %} + +