feat: init the repo
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
"""
|
||||
ferro_ta.mcp — Model Context Protocol (MCP) Server
|
||||
==================================================
|
||||
|
||||
An MCP server that exposes ferro_ta indicators and backtest tools to
|
||||
AI agents (e.g. Claude in Cursor, LangChain, OpenAI function calling).
|
||||
|
||||
Running the server
|
||||
------------------
|
||||
Start the server directly::
|
||||
|
||||
python -m ferro_ta.mcp
|
||||
|
||||
Or with ``uvicorn`` / ``mcp`` runner if the official MCP SDK is installed::
|
||||
|
||||
uvicorn ferro_ta.mcp:app --port 8765
|
||||
|
||||
Cursor integration
|
||||
------------------
|
||||
Add the following to your Cursor MCP settings
|
||||
(``~/.cursor/mcp.json`` or workspace ``.cursor/mcp.json``)::
|
||||
|
||||
{
|
||||
"mcpServers": {
|
||||
"ferro-ta": {
|
||||
"command": "python",
|
||||
"args": ["-m", "ferro_ta.mcp"],
|
||||
"description": "ferro_ta technical analysis tools"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
After reloading Cursor, you can ask the AI assistant things like:
|
||||
|
||||
* "Compute SMA(14) on this price series: [100, 102, ...]"
|
||||
* "Run a backtest with RSI 30/70 strategy on this data"
|
||||
* "list all available indicators"
|
||||
|
||||
See ``docs/mcp.md`` for the full guide.
|
||||
|
||||
Install optional dependency
|
||||
---------------------------
|
||||
The MCP server requires the ``mcp`` SDK::
|
||||
|
||||
pip install ferro-ta[mcp]
|
||||
|
||||
or::
|
||||
|
||||
pip install "mcp>=1.0"
|
||||
|
||||
Tools exposed
|
||||
-------------
|
||||
* ``sma`` — Simple Moving Average
|
||||
* ``ema`` — Exponential Moving Average
|
||||
* ``rsi`` — Relative Strength Index
|
||||
* ``macd`` — MACD line, signal, histogram
|
||||
* ``backtest`` — Run a vectorized backtest
|
||||
* ``list_indicators``— list all registered indicators
|
||||
* ``describe_indicator`` — Describe an indicator
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ferro_ta.tools import (
|
||||
compute_indicator,
|
||||
describe_indicator,
|
||||
list_indicators,
|
||||
run_backtest,
|
||||
)
|
||||
|
||||
__all__ = ["run_server", "handle_list_tools", "handle_call_tool"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool definitions (JSON-schema style)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_TOOLS: list[dict[str, Any]] = [
|
||||
{
|
||||
"name": "sma",
|
||||
"description": "Compute the Simple Moving Average (SMA) of a price series.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"close": {
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"description": "Close price series.",
|
||||
},
|
||||
"timeperiod": {
|
||||
"type": "integer",
|
||||
"description": "Look-back period (default 14).",
|
||||
"default": 14,
|
||||
},
|
||||
},
|
||||
"required": ["close"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "ema",
|
||||
"description": "Compute the Exponential Moving Average (EMA) of a price series.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"close": {
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"description": "Close price series.",
|
||||
},
|
||||
"timeperiod": {
|
||||
"type": "integer",
|
||||
"description": "Look-back period (default 14).",
|
||||
"default": 14,
|
||||
},
|
||||
},
|
||||
"required": ["close"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "rsi",
|
||||
"description": "Compute the Relative Strength Index (RSI) of a price series.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"close": {
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"description": "Close price series.",
|
||||
},
|
||||
"timeperiod": {
|
||||
"type": "integer",
|
||||
"description": "Look-back period (default 14).",
|
||||
"default": 14,
|
||||
},
|
||||
},
|
||||
"required": ["close"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "macd",
|
||||
"description": (
|
||||
"Compute MACD (Moving Average Convergence/Divergence). "
|
||||
"Returns macd line, signal line, and histogram."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"close": {
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"description": "Close price series.",
|
||||
},
|
||||
"fastperiod": {
|
||||
"type": "integer",
|
||||
"description": "Fast EMA period (default 12).",
|
||||
"default": 12,
|
||||
},
|
||||
"slowperiod": {
|
||||
"type": "integer",
|
||||
"description": "Slow EMA period (default 26).",
|
||||
"default": 26,
|
||||
},
|
||||
"signalperiod": {
|
||||
"type": "integer",
|
||||
"description": "Signal EMA period (default 9).",
|
||||
"default": 9,
|
||||
},
|
||||
},
|
||||
"required": ["close"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "backtest",
|
||||
"description": (
|
||||
"Run a vectorized backtest on close prices using a named strategy. "
|
||||
"Returns final equity, number of trades, and the equity curve."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"close": {
|
||||
"type": "array",
|
||||
"items": {"type": "number"},
|
||||
"description": "Close price series (at least 2 bars).",
|
||||
},
|
||||
"strategy": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Strategy name: 'rsi_30_70', 'sma_crossover', or 'macd_crossover'."
|
||||
),
|
||||
"default": "rsi_30_70",
|
||||
},
|
||||
"commission_per_trade": {
|
||||
"type": "number",
|
||||
"description": "Fixed commission per trade (default 0).",
|
||||
"default": 0.0,
|
||||
},
|
||||
"slippage_bps": {
|
||||
"type": "number",
|
||||
"description": "Slippage in basis points (default 0).",
|
||||
"default": 0.0,
|
||||
},
|
||||
},
|
||||
"required": ["close"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "list_indicators",
|
||||
"description": "list all available indicator names registered in ferro_ta.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "describe_indicator",
|
||||
"description": "Return a description of a named ferro_ta indicator.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Indicator name (e.g. 'SMA', 'RSI', 'BBANDS').",
|
||||
}
|
||||
},
|
||||
"required": ["name"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def handle_list_tools() -> dict[str, Any]:
|
||||
"""Return the ListTools response."""
|
||||
return {"tools": _TOOLS}
|
||||
|
||||
|
||||
def handle_call_tool(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Dispatch a CallTool request and return the result.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name : str
|
||||
Tool name (one of the ``_TOOLS`` entries).
|
||||
arguments : dict
|
||||
Tool arguments as provided by the MCP client.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
MCP content response with type ``"text"`` containing the JSON result.
|
||||
"""
|
||||
try:
|
||||
if name in ("sma", "ema", "rsi"):
|
||||
close = np.asarray(arguments["close"], dtype=np.float64)
|
||||
timeperiod = int(arguments.get("timeperiod", 14))
|
||||
result = compute_indicator(name.upper(), close, timeperiod=timeperiod)
|
||||
# Replace NaN with None for JSON serialisation
|
||||
payload = [None if np.isnan(v) else float(v) for v in result]
|
||||
return {"content": [{"type": "text", "text": json.dumps(payload)}]}
|
||||
|
||||
elif name == "macd":
|
||||
close = np.asarray(arguments["close"], dtype=np.float64)
|
||||
kwargs = {
|
||||
"fastperiod": int(arguments.get("fastperiod", 12)),
|
||||
"slowperiod": int(arguments.get("slowperiod", 26)),
|
||||
"signalperiod": int(arguments.get("signalperiod", 9)),
|
||||
}
|
||||
result = compute_indicator("MACD", close, **kwargs)
|
||||
assert isinstance(result, dict)
|
||||
macd_payload = {
|
||||
k: [None if np.isnan(v) else float(v) for v in arr]
|
||||
for k, arr in result.items()
|
||||
}
|
||||
return {"content": [{"type": "text", "text": json.dumps(macd_payload)}]}
|
||||
|
||||
elif name == "backtest":
|
||||
close = np.asarray(arguments["close"], dtype=np.float64)
|
||||
strategy = str(arguments.get("strategy", "rsi_30_70"))
|
||||
commission = float(arguments.get("commission_per_trade", 0.0))
|
||||
slippage = float(arguments.get("slippage_bps", 0.0))
|
||||
summary = run_backtest(
|
||||
strategy,
|
||||
close,
|
||||
commission_per_trade=commission,
|
||||
slippage_bps=slippage,
|
||||
)
|
||||
# JSON-serialise (equity is already a list)
|
||||
return {"content": [{"type": "text", "text": json.dumps(summary)}]}
|
||||
|
||||
elif name == "list_indicators":
|
||||
return {
|
||||
"content": [{"type": "text", "text": json.dumps(list_indicators())}]
|
||||
}
|
||||
|
||||
elif name == "describe_indicator":
|
||||
ind_name = str(arguments["name"])
|
||||
description = describe_indicator(ind_name)
|
||||
return {"content": [{"type": "text", "text": description}]}
|
||||
|
||||
else:
|
||||
return {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Unknown tool: {name!r}"}],
|
||||
}
|
||||
|
||||
except Exception as exc:
|
||||
return {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Error: {exc}"}],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stdio MCP server (JSON-RPC over stdin/stdout)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def run_server() -> None: # pragma: no cover
|
||||
"""Run the MCP server over stdin/stdout (JSON-RPC 2.0 protocol).
|
||||
|
||||
This implements a minimal MCP server that handles ``initialize``,
|
||||
``tools/list``, and ``tools/call`` messages. It is compatible with the
|
||||
MCP client built into Cursor (as of early 2025) and with the official
|
||||
`mcp` Python SDK client.
|
||||
|
||||
The server reads one JSON-RPC message per line from stdin and writes
|
||||
one response per line to stdout.
|
||||
"""
|
||||
# Try to use official mcp SDK if available
|
||||
try:
|
||||
_run_with_sdk()
|
||||
except ImportError:
|
||||
_run_stdio_fallback()
|
||||
|
||||
|
||||
def _run_with_sdk() -> None: # pragma: no cover
|
||||
"""Run using the official MCP Python SDK."""
|
||||
import mcp # type: ignore[import]
|
||||
import mcp.server.stdio # type: ignore[import]
|
||||
from mcp.server import Server # type: ignore[import]
|
||||
from mcp.types import ( # type: ignore[import]
|
||||
CallToolRequest,
|
||||
ListToolsRequest,
|
||||
)
|
||||
|
||||
app = Server("ferro-ta")
|
||||
|
||||
@app.list_tools()
|
||||
async def _list_tools(_req: ListToolsRequest):
|
||||
return handle_list_tools()["tools"]
|
||||
|
||||
@app.call_tool()
|
||||
async def _call_tool(req: CallToolRequest):
|
||||
return handle_call_tool(req.params.name, req.params.arguments or {})
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(mcp.server.stdio.stdio_server(app))
|
||||
|
||||
|
||||
def _run_stdio_fallback() -> None: # pragma: no cover
|
||||
"""Minimal stdin/stdout JSON-RPC MCP implementation (no SDK required)."""
|
||||
import json as _json
|
||||
|
||||
for raw_line in sys.stdin:
|
||||
raw_line = raw_line.strip()
|
||||
if not raw_line:
|
||||
continue
|
||||
try:
|
||||
msg = _json.loads(raw_line)
|
||||
except _json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
msg_id = msg.get("id")
|
||||
method = msg.get("method", "")
|
||||
|
||||
if method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "ferro-ta", "version": "0.1.0"},
|
||||
},
|
||||
}
|
||||
elif method == "tools/list":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": handle_list_tools(),
|
||||
}
|
||||
elif method == "tools/call":
|
||||
params = msg.get("params", {})
|
||||
tool_name = params.get("name", "")
|
||||
arguments = params.get("arguments", {})
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": handle_call_tool(tool_name, arguments),
|
||||
}
|
||||
else:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method!r}"},
|
||||
}
|
||||
|
||||
sys.stdout.write(_json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""Entry point so the MCP server can be run as ``python -m ferro_ta.mcp``."""
|
||||
|
||||
from ferro_ta.mcp import run_server
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_server() # pragma: no cover
|
||||
Reference in New Issue
Block a user