mcp-ivolatility-data 0.1.5__py3-none-any.whl
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.
- ivol_mcp_core/__init__.py +91 -0
- ivol_mcp_core/__main__.py +4 -0
- ivol_mcp_core/assembly.py +50 -0
- ivol_mcp_core/constants.py +201 -0
- ivol_mcp_core/formatters.py +189 -0
- ivol_mcp_core/functions.py +933 -0
- ivol_mcp_core/index.py +660 -0
- ivol_mcp_core/index_openapi.py +219 -0
- ivol_mcp_core/index_prompts.py +158 -0
- ivol_mcp_core/server.py +887 -0
- ivol_mcp_core/store.py +1058 -0
- mcp_ivolatility_data/__init__.py +25 -0
- mcp_ivolatility_data/__main__.py +4 -0
- mcp_ivolatility_data/index_supplement.yml +113 -0
- mcp_ivolatility_data/instructions/00-role.md +40 -0
- mcp_ivolatility_data/instructions/10-discovery.md +65 -0
- mcp_ivolatility_data/instructions/10-discovery.stdio.md +5 -0
- mcp_ivolatility_data/instructions/20-access.md +22 -0
- mcp_ivolatility_data/instructions/30-behavior.md +14 -0
- mcp_ivolatility_data/instructions/30-behavior.remote.md +12 -0
- mcp_ivolatility_data/instructions/30-behavior.stdio.md +12 -0
- mcp_ivolatility_data/instructions/40-calling.md +27 -0
- mcp_ivolatility_data/openapi_ivolatility.yml +11277 -0
- mcp_ivolatility_data-0.1.5.dist-info/METADATA +58 -0
- mcp_ivolatility_data-0.1.5.dist-info/RECORD +28 -0
- mcp_ivolatility_data-0.1.5.dist-info/WHEEL +4 -0
- mcp_ivolatility_data-0.1.5.dist-info/entry_points.txt +2 -0
- mcp_ivolatility_data-0.1.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
__all__ = ["main"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main() -> None:
|
|
10
|
+
"""
|
|
11
|
+
Main CLI entry point for the iVolatility MCP core server.
|
|
12
|
+
Accepts --transport CLI argument (falls back to MCP_TRANSPORT env var, then stdio).
|
|
13
|
+
|
|
14
|
+
Heavy dependencies (numpy, etc.) are imported lazily inside this
|
|
15
|
+
function so that ``uv run`` can finish installing packages and Python
|
|
16
|
+
can start before the 30-second MCP connection timeout fires.
|
|
17
|
+
"""
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
|
|
20
|
+
# Load environment variables from .env file if it exists
|
|
21
|
+
load_dotenv()
|
|
22
|
+
|
|
23
|
+
parser = argparse.ArgumentParser(description="iVolatility MCP server")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--transport",
|
|
26
|
+
choices=["stdio", "sse", "streamable-http"],
|
|
27
|
+
default=None,
|
|
28
|
+
help="Transport protocol (default: stdio). Overrides MCP_TRANSPORT env var.",
|
|
29
|
+
)
|
|
30
|
+
args = parser.parse_args()
|
|
31
|
+
|
|
32
|
+
# CLI arg takes precedence over env var; default to stdio
|
|
33
|
+
if args.transport is not None:
|
|
34
|
+
transport: Literal["stdio", "sse", "streamable-http"] = args.transport
|
|
35
|
+
else:
|
|
36
|
+
supported_transports: dict[str, Literal["stdio", "sse", "streamable-http"]] = {
|
|
37
|
+
"stdio": "stdio",
|
|
38
|
+
"sse": "sse",
|
|
39
|
+
"streamable-http": "streamable-http",
|
|
40
|
+
}
|
|
41
|
+
mcp_transport_str = os.environ.get("MCP_TRANSPORT", "stdio")
|
|
42
|
+
transport = supported_transports.get(mcp_transport_str, "stdio")
|
|
43
|
+
|
|
44
|
+
# Check API key and print startup message.
|
|
45
|
+
# Startup messages go to stderr — stdout is the MCP protocol channel
|
|
46
|
+
# for stdio transport; non-JSON data there corrupts the handshake.
|
|
47
|
+
# IVOL_API_KEY is the canonical name; API_KEY is accepted as a fallback for
|
|
48
|
+
# consistency with the other iVolatility deliverables (workspace prompts,
|
|
49
|
+
# ivolai-dist .env). The specific name wins when both are set.
|
|
50
|
+
ivol_api_key = os.environ.get("IVOL_API_KEY", "") or os.environ.get("API_KEY", "")
|
|
51
|
+
if ivol_api_key:
|
|
52
|
+
print(
|
|
53
|
+
"Starting iVolatility MCP server with API key configured.", file=sys.stderr
|
|
54
|
+
)
|
|
55
|
+
else:
|
|
56
|
+
print(
|
|
57
|
+
"Warning: neither IVOL_API_KEY nor API_KEY environment variable is set.",
|
|
58
|
+
file=sys.stderr,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
base_url = os.environ.get(
|
|
62
|
+
"IVOL_API_BASE_URL", "https://restapi.ivolatility.com"
|
|
63
|
+
).rstrip("/")
|
|
64
|
+
# Endpoint index sources: OpenAPI REST spec (required) + prompt supplement (optional).
|
|
65
|
+
openapi_path = os.environ.get("IVOL_OPENAPI_PATH")
|
|
66
|
+
supplement_path = os.environ.get("IVOL_SUPPLEMENT_PATH")
|
|
67
|
+
# Instructions dir (the resalka): role + discovery + access rules in .md chunks.
|
|
68
|
+
instructions_dir = os.environ.get("IVOL_INSTRUCTIONS_DIR")
|
|
69
|
+
|
|
70
|
+
max_tables: int | None = None
|
|
71
|
+
max_rows: int | None = None
|
|
72
|
+
if os.environ.get("IVOL_MAX_TABLES"):
|
|
73
|
+
max_tables = int(os.environ["IVOL_MAX_TABLES"])
|
|
74
|
+
if os.environ.get("IVOL_MAX_ROWS"):
|
|
75
|
+
max_rows = int(os.environ["IVOL_MAX_ROWS"])
|
|
76
|
+
|
|
77
|
+
# Defer importing server until after env vars are read — this triggers
|
|
78
|
+
# loading numpy and other heavy deps.
|
|
79
|
+
from .server import run, configure_credentials
|
|
80
|
+
|
|
81
|
+
configure_credentials(
|
|
82
|
+
ivol_api_key,
|
|
83
|
+
base_url,
|
|
84
|
+
openapi_path=openapi_path,
|
|
85
|
+
supplement_path=supplement_path,
|
|
86
|
+
instructions_dir=instructions_dir,
|
|
87
|
+
max_tables=max_tables,
|
|
88
|
+
max_rows=max_rows,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
run(transport=transport)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Instructions assembler — the connector's "resalka" (slicer), the inverse of
|
|
2
|
+
the workspace split.py.
|
|
3
|
+
|
|
4
|
+
Behaviour (role, endpoint-choosing rules, account/access rule, quirks) lives in
|
|
5
|
+
small editable Markdown chunks under an instructions directory. This stitches
|
|
6
|
+
them, in filename order, into the single server-instructions string the MCP
|
|
7
|
+
client injects into its system context. Each connector (data / pod) ships its
|
|
8
|
+
own instructions directory; the mechanism is shared here in core.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def assemble_instructions(
|
|
15
|
+
instructions_dir: str | None, base: str = "", transport: str | None = None
|
|
16
|
+
) -> str:
|
|
17
|
+
"""Concatenate sorted ``*.md`` files in ``instructions_dir`` into one string.
|
|
18
|
+
|
|
19
|
+
``base`` (optional) is placed first as a fallback/header. Missing directory
|
|
20
|
+
or no files → returns just ``base``.
|
|
21
|
+
|
|
22
|
+
Transport-specific chunks: guidance that is only true for one deployment
|
|
23
|
+
(e.g. export_data writes a LOCAL file only when the server runs on the
|
|
24
|
+
user's machine) lives in variant files. ``<name>.stdio.md`` is included
|
|
25
|
+
only for the stdio transport (local run, the default when ``transport`` is
|
|
26
|
+
None); ``<name>.remote.md`` only for network transports (sse /
|
|
27
|
+
streamable-http). Plain ``*.md`` chunks are always included.
|
|
28
|
+
"""
|
|
29
|
+
mode = "stdio" if transport in (None, "stdio") else "remote"
|
|
30
|
+
parts: list[str] = []
|
|
31
|
+
if base and base.strip():
|
|
32
|
+
parts.append(base.strip())
|
|
33
|
+
|
|
34
|
+
if instructions_dir:
|
|
35
|
+
d = Path(instructions_dir)
|
|
36
|
+
if d.is_dir():
|
|
37
|
+
for f in sorted(d.glob("*.md")):
|
|
38
|
+
# Skip dotfiles / macOS AppleDouble sidecars (._*.md) that tar may
|
|
39
|
+
# carry along — they are binary and break UTF-8 decoding.
|
|
40
|
+
if f.name.startswith("."):
|
|
41
|
+
continue
|
|
42
|
+
if f.name.endswith(".stdio.md") and mode != "stdio":
|
|
43
|
+
continue
|
|
44
|
+
if f.name.endswith(".remote.md") and mode != "remote":
|
|
45
|
+
continue
|
|
46
|
+
text = f.read_text(encoding="utf-8").strip()
|
|
47
|
+
if text:
|
|
48
|
+
parts.append(text)
|
|
49
|
+
|
|
50
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
# Kept only for import-compatibility with index.py (the Massive markdown
|
|
4
|
+
# builder, unused now that the index is built from OpenAPI + supplement).
|
|
5
|
+
_DEFAULT_LLMS_FULL_TXT_URL = "https://restapi.ivolatility.com/api/openapi"
|
|
6
|
+
|
|
7
|
+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
|
|
8
|
+
|
|
9
|
+
# Aliases map abbreviations/synonyms a user might type to canonical terms that
|
|
10
|
+
# appear in iVolatility endpoint names/descriptions/fields. Values can be a
|
|
11
|
+
# single string or a list of strings. Terms must match real index vocabulary.
|
|
12
|
+
ALIASES: dict[str, str | list[str]] = {
|
|
13
|
+
# ── implied volatility / IVX term structure ──
|
|
14
|
+
"iv": ["ivx", "implied"],
|
|
15
|
+
"impliedvol": ["ivx", "implied"],
|
|
16
|
+
"impliedvolatility": ["ivx", "implied"],
|
|
17
|
+
"volatility": ["ivx", "ivs", "hv"],
|
|
18
|
+
"vol": ["ivx", "ivs"],
|
|
19
|
+
"ivx": "ivx",
|
|
20
|
+
"term": ["ivx"],
|
|
21
|
+
"termstructure": ["ivx"],
|
|
22
|
+
"lean": ["ivx", "rawiv"], # IV-lean signal: ivx OR options-rawiv source
|
|
23
|
+
# ── IV surface / skew ──
|
|
24
|
+
"surface": ["ivs"],
|
|
25
|
+
"ivsurface": ["ivs"],
|
|
26
|
+
"skew": ["ivs", "delta"], # surfaced via ivs-by-delta
|
|
27
|
+
"smile": ["ivs"],
|
|
28
|
+
# ── IV rank / percentile ──
|
|
29
|
+
"ivr": ["ivx", "historical", "range"],
|
|
30
|
+
"ivrank": ["ivx", "historical", "range"],
|
|
31
|
+
"ivp": ["ivx", "percentile"],
|
|
32
|
+
"percentile": ["ivx", "ivp"],
|
|
33
|
+
# ── historical / realized vol ──
|
|
34
|
+
"hv": "hv",
|
|
35
|
+
"historicalvol": "hv",
|
|
36
|
+
"realized": "hv",
|
|
37
|
+
"realizedvol": "hv",
|
|
38
|
+
# ── options / chains ──
|
|
39
|
+
"option": "options",
|
|
40
|
+
"contract": "options",
|
|
41
|
+
"contracts": "options",
|
|
42
|
+
"chain": ["options", "rawiv", "param"],
|
|
43
|
+
"optionchain": ["options", "rawiv"],
|
|
44
|
+
"strike": "options",
|
|
45
|
+
"expiry": "options",
|
|
46
|
+
"expiration": "options",
|
|
47
|
+
"dte": "options",
|
|
48
|
+
"moneyness": ["options", "param"],
|
|
49
|
+
"rawiv": "rawiv",
|
|
50
|
+
"1545": ["1545"],
|
|
51
|
+
# ── NBBO / quotes ──
|
|
52
|
+
"nbbo": ["nbbo", "quote"],
|
|
53
|
+
"bid": ["nbbo", "quote"],
|
|
54
|
+
"ask": ["nbbo", "quote"],
|
|
55
|
+
"quote": ["nbbo", "quote"],
|
|
56
|
+
# ── greeks (functions + options) ──
|
|
57
|
+
"greek": ["greeks", "options"],
|
|
58
|
+
"greeks": ["options"],
|
|
59
|
+
"delta": ["bs_delta", "delta", "options"],
|
|
60
|
+
"gamma": ["bs_gamma", "options"],
|
|
61
|
+
"theta": ["bs_theta", "options"],
|
|
62
|
+
"vega": ["bs_vega", "options"],
|
|
63
|
+
"rho": ["bs_rho", "options"],
|
|
64
|
+
"vanna": ["bs_vanna", "options"],
|
|
65
|
+
"volga": ["bs_volga", "options"],
|
|
66
|
+
"vomma": ["bs_volga", "options"],
|
|
67
|
+
"charm": ["bs_charm", "options"],
|
|
68
|
+
"veta": ["bs_veta", "options"],
|
|
69
|
+
"color": ["bs_color", "options"],
|
|
70
|
+
"blackscholes": ["bs_price", "bs_delta"],
|
|
71
|
+
# ── prices / OHLC ──
|
|
72
|
+
"price": ["prices", "stock"],
|
|
73
|
+
"prices": ["prices", "stock"],
|
|
74
|
+
"close": "prices",
|
|
75
|
+
"ohlc": "prices",
|
|
76
|
+
"stock": ["stock", "prices"],
|
|
77
|
+
"stockprice": ["stock", "prices"],
|
|
78
|
+
# ── earnings ──
|
|
79
|
+
"earnings": ["earnings", "calendar"],
|
|
80
|
+
"eps": "earnings",
|
|
81
|
+
"impliedmove": ["earnings"],
|
|
82
|
+
# ── futures ──
|
|
83
|
+
"future": "futures",
|
|
84
|
+
"futures": "futures",
|
|
85
|
+
"fut": "futures",
|
|
86
|
+
# ── interest rates / yield / dividends ──
|
|
87
|
+
"yield": ["yield", "interest"],
|
|
88
|
+
"dividend": ["yield", "dividend"],
|
|
89
|
+
"rate": ["interest", "rates"],
|
|
90
|
+
"rates": ["interest", "rates"],
|
|
91
|
+
# ── fixed income / curves ──
|
|
92
|
+
"bond": ["bond", "fixedincome"],
|
|
93
|
+
"bonds": ["bond", "fixedincome"],
|
|
94
|
+
"fixedincome": ["fixedincome", "bond"],
|
|
95
|
+
"cashflow": ["cashflow"],
|
|
96
|
+
"msrb": ["msrb"],
|
|
97
|
+
"curve": ["curve", "discount", "zero"],
|
|
98
|
+
"curves": ["curve", "discount", "zero"],
|
|
99
|
+
# ── real-time / streaming ──
|
|
100
|
+
"realtime": ["rt", "realtime"],
|
|
101
|
+
"real-time": ["rt", "realtime"],
|
|
102
|
+
"live": ["rt", "realtime"],
|
|
103
|
+
"rt": ["rt", "realtime"],
|
|
104
|
+
"stream": ["websocket", "rtdl"],
|
|
105
|
+
"streaming": ["websocket", "rtdl"],
|
|
106
|
+
"websocket": ["websocket", "rtdl"],
|
|
107
|
+
"ws": ["websocket", "rtdl"],
|
|
108
|
+
# ── intraday ──
|
|
109
|
+
"intraday": ["intraday"],
|
|
110
|
+
"minute": ["intraday"],
|
|
111
|
+
"minutes": ["intraday"],
|
|
112
|
+
"bar": ["intraday"],
|
|
113
|
+
"bars": ["intraday"],
|
|
114
|
+
# ── account / entitlements ──
|
|
115
|
+
"account": ["account", "access"],
|
|
116
|
+
"access": ["account", "access"],
|
|
117
|
+
"tariff": ["account", "access"],
|
|
118
|
+
"tariffs": ["account", "access"],
|
|
119
|
+
"entitlement": ["account", "access"],
|
|
120
|
+
"entitlements": ["account", "access"],
|
|
121
|
+
"quota": ["account", "access"],
|
|
122
|
+
"limit": ["account", "access"],
|
|
123
|
+
"limits": ["account", "access"],
|
|
124
|
+
# ── discovery / reference ──
|
|
125
|
+
"symbol": ["underlying", "ticker"],
|
|
126
|
+
"symbols": ["underlying", "ticker"],
|
|
127
|
+
"ticker": ["underlying", "ticker", "tickers"],
|
|
128
|
+
"lookup": ["underlying", "series"],
|
|
129
|
+
"series": ["series"],
|
|
130
|
+
"calendar": ["calendar", "trading"],
|
|
131
|
+
"split": ["corp", "actions"],
|
|
132
|
+
"dividends": ["corp", "actions"],
|
|
133
|
+
"corporateactions": ["corp", "actions"],
|
|
134
|
+
# ── instrument metadata (underlying-info: stocks + futures) ──
|
|
135
|
+
"constituent": ["underlying"],
|
|
136
|
+
"constituents": ["underlying"],
|
|
137
|
+
"member": ["underlying"],
|
|
138
|
+
"members": ["underlying"],
|
|
139
|
+
"stockgroup": ["underlying"],
|
|
140
|
+
"etf": ["underlying", "ticker"],
|
|
141
|
+
"etfs": ["underlying", "ticker"],
|
|
142
|
+
"multiplier": ["futures", "underlying"],
|
|
143
|
+
"contractsize": ["futures", "underlying"],
|
|
144
|
+
"root": ["futures", "underlying"],
|
|
145
|
+
"roots": ["futures", "underlying"],
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
# Market keywords used to infer the market facet from a free-text query.
|
|
149
|
+
# Values must match the `market` column values (x-tagGroup names) produced by
|
|
150
|
+
# index_openapi.py / the supplement, so an inferred-market boost lands on the
|
|
151
|
+
# right rows. Equities/Options is the default and intentionally has no keywords.
|
|
152
|
+
_MARKET_KEYWORDS: dict[str, set[str]] = {
|
|
153
|
+
"Futures/Options": {
|
|
154
|
+
"future",
|
|
155
|
+
"futures",
|
|
156
|
+
"fut",
|
|
157
|
+
"commodity",
|
|
158
|
+
"commodities",
|
|
159
|
+
},
|
|
160
|
+
"RealTime Options": {
|
|
161
|
+
"realtime",
|
|
162
|
+
"streaming",
|
|
163
|
+
"websocket",
|
|
164
|
+
"rtdl",
|
|
165
|
+
},
|
|
166
|
+
"Fixed Income": {
|
|
167
|
+
"bond",
|
|
168
|
+
"bonds",
|
|
169
|
+
"fixedincome",
|
|
170
|
+
"cashflow",
|
|
171
|
+
"msrb",
|
|
172
|
+
"debt",
|
|
173
|
+
},
|
|
174
|
+
"Curves": {"curve", "curves", "discount", "zero"},
|
|
175
|
+
"Account": {"account", "tariff", "tariffs", "entitlement", "entitlements", "quota"},
|
|
176
|
+
"WebSocket": {"websocket", "wss", "subscribe"},
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_BULLET_PARAM_RE = re.compile(r"^-\s+\*{0,2}(\w+)\*{0,2}\s*\(([^)]+)\)(?::\s*(.*))?$")
|
|
180
|
+
|
|
181
|
+
_STRUCTURAL_SECTIONS = {"Query Parameters", "Response Attributes", "Sample Response"}
|
|
182
|
+
|
|
183
|
+
# Uppercase 2-5 letter token pattern to detect ticker symbols in the
|
|
184
|
+
# original-case query (fallback signal for the equities market).
|
|
185
|
+
_UPPER_TICKER_RE = re.compile(r"\b([A-Z]{2,5})\b")
|
|
186
|
+
|
|
187
|
+
# Uppercase tokens that look like tickers but are acronyms / non-tickers and
|
|
188
|
+
# must NOT trigger a market-inference fallback.
|
|
189
|
+
_TICKER_FALLBACK_EXCLUSIONS: set[str] = {
|
|
190
|
+
# technical / option jargon
|
|
191
|
+
"IV", "IVX", "IVS", "IVR", "IVP", "HV", "RV", "ATM", "OTM", "ITM",
|
|
192
|
+
"DTE", "OHLC", "OHLCV", "VWAP", "NBBO", "RT", "WS",
|
|
193
|
+
# asset-class / general acronyms
|
|
194
|
+
"FX", "ETF", "ETFS", "IPO", "API", "URL", "JSON", "HTTP", "ID", "AI",
|
|
195
|
+
"EOD", "OSI", "CSV", "ZIP",
|
|
196
|
+
# geo / regulatory / business
|
|
197
|
+
"USA", "UK", "EU", "GMT", "AM", "PM", "OK", "OTC",
|
|
198
|
+
"NYSE", "NASDAQ", "AMEX", "CME", "NYMEX", "CBOT", "EUREX",
|
|
199
|
+
"CEO", "CFO", "CTO", "COO", "FED", "SEC", "FDA", "GDP", "CPI", "IRS",
|
|
200
|
+
"P", "S",
|
|
201
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import csv
|
|
3
|
+
import io
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def strip_response_metadata(json_text: str, exclude_keys: set) -> str:
|
|
8
|
+
"""Strip metadata keys from a JSON response string.
|
|
9
|
+
|
|
10
|
+
Parses the JSON, removes top-level keys in exclude_keys, and re-serializes.
|
|
11
|
+
"""
|
|
12
|
+
data = json.loads(json_text)
|
|
13
|
+
if isinstance(data, dict):
|
|
14
|
+
for key in exclude_keys:
|
|
15
|
+
data.pop(key, None)
|
|
16
|
+
return json.dumps(data)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def extract_records(data: str | dict | list) -> list[dict]:
|
|
20
|
+
"""Extract and flatten records from raw JSON input.
|
|
21
|
+
|
|
22
|
+
Takes raw JSON input (string or parsed), extracts the records list
|
|
23
|
+
(handling 'results', 'last', list, and single-object cases), flattens
|
|
24
|
+
each record via _flatten_dict, and returns a list of flat dicts.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
data: JSON string, dict, or list.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
List of flattened dictionaries.
|
|
31
|
+
"""
|
|
32
|
+
if isinstance(data, str):
|
|
33
|
+
try:
|
|
34
|
+
data = json.loads(data)
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
# Track whether "results" was a proper list — if so, the record count is
|
|
39
|
+
# authoritative and we should not re-interpret a single result's nested
|
|
40
|
+
# lists as the "real" row data.
|
|
41
|
+
results_was_list = False
|
|
42
|
+
|
|
43
|
+
if isinstance(data, dict) and isinstance(data.get("data"), list):
|
|
44
|
+
# iVolatility envelope: {status, query, data: [...]}. The rows live in
|
|
45
|
+
# "data"; treat it like a proper results list.
|
|
46
|
+
records = data["data"]
|
|
47
|
+
results_was_list = True
|
|
48
|
+
elif isinstance(data, dict) and "results" in data:
|
|
49
|
+
results_value = data["results"]
|
|
50
|
+
if isinstance(results_value, list):
|
|
51
|
+
records = results_value
|
|
52
|
+
results_was_list = True
|
|
53
|
+
elif isinstance(results_value, dict):
|
|
54
|
+
records = [results_value]
|
|
55
|
+
else:
|
|
56
|
+
records = [results_value]
|
|
57
|
+
elif isinstance(data, dict) and "last" in data:
|
|
58
|
+
records = [data["last"]] if isinstance(data["last"], dict) else [data]
|
|
59
|
+
elif isinstance(data, list):
|
|
60
|
+
records = data
|
|
61
|
+
results_was_list = True
|
|
62
|
+
else:
|
|
63
|
+
records = [data]
|
|
64
|
+
|
|
65
|
+
# If we got a single record containing list-of-dicts values AND it did
|
|
66
|
+
# NOT come from a proper results list, those inner lists are the real
|
|
67
|
+
# row data. This handles API responses like:
|
|
68
|
+
# {"tickers": [{...}, ...]} (snapshots / gainers)
|
|
69
|
+
# {"underlying": {...}, "values": [...]} (technical indicators via
|
|
70
|
+
# "results" as a dict, not a list)
|
|
71
|
+
# {"dividends": [...], "splits": [...]} (hypothetical multi-list
|
|
72
|
+
# responses — rows from every list are emitted and tagged with
|
|
73
|
+
# a ``_source`` column so downstream can disambiguate)
|
|
74
|
+
if len(records) == 1 and isinstance(records[0], dict) and not results_was_list:
|
|
75
|
+
record = records[0]
|
|
76
|
+
list_of_dict_keys = [
|
|
77
|
+
k
|
|
78
|
+
for k, v in record.items()
|
|
79
|
+
if isinstance(v, list) and v and isinstance(v[0], dict)
|
|
80
|
+
]
|
|
81
|
+
if list_of_dict_keys:
|
|
82
|
+
chosen = set(list_of_dict_keys)
|
|
83
|
+
# Carry every sibling value into the child rows. Sibling lists
|
|
84
|
+
# (of any kind) are stringified rather than silently dropped —
|
|
85
|
+
# this matches _flatten_dict's treatment of list values.
|
|
86
|
+
parent_fields: dict[str, Any] = {}
|
|
87
|
+
for pk, pv in record.items():
|
|
88
|
+
if pk in chosen:
|
|
89
|
+
continue
|
|
90
|
+
if isinstance(pv, dict):
|
|
91
|
+
for fk, fv in _flatten_dict(pv, pk).items():
|
|
92
|
+
if not isinstance(fv, (dict, list)):
|
|
93
|
+
parent_fields[fk] = fv
|
|
94
|
+
elif isinstance(pv, list):
|
|
95
|
+
parent_fields[pk] = str(pv)
|
|
96
|
+
else:
|
|
97
|
+
parent_fields[pk] = pv
|
|
98
|
+
# Emit rows from every list-of-dicts key. When multiple such
|
|
99
|
+
# keys exist, tag each row with its source so overlapping
|
|
100
|
+
# column names (e.g. "date" in both dividends and splits) do
|
|
101
|
+
# not collide ambiguously downstream.
|
|
102
|
+
tag_source = len(list_of_dict_keys) > 1
|
|
103
|
+
new_records: list = []
|
|
104
|
+
for key in list_of_dict_keys:
|
|
105
|
+
for item in record[key]:
|
|
106
|
+
if isinstance(item, dict):
|
|
107
|
+
row = {**parent_fields, **item}
|
|
108
|
+
if tag_source:
|
|
109
|
+
row["_source"] = key
|
|
110
|
+
new_records.append(row)
|
|
111
|
+
else:
|
|
112
|
+
# Preserve non-dict list entries; the final
|
|
113
|
+
# flattening step wraps them as {"value": ...}.
|
|
114
|
+
new_records.append(item)
|
|
115
|
+
records = new_records
|
|
116
|
+
|
|
117
|
+
flattened_records = []
|
|
118
|
+
for record in records:
|
|
119
|
+
if isinstance(record, dict):
|
|
120
|
+
flattened_records.append(_flatten_dict(record))
|
|
121
|
+
else:
|
|
122
|
+
flattened_records.append({"value": str(record)})
|
|
123
|
+
|
|
124
|
+
return flattened_records
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def json_to_csv(json_input: str | dict | list) -> str:
|
|
128
|
+
"""
|
|
129
|
+
Convert JSON to flattened CSV format.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
json_input: JSON string or dict. If the JSON has a 'results' key containing
|
|
133
|
+
a list, it will be extracted. Otherwise, the entire structure
|
|
134
|
+
will be wrapped in a list for processing.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
CSV string with headers and flattened rows
|
|
138
|
+
"""
|
|
139
|
+
flattened_records = extract_records(json_input)
|
|
140
|
+
|
|
141
|
+
if not flattened_records:
|
|
142
|
+
return ""
|
|
143
|
+
|
|
144
|
+
# Get all unique keys across all records (for consistent column ordering)
|
|
145
|
+
all_keys = []
|
|
146
|
+
seen = set()
|
|
147
|
+
for record in flattened_records:
|
|
148
|
+
if isinstance(record, dict):
|
|
149
|
+
for key in record.keys():
|
|
150
|
+
if key not in seen:
|
|
151
|
+
all_keys.append(key)
|
|
152
|
+
seen.add(key)
|
|
153
|
+
|
|
154
|
+
output = io.StringIO()
|
|
155
|
+
writer = csv.DictWriter(output, fieldnames=all_keys, lineterminator="\n")
|
|
156
|
+
writer.writeheader()
|
|
157
|
+
writer.writerows(flattened_records)
|
|
158
|
+
|
|
159
|
+
return output.getvalue()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _flatten_dict(
|
|
163
|
+
d: dict[str, Any], parent_key: str = "", sep: str = "_"
|
|
164
|
+
) -> dict[str, Any]:
|
|
165
|
+
"""
|
|
166
|
+
Flatten a nested dictionary by joining keys with separator.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
d: Dictionary to flatten
|
|
170
|
+
parent_key: Key from parent level (for recursion)
|
|
171
|
+
sep: Separator to use between nested keys
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Flattened dictionary with no nested structures
|
|
175
|
+
"""
|
|
176
|
+
items = []
|
|
177
|
+
for k, v in d.items():
|
|
178
|
+
new_key = f"{parent_key}{sep}{k}" if parent_key else k
|
|
179
|
+
|
|
180
|
+
if isinstance(v, dict):
|
|
181
|
+
# Recursively flatten nested dicts
|
|
182
|
+
items.extend(_flatten_dict(v, new_key, sep=sep).items())
|
|
183
|
+
elif isinstance(v, list):
|
|
184
|
+
# Convert lists to comma-separated strings
|
|
185
|
+
items.append((new_key, str(v)))
|
|
186
|
+
else:
|
|
187
|
+
items.append((new_key, v))
|
|
188
|
+
|
|
189
|
+
return dict(items)
|