oq-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,82 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ wheels/
12
+ develop-eggs/
13
+ eggs/
14
+ parts/
15
+ sdist/
16
+ var/
17
+ *.manifest
18
+ *.spec
19
+ pip-log.txt
20
+ pip-delete-this-directory.txt
21
+
22
+ # uv
23
+ .venv/
24
+ venv/
25
+ env/
26
+ ENV/
27
+ .python-version
28
+
29
+ # Testing / coverage
30
+ .pytest_cache/
31
+ .coverage
32
+ .coverage.*
33
+ htmlcov/
34
+ .tox/
35
+ .nox/
36
+ coverage.xml
37
+ *.cover
38
+ .cache
39
+
40
+ # mypy / ruff
41
+ .mypy_cache/
42
+ .ruff_cache/
43
+ .dmypy.json
44
+ dmypy.json
45
+
46
+ # Jupyter
47
+ .ipynb_checkpoints/
48
+ *.ipynb_checkpoints
49
+
50
+ # Data / artifacts
51
+ data/
52
+ *.parquet
53
+ *.duckdb
54
+ *.duckdb.wal
55
+ *.csv.gz
56
+ *.zip
57
+ .openquant/
58
+
59
+ !packages/*/tests/fixtures/**
60
+
61
+ # IDE / OS
62
+ .idea/
63
+ .vscode/
64
+ *.swp
65
+ *.swo
66
+ .DS_Store
67
+ Thumbs.db
68
+
69
+ # Logs
70
+ *.log
71
+ logs/
72
+
73
+ # Secrets
74
+ .env
75
+ .env.*
76
+ !.env.example
77
+ *.pem
78
+ *.key
79
+
80
+ # build artifacts
81
+ dist/
82
+ *.egg-info/
oq_mcp-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: oq-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing OpenQuant India data, screeners, and honest backtests to LLM clients.
5
+ Project-URL: Homepage, https://github.com/revorhq/openquant
6
+ Project-URL: Repository, https://github.com/revorhq/openquant
7
+ Project-URL: Issues, https://github.com/revorhq/openquant/issues
8
+ Author: OpenQuant India Contributors
9
+ License: Apache-2.0
10
+ Keywords: claude,india,llm,mcp,nse,quant
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Financial and Insurance Industry
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Office/Business :: Financial :: Investment
20
+ Requires-Python: >=3.11
21
+ Requires-Dist: mcp>=1.0
22
+ Requires-Dist: numpy>=1.24
23
+ Requires-Dist: oq-backtest
24
+ Requires-Dist: oq-core
25
+ Requires-Dist: oq-data
26
+ Requires-Dist: pandas>=2.0
27
+ Description-Content-Type: text/markdown
28
+
29
+ # oq-mcp
30
+
31
+ MCP server exposing OpenQuant India data, screeners, and honest backtests to
32
+ LLM clients like Claude Desktop.
33
+
34
+ Tools: `get_prices`, `get_universe`, `screen_stocks`, `get_fundamentals_basic`,
35
+ and the headline `run_backtest` tool — natural-language-parameterized
36
+ backtests with honest Indian-market costs.
37
+
38
+ ```bash
39
+ pip install oq-mcp
40
+ ```
41
+
42
+ Configure in Claude Desktop:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "openquant": { "command": "oq-mcp" }
48
+ }
49
+ }
50
+ ```
51
+
52
+ Then ask Claude: *"Backtest momentum on Nifty 500 with Zerodha costs since 2015."*
53
+
54
+ Part of [OpenQuant India](https://github.com/revorhq/openquant) — honest, open
55
+ source quant infrastructure for Indian markets. Apache 2.0.
oq_mcp-0.1.0/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # oq-mcp
2
+
3
+ MCP server exposing OpenQuant India data, screeners, and honest backtests to
4
+ LLM clients like Claude Desktop.
5
+
6
+ Tools: `get_prices`, `get_universe`, `screen_stocks`, `get_fundamentals_basic`,
7
+ and the headline `run_backtest` tool — natural-language-parameterized
8
+ backtests with honest Indian-market costs.
9
+
10
+ ```bash
11
+ pip install oq-mcp
12
+ ```
13
+
14
+ Configure in Claude Desktop:
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "openquant": { "command": "oq-mcp" }
20
+ }
21
+ }
22
+ ```
23
+
24
+ Then ask Claude: *"Backtest momentum on Nifty 500 with Zerodha costs since 2015."*
25
+
26
+ Part of [OpenQuant India](https://github.com/revorhq/openquant) — honest, open
27
+ source quant infrastructure for Indian markets. Apache 2.0.
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "oq-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server exposing OpenQuant India data, screeners, and honest backtests to LLM clients."
5
+ requires-python = ">=3.11"
6
+ license = { text = "Apache-2.0" }
7
+ readme = "README.md"
8
+ authors = [{ name = "OpenQuant India Contributors" }]
9
+ keywords = ["mcp", "quant", "nse", "india", "llm", "claude"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Intended Audience :: Financial and Insurance Industry",
14
+ "License :: OSI Approved :: Apache Software License",
15
+ "Operating System :: OS Independent",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Topic :: Office/Business :: Financial :: Investment",
20
+ ]
21
+ dependencies = [
22
+ "mcp>=1.0",
23
+ "pandas>=2.0",
24
+ "numpy>=1.24",
25
+ "oq-core",
26
+ "oq-data",
27
+ "oq-backtest",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/revorhq/openquant"
32
+ Repository = "https://github.com/revorhq/openquant"
33
+ Issues = "https://github.com/revorhq/openquant/issues"
34
+
35
+ [project.scripts]
36
+ oq-mcp = "oq_mcp.server:main"
37
+
38
+ [build-system]
39
+ requires = ["hatchling"]
40
+ build-backend = "hatchling.build"
41
+
42
+ [tool.hatch.build.targets.wheel]
43
+ packages = ["src/oq_mcp"]
@@ -0,0 +1,28 @@
1
+ """MCP server exposing OpenQuant India data and honest backtests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from oq_mcp.cache import TTLCache
6
+ from oq_mcp.screener import screen
7
+ from oq_mcp.server import build_server
8
+ from oq_mcp.tools import (
9
+ get_fundamentals_basic,
10
+ get_prices,
11
+ get_universe,
12
+ run_backtest,
13
+ screen_stocks,
14
+ )
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = [
19
+ "TTLCache",
20
+ "__version__",
21
+ "build_server",
22
+ "get_fundamentals_basic",
23
+ "get_prices",
24
+ "get_universe",
25
+ "run_backtest",
26
+ "screen",
27
+ "screen_stocks",
28
+ ]
@@ -0,0 +1,54 @@
1
+ """In-process TTL cache for upstream rate-limit friendliness.
2
+
3
+ The cache is keyed on a hashable tuple and stores any picklable value.
4
+ Entries expire after ``ttl_seconds``; lookups for expired entries are
5
+ treated as misses. A small ``max_entries`` bound keeps memory predictable
6
+ under heavy tool traffic (oldest entries are evicted FIFO).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import time
12
+ from collections import OrderedDict
13
+ from collections.abc import Hashable
14
+ from typing import Generic, TypeVar
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ class TTLCache(Generic[T]):
20
+ def __init__(self, ttl_seconds: float = 300.0, max_entries: int = 1024) -> None:
21
+ if ttl_seconds < 0:
22
+ raise ValueError("ttl_seconds must be >= 0")
23
+ if max_entries < 1:
24
+ raise ValueError("max_entries must be >= 1")
25
+ self._ttl = float(ttl_seconds)
26
+ self._max = int(max_entries)
27
+ self._data: OrderedDict[Hashable, tuple[float, T]] = OrderedDict()
28
+
29
+ def __len__(self) -> int:
30
+ return len(self._data)
31
+
32
+ def get(self, key: Hashable) -> T | None:
33
+ entry = self._data.get(key)
34
+ if entry is None:
35
+ return None
36
+ expires_at, value = entry
37
+ if expires_at < time.monotonic():
38
+ self._data.pop(key, None)
39
+ return None
40
+ self._data.move_to_end(key)
41
+ return value
42
+
43
+ def set(self, key: Hashable, value: T) -> None:
44
+ if key in self._data:
45
+ self._data.move_to_end(key)
46
+ self._data[key] = (time.monotonic() + self._ttl, value)
47
+ while len(self._data) > self._max:
48
+ self._data.popitem(last=False)
49
+
50
+ def clear(self) -> None:
51
+ self._data.clear()
52
+
53
+
54
+ __all__ = ["TTLCache"]
@@ -0,0 +1,131 @@
1
+ """Tiny screener DSL evaluated against a wide adjusted-price frame.
2
+
3
+ Supported expressions (case-insensitive on the field name):
4
+
5
+ * ``close > 100``
6
+ * ``returns_20d > 0.05``
7
+ * ``returns_252d >= 0.10``
8
+ * ``pct_from_52w_high <= 0.05`` (within 5% of 52-week high)
9
+ * ``pct_from_52w_low >= 0.20``
10
+ * ``sma_50_above_sma_200``
11
+ * ``volume > 100000`` (requires a volume frame)
12
+
13
+ Multiple expressions can be combined with ``&`` (AND) or ``|`` (OR).
14
+ Symbols missing data for the most-recent observation are dropped.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from collections.abc import Iterable
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ _NUMERIC_FIELDS = {
26
+ "close",
27
+ "volume",
28
+ "returns_5d",
29
+ "returns_20d",
30
+ "returns_60d",
31
+ "returns_252d",
32
+ "pct_from_52w_high",
33
+ "pct_from_52w_low",
34
+ }
35
+ _BOOLEAN_FIELDS = {"sma_50_above_sma_200"}
36
+
37
+ _OP_RE = re.compile(r"^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*(<=|>=|==|<|>)\s*(-?\d+(?:\.\d+)?)\s*$")
38
+ _BOOL_RE = re.compile(r"^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*$")
39
+
40
+
41
+ def _last(prices: pd.DataFrame, lookback: int) -> pd.Series:
42
+ if lookback <= 0 or lookback >= len(prices):
43
+ return pd.Series(index=prices.columns, dtype=float)
44
+ return prices.iloc[-1] / prices.iloc[-1 - lookback] - 1.0
45
+
46
+
47
+ def _features(prices: pd.DataFrame, volume: pd.DataFrame | None) -> pd.DataFrame:
48
+ if prices.empty:
49
+ return pd.DataFrame()
50
+ last = prices.iloc[-1]
51
+ high_52w = prices.tail(252).max()
52
+ low_52w = prices.tail(252).min()
53
+ sma_50 = prices.tail(50).mean()
54
+ sma_200 = prices.tail(200).mean()
55
+ feats = pd.DataFrame(
56
+ {
57
+ "close": last,
58
+ "returns_5d": _last(prices, 5),
59
+ "returns_20d": _last(prices, 20),
60
+ "returns_60d": _last(prices, 60),
61
+ "returns_252d": _last(prices, 252),
62
+ "pct_from_52w_high": (high_52w - last) / high_52w,
63
+ "pct_from_52w_low": (last - low_52w) / low_52w,
64
+ "sma_50_above_sma_200": sma_50 > sma_200,
65
+ }
66
+ )
67
+ if volume is not None and not volume.empty:
68
+ feats["volume"] = volume.iloc[-1].reindex(feats.index)
69
+ return feats.replace([np.inf, -np.inf], np.nan)
70
+
71
+
72
+ def _parse_atom(expr: str) -> tuple[str, str, float | bool]:
73
+ m = _OP_RE.match(expr)
74
+ if m:
75
+ field, op, num = m.group(1), m.group(2), float(m.group(3))
76
+ if field not in _NUMERIC_FIELDS:
77
+ raise ValueError(f"unknown numeric field: {field}")
78
+ return field, op, num
79
+ m = _BOOL_RE.match(expr)
80
+ if m:
81
+ field = m.group(1)
82
+ if field not in _BOOLEAN_FIELDS:
83
+ raise ValueError(f"unknown boolean field: {field}")
84
+ return field, "==", True
85
+ raise ValueError(f"could not parse screener expression: {expr!r}")
86
+
87
+
88
+ def _apply(feats: pd.DataFrame, atom: tuple[str, str, float | bool]) -> pd.Series:
89
+ field, op, rhs = atom
90
+ series = feats[field]
91
+ if op == ">":
92
+ return series > rhs
93
+ if op == ">=":
94
+ return series >= rhs
95
+ if op == "<":
96
+ return series < rhs
97
+ if op == "<=":
98
+ return series <= rhs
99
+ if op == "==":
100
+ return series == rhs
101
+ raise ValueError(f"unknown operator: {op}")
102
+
103
+
104
+ def screen(
105
+ prices: pd.DataFrame,
106
+ expressions: Iterable[str],
107
+ combine: str = "and",
108
+ volume: pd.DataFrame | None = None,
109
+ universe: Iterable[str] | None = None,
110
+ ) -> list[str]:
111
+ """Return the symbols passing every (or any) supplied expression."""
112
+ exprs = [e.strip() for e in expressions if e and e.strip()]
113
+ if not exprs:
114
+ raise ValueError("at least one expression is required")
115
+ if prices.empty:
116
+ return []
117
+ if universe is not None:
118
+ keep = [s for s in universe if s in prices.columns]
119
+ prices = prices[keep]
120
+ if volume is not None:
121
+ volume = volume[[c for c in volume.columns if c in keep]]
122
+ feats = _features(prices, volume)
123
+ masks = [_apply(feats, _parse_atom(e)) for e in exprs]
124
+ combined = masks[0].astype(bool)
125
+ op = combine.lower()
126
+ for m in masks[1:]:
127
+ combined = (combined & m) if op == "and" else (combined | m)
128
+ return sorted(combined[combined.fillna(False)].index.tolist())
129
+
130
+
131
+ __all__ = ["screen"]
@@ -0,0 +1,134 @@
1
+ """FastMCP server wiring for the OpenQuant India tool set.
2
+
3
+ Run over stdio for desktop MCP clients::
4
+
5
+ uv run oq-mcp
6
+
7
+ or programmatically::
8
+
9
+ from oq_mcp.server import build_server
10
+ server = build_server()
11
+ server.run()
12
+
13
+ Every tool is a thin pass-through to :mod:`oq_mcp.tools` so the underlying
14
+ logic stays trivially testable without the MCP machinery.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import logging
20
+ from collections.abc import Sequence
21
+ from typing import Any
22
+
23
+ from mcp.server.fastmcp import FastMCP
24
+
25
+ from oq_mcp import tools
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ SERVER_INSTRUCTIONS = (
30
+ "OpenQuant India MCP server. Tools expose NSE EOD prices, "
31
+ "point-in-time index universes, a screener DSL, basic fundamentals, "
32
+ "and an honest, cost-aware backtester. All cost numbers are net of "
33
+ "Indian frictions (STT, brokerage, exchange, SEBI, GST, stamp duty, "
34
+ "slippage). Outputs are research artefacts, NOT investment advice."
35
+ )
36
+
37
+
38
+ def build_server(name: str = "openquant-india") -> FastMCP:
39
+ """Build a FastMCP instance with every OpenQuant tool registered."""
40
+ server = FastMCP(name=name, instructions=SERVER_INSTRUCTIONS)
41
+
42
+ @server.tool(
43
+ name="get_prices",
44
+ description="Return adjusted EOD prices for a single NSE symbol.",
45
+ )
46
+ def _get_prices(
47
+ symbol: str,
48
+ start: str | None = None,
49
+ end: str | None = None,
50
+ adjusted: bool = True,
51
+ ) -> dict[str, Any]:
52
+ return tools.get_prices(symbol, start=start, end=end, adjusted=adjusted)
53
+
54
+ @server.tool(
55
+ name="get_universe",
56
+ description="Return point-in-time members of an NSE index (Nifty 50/100/500).",
57
+ )
58
+ def _get_universe(index_name: str, as_of: str) -> dict[str, Any]:
59
+ return tools.get_universe(index_name, as_of=as_of)
60
+
61
+ @server.tool(
62
+ name="screen_stocks",
63
+ description=(
64
+ "Apply a list of screener expressions against the PIT universe. "
65
+ "Expressions: 'returns_252d > 0.10', 'pct_from_52w_high <= 0.05', "
66
+ "'sma_50_above_sma_200', 'close > 100'."
67
+ ),
68
+ )
69
+ def _screen_stocks(
70
+ expressions: Sequence[str],
71
+ index_name: str | None = None,
72
+ as_of: str | None = None,
73
+ lookback_days: int = 300,
74
+ combine: str = "and",
75
+ ) -> dict[str, Any]:
76
+ return tools.screen_stocks(
77
+ expressions,
78
+ index_name=index_name,
79
+ as_of=as_of,
80
+ lookback_days=lookback_days,
81
+ combine=combine,
82
+ )
83
+
84
+ @server.tool(
85
+ name="get_fundamentals_basic",
86
+ description="Return basic reference info (ISIN, series, last close/volume) for a symbol.",
87
+ )
88
+ def _get_fundamentals_basic(symbol: str) -> dict[str, Any]:
89
+ return tools.get_fundamentals_basic(symbol)
90
+
91
+ @server.tool(
92
+ name="run_backtest",
93
+ description=(
94
+ "Run an honest, cost-aware backtest. signals_source in "
95
+ "{momentum, mean_reversion, equal_weight}; costs in "
96
+ "{zerodha, upstox, fyers, dhan, full_service, zero}."
97
+ ),
98
+ )
99
+ def _run_backtest(
100
+ signals_source: str = "momentum",
101
+ index_name: str | None = None,
102
+ start: str | None = None,
103
+ end: str | None = None,
104
+ costs: str = "zerodha",
105
+ slippage_bps: float = 5.0,
106
+ initial_capital: float = 1_000_000.0,
107
+ lookback: int = 252,
108
+ top_k: int = 10,
109
+ schedule: str = "monthly",
110
+ ) -> dict[str, Any]:
111
+ return tools.run_backtest(
112
+ signals_source=signals_source,
113
+ index_name=index_name,
114
+ start=start,
115
+ end=end,
116
+ costs=costs,
117
+ slippage_bps=slippage_bps,
118
+ initial_capital=initial_capital,
119
+ lookback=lookback,
120
+ top_k=top_k,
121
+ schedule=schedule,
122
+ )
123
+
124
+ return server
125
+
126
+
127
+ def main() -> None:
128
+ """Console-script entry point: serve over stdio."""
129
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
130
+ server = build_server()
131
+ server.run()
132
+
133
+
134
+ __all__ = ["SERVER_INSTRUCTIONS", "build_server", "main"]
@@ -0,0 +1,246 @@
1
+ """Pure-Python implementations of every MCP tool.
2
+
3
+ These functions accept primitive arguments (so they serialise cleanly
4
+ across the MCP wire) and return JSON-serialisable dicts. The MCP server
5
+ in :mod:`oq_mcp.server` is a thin wrapper that adapts each function
6
+ into a ``FastMCP`` tool. Keeping the logic here makes the tools easy to
7
+ test offline without spinning up the server.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterable, Sequence
13
+ from datetime import date
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ import oq_backtest as bt
18
+ import oq_data
19
+ import pandas as pd
20
+ from oq_data.config import DataPaths, get_paths
21
+
22
+ from oq_mcp.cache import TTLCache
23
+ from oq_mcp.screener import screen
24
+
25
+ _DEFAULT_CACHE: TTLCache[Any] = TTLCache(ttl_seconds=300.0, max_entries=256)
26
+
27
+
28
+ def _resolve_paths(data_dir: str | Path | None) -> DataPaths:
29
+ return get_paths(data_dir) if data_dir else get_paths()
30
+
31
+
32
+ def _coerce_date(value: str | date | None) -> date | None:
33
+ if value is None:
34
+ return None
35
+ if isinstance(value, date):
36
+ return value
37
+ return pd.to_datetime(value).date()
38
+
39
+
40
+ def _series_to_records(df: pd.DataFrame) -> list[dict[str, Any]]:
41
+ if df.empty:
42
+ return []
43
+ out = df.copy()
44
+ if "date" in out.columns:
45
+ out["date"] = pd.to_datetime(out["date"]).dt.strftime("%Y-%m-%d")
46
+ return out.to_dict(orient="records")
47
+
48
+
49
+ def get_prices(
50
+ symbol: str,
51
+ start: str | None = None,
52
+ end: str | None = None,
53
+ adjusted: bool = True,
54
+ data_dir: str | None = None,
55
+ cache: TTLCache[Any] | None = None,
56
+ ) -> dict[str, Any]:
57
+ """Return adjusted EOD prices for ``symbol`` between ``start`` and ``end``."""
58
+ paths = _resolve_paths(data_dir)
59
+ key = ("prices", symbol, start, end, adjusted, str(paths.root))
60
+ c = cache or _DEFAULT_CACHE
61
+ hit = c.get(key)
62
+ if hit is not None:
63
+ return hit
64
+ df = oq_data.prices(
65
+ symbol,
66
+ start=_coerce_date(start),
67
+ end=_coerce_date(end),
68
+ adjusted=adjusted,
69
+ paths=paths,
70
+ )
71
+ result = {
72
+ "symbol": symbol,
73
+ "adjusted": adjusted,
74
+ "rows": len(df),
75
+ "data": _series_to_records(df),
76
+ }
77
+ c.set(key, result)
78
+ return result
79
+
80
+
81
+ def get_universe(
82
+ index_name: str,
83
+ as_of: str,
84
+ data_dir: str | None = None,
85
+ cache: TTLCache[Any] | None = None,
86
+ ) -> dict[str, Any]:
87
+ """Return PIT membership of ``index_name`` as of ``as_of`` (YYYY-MM-DD)."""
88
+ paths = _resolve_paths(data_dir)
89
+ key = ("universe", index_name, as_of, str(paths.root))
90
+ c = cache or _DEFAULT_CACHE
91
+ hit = c.get(key)
92
+ if hit is not None:
93
+ return hit
94
+ members = oq_data.universe(index_name, as_of, paths=paths)
95
+ result = {
96
+ "index": index_name,
97
+ "as_of": as_of,
98
+ "count": len(members),
99
+ "symbols": members,
100
+ }
101
+ c.set(key, result)
102
+ return result
103
+
104
+
105
+ def screen_stocks(
106
+ expressions: Sequence[str],
107
+ index_name: str | None = None,
108
+ as_of: str | None = None,
109
+ lookback_days: int = 300,
110
+ combine: str = "and",
111
+ data_dir: str | None = None,
112
+ ) -> dict[str, Any]:
113
+ """Run the screener DSL over a (PIT) universe."""
114
+ paths = _resolve_paths(data_dir)
115
+ end = _coerce_date(as_of) or date.today()
116
+ start = end - pd.Timedelta(days=int(lookback_days) + 30)
117
+ if index_name:
118
+ members = oq_data.universe(index_name, end, paths=paths)
119
+ else:
120
+ members = oq_data.list_symbols(paths=paths)
121
+ if not members:
122
+ return {"count": 0, "symbols": [], "universe_size": 0, "as_of": end.isoformat()}
123
+ wide = oq_data.wide_prices(members, start=start, end=end, paths=paths)
124
+ matches = screen(wide, expressions, combine=combine, universe=members)
125
+ return {
126
+ "count": len(matches),
127
+ "symbols": matches,
128
+ "universe_size": len(members),
129
+ "as_of": end.isoformat(),
130
+ "expressions": list(expressions),
131
+ "combine": combine,
132
+ }
133
+
134
+
135
+ def get_fundamentals_basic(
136
+ symbol: str,
137
+ data_dir: str | None = None,
138
+ ) -> dict[str, Any]:
139
+ """Return basic reference information for ``symbol``.
140
+
141
+ Phase 3 ships the *basic* contract only — symbol identity (ISIN,
142
+ series, exchange) plus the latest known close and traded volume.
143
+ Quarterly results and shareholding feeds land in a later phase.
144
+ """
145
+ paths = _resolve_paths(data_dir)
146
+ eod = oq_data.storage.read_prices(symbols=symbol, paths=paths)
147
+ if eod.empty:
148
+ return {"symbol": symbol, "found": False}
149
+ last = eod.iloc[-1]
150
+ master = oq_data.symbols.load_master(paths=paths)
151
+ info: dict[str, Any] = {
152
+ "symbol": symbol,
153
+ "found": True,
154
+ "isin": str(last.get("isin")) if pd.notna(last.get("isin")) else None,
155
+ "series": str(last.get("series")),
156
+ "last_date": pd.to_datetime(last["date"]).strftime("%Y-%m-%d"),
157
+ "last_close": float(last["close"]),
158
+ "last_volume": int(last["volume"]) if pd.notna(last["volume"]) else None,
159
+ "history_rows": len(eod),
160
+ }
161
+ if not master.df.empty:
162
+ match = master.df[(master.df["new_symbol"] == symbol) | (master.df["old_symbol"] == symbol)]
163
+ if not match.empty:
164
+ info["canonical_isin"] = str(match.iloc[-1]["isin"])
165
+ return info
166
+
167
+
168
+ def run_backtest(
169
+ signals_source: str = "momentum",
170
+ index_name: str | None = None,
171
+ start: str | None = None,
172
+ end: str | None = None,
173
+ costs: str = "zerodha",
174
+ slippage_bps: float = 5.0,
175
+ initial_capital: float = 1_000_000.0,
176
+ lookback: int = 252,
177
+ top_k: int = 10,
178
+ schedule: str = "monthly",
179
+ data_dir: str | None = None,
180
+ symbols: Iterable[str] | None = None,
181
+ ) -> dict[str, Any]:
182
+ """Run an honest backtest and return the tearsheet as a dict.
183
+
184
+ ``signals_source`` is one of ``momentum`` (default), ``mean_reversion``,
185
+ or ``equal_weight``. Universe is the PIT membership of ``index_name``
186
+ on ``end`` (or today) unless ``symbols`` is provided explicitly.
187
+ """
188
+ paths = _resolve_paths(data_dir)
189
+ end_d = _coerce_date(end) or date.today()
190
+ start_d = _coerce_date(start)
191
+ if symbols is not None:
192
+ syms = list(symbols)
193
+ elif index_name:
194
+ syms = oq_data.universe(index_name, end_d, paths=paths)
195
+ else:
196
+ syms = oq_data.list_symbols(paths=paths)
197
+ if not syms:
198
+ raise ValueError("no symbols available; ingest data or pass `symbols`")
199
+ prices_df = oq_data.wide_prices(syms, start=start_d, end=end_d, paths=paths)
200
+ if prices_df.empty:
201
+ raise ValueError("price frame is empty; check ingestion and date range")
202
+
203
+ src = signals_source.lower()
204
+ if src == "momentum":
205
+ sigs = bt.momentum_signal(prices_df, lookback=lookback, top_k=top_k, schedule=schedule)
206
+ elif src in {"mean_reversion", "mean-reversion", "reversion"}:
207
+ sigs = bt.mean_reversion_signal(
208
+ prices_df, lookback=max(lookback // 50, 2), bottom_k=top_k, schedule=schedule
209
+ )
210
+ elif src in {"equal_weight", "equal-weight", "equal"}:
211
+ sigs = bt.equal_weight(prices_df.columns, prices_df.index)
212
+ else:
213
+ raise ValueError(f"unknown signals_source: {signals_source!r}")
214
+
215
+ result = bt.backtest(
216
+ sigs,
217
+ prices_df,
218
+ costs=costs,
219
+ slippage=float(slippage_bps),
220
+ initial_capital=float(initial_capital),
221
+ )
222
+ summary = result.summary()
223
+ attribution = result.cost_attribution().to_dict()
224
+ return {
225
+ "signals_source": src,
226
+ "universe_size": len(prices_df.columns),
227
+ "period": {
228
+ "start": str(prices_df.index[0].date()),
229
+ "end": str(prices_df.index[-1].date()),
230
+ },
231
+ "costs": costs,
232
+ "slippage_bps": float(slippage_bps),
233
+ "initial_capital": float(initial_capital),
234
+ "summary": {k: float(v) for k, v in summary.items()},
235
+ "cost_attribution_inr": {k: float(v) for k, v in attribution.items()},
236
+ "tearsheet": result.tearsheet(),
237
+ }
238
+
239
+
240
+ __all__ = [
241
+ "get_fundamentals_basic",
242
+ "get_prices",
243
+ "get_universe",
244
+ "run_backtest",
245
+ "screen_stocks",
246
+ ]
@@ -0,0 +1,61 @@
1
+ """Test fixtures for oq-mcp: a fully seeded tmp DataPaths with prices + a universe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+ import pytest
11
+ from oq_data import storage, universes
12
+ from oq_data.config import DataPaths
13
+
14
+ SYMBOLS = ["AAA", "BBB", "CCC", "DDD"]
15
+
16
+
17
+ def _make_eod(symbols: list[str], n_days: int = 320, seed: int = 7) -> pd.DataFrame:
18
+ rng = np.random.default_rng(seed)
19
+ dates = pd.bdate_range(start="2023-01-02", periods=n_days)
20
+ rows: list[dict[str, object]] = []
21
+ for i, sym in enumerate(symbols):
22
+ rets = rng.normal(loc=0.0004 + i * 0.0001, scale=0.014, size=n_days)
23
+ rets[0] = 0.0
24
+ closes = 100.0 * np.cumprod(1.0 + rets)
25
+ for d, c in zip(dates, closes, strict=True):
26
+ rows.append(
27
+ {
28
+ "date": d,
29
+ "symbol": sym,
30
+ "isin": f"INE000000{i:03d}",
31
+ "series": "EQ",
32
+ "open": c * 0.999,
33
+ "high": c * 1.005,
34
+ "low": c * 0.995,
35
+ "close": c,
36
+ "prev_close": c * 0.999,
37
+ "volume": 100_000 + i * 1_000,
38
+ "value": 100_000 * c,
39
+ "trades": 500,
40
+ }
41
+ )
42
+ return pd.DataFrame(rows)
43
+
44
+
45
+ @pytest.fixture
46
+ def seeded_paths(tmp_path: Path) -> DataPaths:
47
+ paths = DataPaths(tmp_path)
48
+ paths.ensure()
49
+ df = _make_eod(SYMBOLS)
50
+ storage.write_eod(df, paths=paths)
51
+ entries = [
52
+ universes.UniverseEntry(
53
+ index_name="NIFTY 50",
54
+ symbol=sym,
55
+ isin=f"INE000000{i:03d}",
56
+ include_date=date(2023, 1, 2),
57
+ )
58
+ for i, sym in enumerate(SYMBOLS)
59
+ ]
60
+ universes.add_entries(entries, paths=paths)
61
+ return paths
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+
5
+ import pytest
6
+ from oq_mcp.cache import TTLCache
7
+
8
+
9
+ def test_set_and_get_roundtrip() -> None:
10
+ c: TTLCache[int] = TTLCache(ttl_seconds=60.0, max_entries=8)
11
+ c.set(("a", 1), 42)
12
+ assert c.get(("a", 1)) == 42
13
+
14
+
15
+ def test_miss_returns_none() -> None:
16
+ c: TTLCache[int] = TTLCache(ttl_seconds=60.0)
17
+ assert c.get("missing") is None
18
+
19
+
20
+ def test_expiration() -> None:
21
+ c: TTLCache[int] = TTLCache(ttl_seconds=0.05)
22
+ c.set("k", 1)
23
+ time.sleep(0.1)
24
+ assert c.get("k") is None
25
+
26
+
27
+ def test_eviction_fifo() -> None:
28
+ c: TTLCache[int] = TTLCache(ttl_seconds=60.0, max_entries=2)
29
+ c.set("a", 1)
30
+ c.set("b", 2)
31
+ c.set("c", 3)
32
+ assert c.get("a") is None
33
+ assert c.get("b") == 2
34
+ assert c.get("c") == 3
35
+
36
+
37
+ def test_invalid_settings() -> None:
38
+ with pytest.raises(ValueError):
39
+ TTLCache(ttl_seconds=-1)
40
+ with pytest.raises(ValueError):
41
+ TTLCache(max_entries=0)
42
+
43
+
44
+ def test_clear() -> None:
45
+ c: TTLCache[int] = TTLCache(ttl_seconds=60.0)
46
+ c.set("k", 9)
47
+ c.clear()
48
+ assert c.get("k") is None
49
+ assert len(c) == 0
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pandas as pd
5
+ import pytest
6
+ from oq_mcp.screener import screen
7
+
8
+
9
+ def _prices() -> pd.DataFrame:
10
+ rng = np.random.default_rng(3)
11
+ dates = pd.bdate_range("2024-01-02", periods=260)
12
+ cols = ["UP", "DOWN", "FLAT", "TINY"]
13
+ data = {
14
+ "UP": 100 * np.cumprod(1 + rng.normal(0.002, 0.01, 260)),
15
+ "DOWN": 100 * np.cumprod(1 + rng.normal(-0.002, 0.01, 260)),
16
+ "FLAT": 100 * np.ones(260),
17
+ "TINY": 0.5 * np.ones(260),
18
+ }
19
+ return pd.DataFrame(data, index=dates, columns=cols)
20
+
21
+
22
+ def test_numeric_filter() -> None:
23
+ p = _prices()
24
+ out = screen(p, ["returns_252d > 0.10"])
25
+ assert "UP" in out
26
+ assert "DOWN" not in out
27
+
28
+
29
+ def test_combine_and() -> None:
30
+ p = _prices()
31
+ out = screen(p, ["returns_252d > 0.05", "close > 5"], combine="and")
32
+ assert "TINY" not in out
33
+
34
+
35
+ def test_combine_or() -> None:
36
+ p = _prices()
37
+ out = screen(p, ["close > 90", "close < 1"], combine="or")
38
+ assert "TINY" in out
39
+
40
+
41
+ def test_boolean_field() -> None:
42
+ p = _prices()
43
+ out = screen(p, ["sma_50_above_sma_200"])
44
+ assert isinstance(out, list)
45
+
46
+
47
+ def test_bad_expression_raises() -> None:
48
+ p = _prices()
49
+ with pytest.raises(ValueError):
50
+ screen(p, ["not a thing"])
51
+ with pytest.raises(ValueError):
52
+ screen(p, [])
53
+
54
+
55
+ def test_empty_universe() -> None:
56
+ out = screen(pd.DataFrame(), ["close > 1"])
57
+ assert out == []
58
+
59
+
60
+ def test_pct_from_52w_high() -> None:
61
+ p = _prices()
62
+ out = screen(p, ["pct_from_52w_high <= 0.50"])
63
+ assert isinstance(out, list)
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from oq_data.config import DataPaths
5
+ from oq_mcp import tools
6
+ from oq_mcp.server import build_server
7
+
8
+
9
+ def test_build_server_registers_all_tools() -> None:
10
+ server = build_server()
11
+ tool_list = pytest.importorskip("asyncio").run(server.list_tools())
12
+ names = {t.name for t in tool_list}
13
+ assert names == {
14
+ "get_prices",
15
+ "get_universe",
16
+ "screen_stocks",
17
+ "get_fundamentals_basic",
18
+ "run_backtest",
19
+ }
20
+
21
+
22
+ def test_server_name_and_instructions() -> None:
23
+ server = build_server(name="oq-test")
24
+ assert server.name == "oq-test"
25
+ assert "OpenQuant India" in server.instructions
26
+
27
+
28
+ async def test_call_get_universe_via_server(
29
+ seeded_paths: DataPaths, monkeypatch: pytest.MonkeyPatch
30
+ ) -> None:
31
+ monkeypatch.setenv("OPENQUANT_DATA_DIR", str(seeded_paths.root))
32
+
33
+ out = tools.get_universe("NIFTY 50", as_of="2024-06-01")
34
+ assert out["count"] == 4
@@ -0,0 +1,97 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+ from oq_data.config import DataPaths
5
+ from oq_mcp import tools
6
+
7
+
8
+ def test_get_prices_returns_rows(seeded_paths: DataPaths) -> None:
9
+ out = tools.get_prices("AAA", data_dir=str(seeded_paths.root))
10
+ assert out["symbol"] == "AAA"
11
+ assert out["rows"] > 0
12
+ assert out["adjusted"] is True
13
+ assert all("date" in r and "close" in r for r in out["data"][:3])
14
+
15
+
16
+ def test_get_prices_caches(seeded_paths: DataPaths) -> None:
17
+ from oq_mcp.cache import TTLCache
18
+
19
+ cache: TTLCache = TTLCache(ttl_seconds=60.0)
20
+ out1 = tools.get_prices("AAA", data_dir=str(seeded_paths.root), cache=cache)
21
+ out2 = tools.get_prices("AAA", data_dir=str(seeded_paths.root), cache=cache)
22
+ assert out1 is out2
23
+
24
+
25
+ def test_get_universe(seeded_paths: DataPaths) -> None:
26
+ out = tools.get_universe("NIFTY 50", as_of="2024-06-01", data_dir=str(seeded_paths.root))
27
+ assert out["index"] == "NIFTY 50"
28
+ assert out["count"] == 4
29
+ assert set(out["symbols"]) == {"AAA", "BBB", "CCC", "DDD"}
30
+
31
+
32
+ def test_get_fundamentals_basic_found(seeded_paths: DataPaths) -> None:
33
+ out = tools.get_fundamentals_basic("BBB", data_dir=str(seeded_paths.root))
34
+ assert out["found"] is True
35
+ assert out["symbol"] == "BBB"
36
+ assert out["series"] == "EQ"
37
+ assert out["last_close"] > 0
38
+
39
+
40
+ def test_get_fundamentals_basic_missing(seeded_paths: DataPaths) -> None:
41
+ out = tools.get_fundamentals_basic("ZZZ", data_dir=str(seeded_paths.root))
42
+ assert out["found"] is False
43
+
44
+
45
+ def test_screen_stocks_runs(seeded_paths: DataPaths) -> None:
46
+ out = tools.screen_stocks(
47
+ ["close > 0"],
48
+ index_name="NIFTY 50",
49
+ as_of="2024-06-01",
50
+ data_dir=str(seeded_paths.root),
51
+ )
52
+ assert out["count"] >= 1
53
+ assert out["universe_size"] == 4
54
+
55
+
56
+ def test_run_backtest_momentum(seeded_paths: DataPaths) -> None:
57
+ out = tools.run_backtest(
58
+ signals_source="momentum",
59
+ index_name="NIFTY 50",
60
+ start="2023-06-01",
61
+ costs="zerodha",
62
+ lookback=60,
63
+ top_k=2,
64
+ data_dir=str(seeded_paths.root),
65
+ )
66
+ assert out["signals_source"] == "momentum"
67
+ assert out["universe_size"] == 4
68
+ assert "net_cagr" in out["summary"]
69
+ assert "tearsheet" in out
70
+ assert isinstance(out["cost_attribution_inr"], dict)
71
+
72
+
73
+ def test_run_backtest_equal_weight(seeded_paths: DataPaths) -> None:
74
+ out = tools.run_backtest(
75
+ signals_source="equal_weight",
76
+ index_name="NIFTY 50",
77
+ data_dir=str(seeded_paths.root),
78
+ )
79
+ assert out["summary"]["final_net_value"] > 0
80
+
81
+
82
+ def test_run_backtest_unknown_source(seeded_paths: DataPaths) -> None:
83
+ with pytest.raises(ValueError):
84
+ tools.run_backtest(
85
+ signals_source="nope",
86
+ index_name="NIFTY 50",
87
+ data_dir=str(seeded_paths.root),
88
+ )
89
+
90
+
91
+ def test_run_backtest_no_data(tmp_path) -> None:
92
+ with pytest.raises(ValueError):
93
+ tools.run_backtest(
94
+ signals_source="momentum",
95
+ index_name="NIFTY 50",
96
+ data_dir=str(tmp_path),
97
+ )