data fixes, partial custom indicator support

This commit is contained in:
2026-04-08 21:28:31 -04:00
parent b701554996
commit a70dcd954f
81 changed files with 5438 additions and 1852 deletions

View File

@@ -10,6 +10,7 @@ Brings together:
"""
import asyncio
import contextlib
import logging
import os
import signal
@@ -20,8 +21,8 @@ from typing import Optional
import uvicorn
import yaml
from mcp.server import Server
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.types import Tool, TextContent, ImageContent
from starlette.applications import Starlette
from starlette.requests import Request
@@ -34,8 +35,11 @@ from dexorder.conda_manager import sync_packages, install_packages
from dexorder.events import EventType, UserEvent, DeliverySpec
from dexorder.impl.charting_api_impl import ChartingAPIImpl
from dexorder.impl.data_api_impl import DataAPIImpl
from dexorder.tools.category_tools import get_category_manager
from dexorder.tools.python_tools import get_category_manager
from dexorder.tools.workspace_tools import get_workspace_store
from dexorder.tools.evaluate_indicator import evaluate_indicator
from dexorder.tools.backtest_strategy import backtest_strategy
from dexorder.tools.activate_strategy import activate_strategy, deactivate_strategy, list_active_strategies
# =============================================================================
# Global Data Directory
@@ -249,7 +253,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
}
),
Tool(
name="category_write",
name="python_write",
description="Write a new strategy, indicator, or research script with validation",
inputSchema={
"type": "object",
@@ -273,15 +277,27 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
},
"metadata": {
"type": "object",
"description": "Optional category-specific metadata (e.g., default_length for indicators, data_feeds for strategies)"
"description": (
"Optional category-specific metadata. "
"For strategy: include 'data_feeds' (list of {symbol, period_seconds, description}) "
"and 'parameters' (object mapping param_name → {default, description}). "
"Example: {\"data_feeds\": [{\"symbol\": \"BTC/USDT.BINANCE\", \"period_seconds\": 3600, \"description\": \"Primary BTC/USDT hourly feed\"}], "
"\"parameters\": {\"rsi_length\": {\"default\": 14, \"description\": \"RSI lookback period\"}, \"threshold\": {\"default\": 70, \"description\": \"Overbought level\"}}}. "
"For indicator: include 'default_length' (int). "
"For any category: 'conda_packages' (list of package names) if extra dependencies are needed."
)
}
},
"required": ["category", "name", "description", "code"]
}
),
Tool(
name="category_edit",
description="Edit an existing category script (updates code, description, or metadata)",
name="python_edit",
description=(
"Edit an existing category script. "
"Use 'patches' for targeted string replacements (preferred for small changes), "
"or 'code' to replace the full implementation. Do not supply both."
),
inputSchema={
"type": "object",
"properties": {
@@ -296,7 +312,24 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
},
"code": {
"type": "string",
"description": "Updated Python code (optional, omit to keep existing)"
"description": "Full replacement Python code. Use only when rewriting the entire implementation; prefer 'patches' for targeted edits."
},
"patches": {
"type": "array",
"description": (
"Targeted code edits as old/new string pairs. Preferred over 'code' for small changes. "
"Each patch: {\"old_string\": \"exact text to find\", \"new_string\": \"replacement text\"}. "
"old_string must be unique in the file (add surrounding context if needed). "
"Patches are applied in order."
),
"items": {
"type": "object",
"properties": {
"old_string": {"type": "string"},
"new_string": {"type": "string"}
},
"required": ["old_string", "new_string"]
}
},
"description": {
"type": "string",
@@ -304,14 +337,20 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
},
"metadata": {
"type": "object",
"description": "Updated metadata fields (optional)"
"description": (
"Updated metadata fields (optional). "
"For strategy: 'data_feeds' (list of {symbol, period_seconds, description}) "
"and/or 'parameters' (object mapping param_name → {default, description}). "
"For indicator: 'default_length' (int). "
"For any category: 'conda_packages' (list of package names)."
)
}
},
"required": ["category", "name"]
}
),
Tool(
name="category_read",
name="python_read",
description="Read a category script and its metadata",
inputSchema={
"type": "object",
@@ -330,7 +369,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
}
),
Tool(
name="category_list",
name="python_list",
description="List all items in a category with names and descriptions",
inputSchema={
"type": "object",
@@ -344,6 +383,53 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
"required": ["category"]
}
),
Tool(
name="python_log",
description="Show git commit history for category items. Filter by category and/or name to see history for a specific item.",
inputSchema={
"type": "object",
"properties": {
"category": {
"type": "string",
"enum": ["strategy", "indicator", "research"],
"description": "Filter to this category (optional)"
},
"name": {
"type": "string",
"description": "Filter to this item (optional, requires category)"
},
"limit": {
"type": "integer",
"description": "Max commits to return (default 20)",
"default": 20
}
},
"required": []
}
),
Tool(
name="python_revert",
description="Restore a category item to a previous git revision. Creates a new commit — non-destructive.",
inputSchema={
"type": "object",
"properties": {
"revision": {
"type": "string",
"description": "Git commit hash (full or short) to restore to"
},
"category": {
"type": "string",
"enum": ["strategy", "indicator", "research"],
"description": "Category of the item"
},
"name": {
"type": "string",
"description": "Display name of the item to restore"
}
},
"required": ["revision", "category", "name"]
}
),
Tool(
name="conda_sync",
description="Sync conda packages: scan all metadata, remove unused packages (excluding base environment)",
@@ -381,13 +467,179 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
},
"required": ["name"]
}
)
),
Tool(
name="evaluate_indicator",
description=(
"Evaluate a pandas-ta indicator against real OHLC data and return a structured "
"array of timestamped values. Use this to validate that an indicator computes "
"correctly before adding it to the workspace, or to inspect its output values."
),
inputSchema={
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "Market symbol in 'MARKET.EXCHANGE' format, e.g. 'BTC/USDT.BINANCE'"
},
"from_time": {
"description": "Start of time range. Unix timestamp (int) or date string e.g. '30 days ago', '2024-01-01'"
},
"to_time": {
"description": "End of time range. Unix timestamp (int) or date string e.g. 'now', '2024-03-01'"
},
"period_seconds": {
"type": "integer",
"description": "Candle period in seconds (e.g. 3600 for 1h, 900 for 15m, 86400 for 1d)",
"default": 3600
},
"pandas_ta_name": {
"type": "string",
"description": "Lowercase pandas-ta function name, e.g. 'rsi', 'macd', 'bbands'"
},
"parameters": {
"type": "object",
"description": "pandas-ta keyword arguments, e.g. {\"length\": 14} or {\"fast\": 12, \"slow\": 26, \"signal\": 9}",
"default": {}
}
},
"required": ["symbol", "from_time", "to_time", "pandas_ta_name"]
}
),
Tool(
name="backtest_strategy",
description=(
"Run a saved trading strategy against historical OHLC data using Nautilus Trader "
"BacktestEngine. Returns performance metrics (total return, Sharpe ratio, "
"max drawdown, win rate, trade count) and a full equity curve. "
"Supports multiple data feeds and includes order-flow fields (buy_vol, sell_vol, "
"open_interest) in the strategy's DataFrame."
),
inputSchema={
"type": "object",
"properties": {
"strategy_name": {
"type": "string",
"description": "Display name of the strategy as saved via python_write"
},
"feeds": {
"type": "array",
"description": "Data feeds to backtest against",
"items": {
"type": "object",
"properties": {
"symbol": {
"type": "string",
"description": "Market symbol in 'MARKET.EXCHANGE' format, e.g. 'BTC/USDT.BINANCE'"
},
"period_seconds": {
"type": "integer",
"description": "Candle period in seconds (e.g. 3600 for 1h)",
"default": 3600
}
},
"required": ["symbol"]
},
"minItems": 1
},
"from_time": {
"description": "Backtest start. Unix timestamp or date string e.g. '2024-01-01', '90 days ago'"
},
"to_time": {
"description": "Backtest end. Unix timestamp or date string e.g. '2025-01-01', 'now'"
},
"initial_capital": {
"type": "number",
"description": "Starting capital in quote currency (e.g. 10000.0 USDT)",
"default": 10000.0
},
"paper": {
"type": "boolean",
"description": "Always true for historical backtest (reserved for forward testing)",
"default": True
}
},
"required": ["strategy_name", "feeds", "from_time", "to_time"]
}
),
Tool(
name="activate_strategy",
description=(
"Activate a strategy for paper or live forward trading with a capital allocation. "
"paper=true (default): simulated fills on live data — no API keys required. "
"paper=false: real execution via user secrets vault (not yet implemented). "
"Note: live data streaming is TBD; this registers the strategy for when it becomes available."
),
inputSchema={
"type": "object",
"properties": {
"strategy_name": {
"type": "string",
"description": "Display name of the strategy as saved via python_write"
},
"feeds": {
"type": "array",
"description": "Data feeds for the strategy",
"items": {
"type": "object",
"properties": {
"symbol": {"type": "string"},
"period_seconds": {"type": "integer", "default": 3600}
},
"required": ["symbol"]
},
"minItems": 1
},
"allocation": {
"type": "number",
"description": "Capital allocated in quote currency (e.g. 5000.0 USDT)"
},
"paper": {
"type": "boolean",
"description": "True = paper/simulated (default); False = live execution",
"default": True
}
},
"required": ["strategy_name", "feeds", "allocation"]
}
),
Tool(
name="deactivate_strategy",
description="Stop an active strategy and return its final P&L summary.",
inputSchema={
"type": "object",
"properties": {
"strategy_name": {
"type": "string",
"description": "Display name of the active strategy to stop"
}
},
"required": ["strategy_name"]
}
),
Tool(
name="list_active_strategies",
description="List all currently active (live or paper) strategies and their status.",
inputSchema={
"type": "object",
"properties": {},
"required": []
}
),
]
@server.call_tool()
async def handle_tool_call(name: str, arguments: dict):
"""Handle tool calls including workspace and category tools"""
get_lifecycle_manager().record_activity()
try:
return await _handle_tool_call_inner(name, arguments)
except Exception:
logging.exception("Unhandled exception in tool '%s'", name)
raise
async def _handle_tool_call_inner(name: str, arguments: dict):
if name == "workspace_read":
return workspace_store.read(arguments.get("store_name", ""))
elif name == "workspace_write":
@@ -400,7 +652,7 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
arguments.get("store_name", ""),
arguments.get("patch", [])
)
elif name == "category_write":
elif name == "python_write":
result = category_manager.write(
category=arguments.get("category", ""),
name=arguments.get("name", ""),
@@ -410,6 +662,8 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
)
content = []
meta_parts = [f"success: {result['success']}", f"path: {result['path']}"]
if result.get("revision"):
meta_parts.append(f"revision: {result['revision']}")
if result.get("validation") and not result["validation"].get("success"):
meta_parts.append(f"validation errors: {result['validation'].get('errors', [])}")
content.append(TextContent(type="text", text="\n".join(meta_parts)))
@@ -417,20 +671,23 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
exec_content = result["execution"].get("content", [])
content.extend(exec_content)
image_count = sum(1 for item in exec_content if item.type == "image")
logging.info(f"category_write '{arguments.get('name')}': returning {len(content)} items, {image_count} images")
logging.info(f"python_write '{arguments.get('name')}': returning {len(content)} items, {image_count} images")
else:
logging.info(f"category_write '{arguments.get('name')}': no execution result (category={arguments.get('category')})")
logging.info(f"python_write '{arguments.get('name')}': no execution result (category={arguments.get('category')})")
return content
elif name == "category_edit":
elif name == "python_edit":
result = category_manager.edit(
category=arguments.get("category", ""),
name=arguments.get("name", ""),
code=arguments.get("code"),
patches=arguments.get("patches"),
description=arguments.get("description"),
metadata=arguments.get("metadata")
)
content = []
meta_parts = [f"success: {result['success']}", f"path: {result['path']}"]
if result.get("revision"):
meta_parts.append(f"revision: {result['revision']}")
if result.get("validation") and not result["validation"].get("success"):
meta_parts.append(f"validation errors: {result['validation'].get('errors', [])}")
content.append(TextContent(type="text", text="\n".join(meta_parts)))
@@ -438,19 +695,43 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
exec_content = result["execution"].get("content", [])
content.extend(exec_content)
image_count = sum(1 for item in exec_content if item.type == "image")
logging.info(f"category_edit '{arguments.get('name')}': returning {len(content)} items, {image_count} images")
logging.info(f"python_edit '{arguments.get('name')}': returning {len(content)} items, {image_count} images")
else:
logging.info(f"category_edit '{arguments.get('name')}': no execution result")
logging.info(f"python_edit '{arguments.get('name')}': no execution result")
return content
elif name == "category_read":
elif name == "python_read":
return category_manager.read(
category=arguments.get("category", ""),
name=arguments.get("name", "")
)
elif name == "category_list":
elif name == "python_list":
return category_manager.list_items(
category=arguments.get("category", "")
)
elif name == "python_log":
result = category_manager.git_log(
category=arguments.get("category"),
name=arguments.get("name"),
limit=int(arguments.get("limit", 20))
)
lines = [f"success: {result['success']}"]
for c in result.get("commits", []):
lines.append(f"{c['short_hash']} {c['date'][:10]} {c['message']}")
return [TextContent(type="text", text="\n".join(lines))]
elif name == "python_revert":
result = category_manager.git_revert(
revision=arguments.get("revision", ""),
category=arguments.get("category", ""),
name=arguments.get("name", "")
)
meta_parts = [f"success: {result['success']}"]
if result.get("revision"):
meta_parts.append(f"revision: {result['revision']}")
if result.get("error"):
meta_parts.append(f"error: {result['error']}")
if result.get("validation") and not result["validation"].get("success"):
meta_parts.append(f"validation errors: {result['validation'].get('errors', [])}")
return [TextContent(type="text", text="\n".join(meta_parts))]
elif name == "conda_sync":
# Get environment.yml path relative to main.py
env_yml = Path(__file__).parent / "environment.yml"
@@ -469,6 +750,37 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
image_count = sum(1 for item in content if item.type == "image")
logging.info(f"execute_research '{arguments.get('name')}': returning {len(content)} items, {image_count} images")
return content
elif name == "evaluate_indicator":
return await evaluate_indicator(
symbol=arguments.get("symbol", ""),
from_time=arguments.get("from_time"),
to_time=arguments.get("to_time"),
period_seconds=int(arguments.get("period_seconds", 3600)),
pandas_ta_name=arguments.get("pandas_ta_name", ""),
parameters=arguments.get("parameters") or {},
)
elif name == "backtest_strategy":
return await backtest_strategy(
strategy_name=arguments.get("strategy_name", ""),
feeds=arguments.get("feeds", []),
from_time=arguments.get("from_time"),
to_time=arguments.get("to_time"),
initial_capital=float(arguments.get("initial_capital", 10_000.0)),
paper=bool(arguments.get("paper", True)),
)
elif name == "activate_strategy":
return await activate_strategy(
strategy_name=arguments.get("strategy_name", ""),
feeds=arguments.get("feeds", []),
allocation=float(arguments.get("allocation", 0.0)),
paper=bool(arguments.get("paper", True)),
)
elif name == "deactivate_strategy":
return await deactivate_strategy(
strategy_name=arguments.get("strategy_name", ""),
)
elif name == "list_active_strategies":
return await list_active_strategies()
else:
raise ValueError(f"Unknown tool: {name}")
@@ -477,26 +789,18 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
# =============================================================================
# SSE Transport Setup
# Streamable HTTP Transport Setup
# =============================================================================
def create_sse_app(mcp_server: Server) -> Starlette:
"""Create Starlette app with SSE endpoint for MCP"""
def create_streamable_http_app(mcp_server: Server) -> Starlette:
"""Create Starlette app with Streamable HTTP endpoint for MCP"""
# Create SSE transport instance
sse = SseServerTransport("/messages/")
session_manager = StreamableHTTPSessionManager(app=mcp_server)
async def handle_sse(request: Request) -> Response:
"""Handle SSE connections for MCP"""
async with sse.connect_sse(
request.scope, request.receive, request._send
) as streams:
await mcp_server.run(
streams[0],
streams[1],
mcp_server.create_initialization_options()
)
return Response()
@contextlib.asynccontextmanager
async def lifespan(app: Starlette):
async with session_manager.run():
yield
async def handle_health(request: Request) -> Response:
"""Health check endpoint for k8s probes and gateway readiness checks"""
@@ -506,9 +810,9 @@ def create_sse_app(mcp_server: Server) -> Starlette:
)
app = Starlette(
lifespan=lifespan,
routes=[
Route("/sse", handle_sse),
Mount("/messages/", app=sse.handle_post_message),
Mount("/mcp", app=session_manager.handle_request),
Route("/health", handle_health),
]
)
@@ -648,9 +952,9 @@ class UserContainer:
self.mcp_server.create_initialization_options()
)
elif self.config.mcp_transport == "sse":
# Run MCP server via HTTP/SSE (for production)
logging.info(f"Starting MCP server with SSE transport on {self.config.mcp_http_host}:{self.config.mcp_http_port}")
app = create_sse_app(self.mcp_server)
# Run MCP server via Streamable HTTP (for production)
logging.info(f"Starting MCP server with Streamable HTTP transport on {self.config.mcp_http_host}:{self.config.mcp_http_port}")
app = create_streamable_http_app(self.mcp_server)
config = uvicorn.Config(
app,
host=self.config.mcp_http_host,