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.
- oq_mcp-0.1.0/.gitignore +82 -0
- oq_mcp-0.1.0/PKG-INFO +55 -0
- oq_mcp-0.1.0/README.md +27 -0
- oq_mcp-0.1.0/pyproject.toml +43 -0
- oq_mcp-0.1.0/src/oq_mcp/__init__.py +28 -0
- oq_mcp-0.1.0/src/oq_mcp/cache.py +54 -0
- oq_mcp-0.1.0/src/oq_mcp/screener.py +131 -0
- oq_mcp-0.1.0/src/oq_mcp/server.py +134 -0
- oq_mcp-0.1.0/src/oq_mcp/tools.py +246 -0
- oq_mcp-0.1.0/tests/conftest.py +61 -0
- oq_mcp-0.1.0/tests/test_cache.py +49 -0
- oq_mcp-0.1.0/tests/test_screener.py +63 -0
- oq_mcp-0.1.0/tests/test_server.py +34 -0
- oq_mcp-0.1.0/tests/test_tools.py +97 -0
oq_mcp-0.1.0/.gitignore
ADDED
|
@@ -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
|
+
)
|