equity-intel-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.
Files changed (27) hide show
  1. equity_intel_mcp-0.1.0/LICENSE +21 -0
  2. equity_intel_mcp-0.1.0/PKG-INFO +170 -0
  3. equity_intel_mcp-0.1.0/README.md +137 -0
  4. equity_intel_mcp-0.1.0/equity_intel_mcp/__init__.py +9 -0
  5. equity_intel_mcp-0.1.0/equity_intel_mcp/__main__.py +5 -0
  6. equity_intel_mcp-0.1.0/equity_intel_mcp/analyze.py +90 -0
  7. equity_intel_mcp-0.1.0/equity_intel_mcp/cache.py +41 -0
  8. equity_intel_mcp-0.1.0/equity_intel_mcp/formatting.py +124 -0
  9. equity_intel_mcp-0.1.0/equity_intel_mcp/server.py +318 -0
  10. equity_intel_mcp-0.1.0/equity_intel_mcp/signal.py +74 -0
  11. equity_intel_mcp-0.1.0/equity_intel_mcp/sources/__init__.py +1 -0
  12. equity_intel_mcp-0.1.0/equity_intel_mcp/sources/analysts.py +62 -0
  13. equity_intel_mcp-0.1.0/equity_intel_mcp/sources/insider.py +119 -0
  14. equity_intel_mcp-0.1.0/equity_intel_mcp/sources/options.py +109 -0
  15. equity_intel_mcp-0.1.0/equity_intel_mcp/sources/quote.py +81 -0
  16. equity_intel_mcp-0.1.0/equity_intel_mcp/sources/superinvestor.py +112 -0
  17. equity_intel_mcp-0.1.0/equity_intel_mcp/sources/valuation.py +118 -0
  18. equity_intel_mcp-0.1.0/equity_intel_mcp.egg-info/PKG-INFO +170 -0
  19. equity_intel_mcp-0.1.0/equity_intel_mcp.egg-info/SOURCES.txt +25 -0
  20. equity_intel_mcp-0.1.0/equity_intel_mcp.egg-info/dependency_links.txt +1 -0
  21. equity_intel_mcp-0.1.0/equity_intel_mcp.egg-info/entry_points.txt +2 -0
  22. equity_intel_mcp-0.1.0/equity_intel_mcp.egg-info/requires.txt +8 -0
  23. equity_intel_mcp-0.1.0/equity_intel_mcp.egg-info/top_level.txt +1 -0
  24. equity_intel_mcp-0.1.0/pyproject.toml +47 -0
  25. equity_intel_mcp-0.1.0/setup.cfg +4 -0
  26. equity_intel_mcp-0.1.0/tests/test_composite.py +42 -0
  27. equity_intel_mcp-0.1.0/tests/test_smoke.py +44 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Cristian Amigo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: equity-intel-mcp
3
+ Version: 0.1.0
4
+ Summary: Institutional-grade equity analysis (insider, superinvestor, valuation) over MCP.
5
+ Author-email: Cristian Amigo <cstamigo@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/cstamigo-droid/equity-intel-mcp
8
+ Project-URL: Repository, https://github.com/cstamigo-droid/equity-intel-mcp
9
+ Project-URL: Bug Tracker, https://github.com/cstamigo-droid/equity-intel-mcp/issues
10
+ Keywords: mcp,model-context-protocol,equity,stocks,sec,edgar,insider-trading,valuation,llm,agents
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Classifier: Topic :: Office/Business :: Financial :: Investment
20
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: mcp>=1.2.0
25
+ Requires-Dist: pydantic>=2.6
26
+ Requires-Dist: python-dotenv>=1.0
27
+ Requires-Dist: yfinance>=0.2.40
28
+ Requires-Dist: requests>=2.31
29
+ Requires-Dist: beautifulsoup4>=4.12
30
+ Requires-Dist: edgartools>=4.0
31
+ Requires-Dist: pandas>=2.0
32
+ Dynamic: license-file
33
+
34
+ # equity-intel-mcp
35
+
36
+ [![equity-intel-mcp MCP server](https://glama.ai/mcp/servers/cstamigo-droid/equity-intel-mcp/badges/score.svg)](https://glama.ai/mcp/servers/cstamigo-droid/equity-intel-mcp)
37
+
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB.svg?logo=python&logoColor=white)](https://www.python.org) [![MCP](https://img.shields.io/badge/Model_Context_Protocol-server-7C3AED.svg)](https://modelcontextprotocol.io)
39
+
40
+ ![equity-intel-mcp - composite verdict demo](docs/demo.png)
41
+
42
+
43
+ **Institutional-grade equity analysis for any LLM, over the Model Context Protocol.**
44
+
45
+ Most "stock" integrations just echo a price. This one gives an AI agent the
46
+ signals professionals actually look at — **insider buying from SEC filings**,
47
+ **what renowned value investors are holding**, analyst consensus, options-implied
48
+ moves, and valuation — and blends them into a single, confidence-weighted verdict.
49
+
50
+ It runs entirely on **free / public data** (Yahoo Finance, SEC EDGAR, Dataroma),
51
+ fails gracefully when a source is missing, and never fabricates a signal it
52
+ doesn't have.
53
+
54
+ ```
55
+ # Equity Intelligence — MSFT
56
+
57
+ ## Verdict: Neutral → HOLD
58
+ [........|#.......] +11/100 · confidence 69% · 5 source(s)
59
+
60
+ | Source | Signal | Score | Conf | Weight |
61
+ |---------------|--------------|-------:|-----:|-------:|
62
+ | insider | Bearish | -77 | 70% | 0.25 |
63
+ | superinvestor | Lean bullish | +33 | 100% | 0.30 |
64
+ | analysts | Bullish | +64 | 75% | 0.15 |
65
+ | valuation | Bullish | +91 | 60% | 0.09 |
66
+ | options | Lean bullish | +19 | 40% | 0.04 |
67
+
68
+ - insider: Insiders net selling $13.5M over 180d (62 filings).
69
+ - superinvestor: 38 tracked superinvestors hold MSFT; 19 buys / 18 sells.
70
+ - analysts: 66 analysts: 23 strong buy / 38 buy / 5 hold.
71
+ - valuation: Fair value ~$569 vs $391 (+46% upside); health 84/100.
72
+ - options: 1-month implied move +/-7.6%; put/call OI 0.53.
73
+ ```
74
+
75
+ ---
76
+
77
+ ## Tools
78
+
79
+ | Tool | What it does | Source | Status |
80
+ |------|--------------|--------|:------:|
81
+ | `equity_analyze_ticker` | **Hero tool.** Runs every source in parallel and returns one scored verdict (BUY → AVOID) with a per-source breakdown. | composite | ✅ |
82
+ | `equity_insider_activity` | Net insider buying vs. selling from SEC **Form 4** filings (180-day window), weighted by USD value. | SEC EDGAR | ✅ |
83
+ | `equity_superinvestors` | Which of ~80 tracked value investors hold the stock, plus recent net buying/selling. | Dataroma | ✅ |
84
+ | `equity_get_quote` | Live price snapshot + position in the 52-week range. | Yahoo Finance | ✅ |
85
+ | `equity_analyst_consensus` | Wall Street buy/hold/sell consensus — scored from distribution of strong-buy to strong-sell ratings. | Finnhub | ✅ |
86
+ | `equity_options_signal` | 1-month implied move (straddle/spot) + put/call OI skew. Primary use: risk-sizing. | Yahoo Finance | ✅ |
87
+ | `equity_valuation` | Fair-value estimate (forward EPS × sector P/E) + financial-health score (debt, liquidity, margins). | Yahoo Finance | ✅ |
88
+
89
+ Every tool returns **Markdown** (human-readable, default) or **JSON**
90
+ (`response_format="json"`) for programmatic use.
91
+
92
+ ---
93
+
94
+ ## Quick start
95
+
96
+ ```bash
97
+ git clone https://github.com/cstamigo-droid/equity-intel-mcp equity-intel-mcp
98
+ cd equity-intel-mcp
99
+ python -m venv .venv && .venv\Scripts\activate # Windows
100
+ pip install -r requirements.txt
101
+
102
+ copy .env.example .env # then edit .env (set EDGAR_IDENTITY)
103
+ python -m equity_intel_mcp # starts the MCP server over stdio
104
+ ```
105
+
106
+ **Smoke test** (hits the live sources and prints each signal):
107
+
108
+ ```bash
109
+ python tests/test_smoke.py AAPL
110
+ ```
111
+
112
+ ### Configure (`.env`)
113
+
114
+ ```ini
115
+ # Required by SEC fair-access policy — any "Name email@example.com"
116
+ EDGAR_IDENTITY=Your Name you@example.com
117
+ # Required by equity_analyst_consensus (free key at finnhub.io)
118
+ FINNHUB_API_KEY=your-key-here
119
+ ```
120
+
121
+ ---
122
+
123
+ ## Use it in Claude Desktop
124
+
125
+ Add this to `claude_desktop_config.json`
126
+ (`%APPDATA%\Claude\` on Windows, `~/Library/Application Support/Claude/` on macOS),
127
+ then restart Claude Desktop:
128
+
129
+ ```json
130
+ {
131
+ "mcpServers": {
132
+ "equity-intel": {
133
+ "command": "python",
134
+ "args": ["-m", "equity_intel_mcp"],
135
+ "cwd": "C:/path/to/equity-intel-mcp",
136
+ "env": { "EDGAR_IDENTITY": "Your Name you@example.com" }
137
+ }
138
+ }
139
+ }
140
+ ```
141
+
142
+ Then just ask Claude: *"Give me a full read on NVDA"* or *"Are insiders buying PLTR?"*
143
+
144
+ ---
145
+
146
+ ## Why it's built this way
147
+
148
+ - **Uniform signal contract.** Every source returns the same shape
149
+ (`score -100..+100`, `confidence 0..1`, `data`, `summary`). That's what lets an
150
+ LLM reason *across* heterogeneous evidence instead of parsing five formats.
151
+ - **Graceful degradation.** A stock with no Form 4 activity returns *"no signal"*,
152
+ not a fake bearish score. Missing data lowers confidence; it never invents a call.
153
+ - **Confidence-weighted blending.** The composite weights each source by its
154
+ importance × its own confidence, so thin signals don't outvote strong ones.
155
+ - **Resilient + cached.** Short per-source TTL caches avoid hammering rate-limited
156
+ endpoints when an agent calls several tools on the same ticker in one turn.
157
+
158
+ See [ROADMAP.md](ROADMAP.md) for what's next.
159
+
160
+ ---
161
+
162
+ ## Disclaimer
163
+
164
+ For research and educational use only. **Not investment advice.** Data comes from
165
+ third-party public sources and may be delayed or incomplete. Do your own due
166
+ diligence.
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,137 @@
1
+ # equity-intel-mcp
2
+
3
+ [![equity-intel-mcp MCP server](https://glama.ai/mcp/servers/cstamigo-droid/equity-intel-mcp/badges/score.svg)](https://glama.ai/mcp/servers/cstamigo-droid/equity-intel-mcp)
4
+
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Python](https://img.shields.io/badge/Python-3.10%2B-3776AB.svg?logo=python&logoColor=white)](https://www.python.org) [![MCP](https://img.shields.io/badge/Model_Context_Protocol-server-7C3AED.svg)](https://modelcontextprotocol.io)
6
+
7
+ ![equity-intel-mcp - composite verdict demo](docs/demo.png)
8
+
9
+
10
+ **Institutional-grade equity analysis for any LLM, over the Model Context Protocol.**
11
+
12
+ Most "stock" integrations just echo a price. This one gives an AI agent the
13
+ signals professionals actually look at — **insider buying from SEC filings**,
14
+ **what renowned value investors are holding**, analyst consensus, options-implied
15
+ moves, and valuation — and blends them into a single, confidence-weighted verdict.
16
+
17
+ It runs entirely on **free / public data** (Yahoo Finance, SEC EDGAR, Dataroma),
18
+ fails gracefully when a source is missing, and never fabricates a signal it
19
+ doesn't have.
20
+
21
+ ```
22
+ # Equity Intelligence — MSFT
23
+
24
+ ## Verdict: Neutral → HOLD
25
+ [........|#.......] +11/100 · confidence 69% · 5 source(s)
26
+
27
+ | Source | Signal | Score | Conf | Weight |
28
+ |---------------|--------------|-------:|-----:|-------:|
29
+ | insider | Bearish | -77 | 70% | 0.25 |
30
+ | superinvestor | Lean bullish | +33 | 100% | 0.30 |
31
+ | analysts | Bullish | +64 | 75% | 0.15 |
32
+ | valuation | Bullish | +91 | 60% | 0.09 |
33
+ | options | Lean bullish | +19 | 40% | 0.04 |
34
+
35
+ - insider: Insiders net selling $13.5M over 180d (62 filings).
36
+ - superinvestor: 38 tracked superinvestors hold MSFT; 19 buys / 18 sells.
37
+ - analysts: 66 analysts: 23 strong buy / 38 buy / 5 hold.
38
+ - valuation: Fair value ~$569 vs $391 (+46% upside); health 84/100.
39
+ - options: 1-month implied move +/-7.6%; put/call OI 0.53.
40
+ ```
41
+
42
+ ---
43
+
44
+ ## Tools
45
+
46
+ | Tool | What it does | Source | Status |
47
+ |------|--------------|--------|:------:|
48
+ | `equity_analyze_ticker` | **Hero tool.** Runs every source in parallel and returns one scored verdict (BUY → AVOID) with a per-source breakdown. | composite | ✅ |
49
+ | `equity_insider_activity` | Net insider buying vs. selling from SEC **Form 4** filings (180-day window), weighted by USD value. | SEC EDGAR | ✅ |
50
+ | `equity_superinvestors` | Which of ~80 tracked value investors hold the stock, plus recent net buying/selling. | Dataroma | ✅ |
51
+ | `equity_get_quote` | Live price snapshot + position in the 52-week range. | Yahoo Finance | ✅ |
52
+ | `equity_analyst_consensus` | Wall Street buy/hold/sell consensus — scored from distribution of strong-buy to strong-sell ratings. | Finnhub | ✅ |
53
+ | `equity_options_signal` | 1-month implied move (straddle/spot) + put/call OI skew. Primary use: risk-sizing. | Yahoo Finance | ✅ |
54
+ | `equity_valuation` | Fair-value estimate (forward EPS × sector P/E) + financial-health score (debt, liquidity, margins). | Yahoo Finance | ✅ |
55
+
56
+ Every tool returns **Markdown** (human-readable, default) or **JSON**
57
+ (`response_format="json"`) for programmatic use.
58
+
59
+ ---
60
+
61
+ ## Quick start
62
+
63
+ ```bash
64
+ git clone https://github.com/cstamigo-droid/equity-intel-mcp equity-intel-mcp
65
+ cd equity-intel-mcp
66
+ python -m venv .venv && .venv\Scripts\activate # Windows
67
+ pip install -r requirements.txt
68
+
69
+ copy .env.example .env # then edit .env (set EDGAR_IDENTITY)
70
+ python -m equity_intel_mcp # starts the MCP server over stdio
71
+ ```
72
+
73
+ **Smoke test** (hits the live sources and prints each signal):
74
+
75
+ ```bash
76
+ python tests/test_smoke.py AAPL
77
+ ```
78
+
79
+ ### Configure (`.env`)
80
+
81
+ ```ini
82
+ # Required by SEC fair-access policy — any "Name email@example.com"
83
+ EDGAR_IDENTITY=Your Name you@example.com
84
+ # Required by equity_analyst_consensus (free key at finnhub.io)
85
+ FINNHUB_API_KEY=your-key-here
86
+ ```
87
+
88
+ ---
89
+
90
+ ## Use it in Claude Desktop
91
+
92
+ Add this to `claude_desktop_config.json`
93
+ (`%APPDATA%\Claude\` on Windows, `~/Library/Application Support/Claude/` on macOS),
94
+ then restart Claude Desktop:
95
+
96
+ ```json
97
+ {
98
+ "mcpServers": {
99
+ "equity-intel": {
100
+ "command": "python",
101
+ "args": ["-m", "equity_intel_mcp"],
102
+ "cwd": "C:/path/to/equity-intel-mcp",
103
+ "env": { "EDGAR_IDENTITY": "Your Name you@example.com" }
104
+ }
105
+ }
106
+ }
107
+ ```
108
+
109
+ Then just ask Claude: *"Give me a full read on NVDA"* or *"Are insiders buying PLTR?"*
110
+
111
+ ---
112
+
113
+ ## Why it's built this way
114
+
115
+ - **Uniform signal contract.** Every source returns the same shape
116
+ (`score -100..+100`, `confidence 0..1`, `data`, `summary`). That's what lets an
117
+ LLM reason *across* heterogeneous evidence instead of parsing five formats.
118
+ - **Graceful degradation.** A stock with no Form 4 activity returns *"no signal"*,
119
+ not a fake bearish score. Missing data lowers confidence; it never invents a call.
120
+ - **Confidence-weighted blending.** The composite weights each source by its
121
+ importance × its own confidence, so thin signals don't outvote strong ones.
122
+ - **Resilient + cached.** Short per-source TTL caches avoid hammering rate-limited
123
+ endpoints when an agent calls several tools on the same ticker in one turn.
124
+
125
+ See [ROADMAP.md](ROADMAP.md) for what's next.
126
+
127
+ ---
128
+
129
+ ## Disclaimer
130
+
131
+ For research and educational use only. **Not investment advice.** Data comes from
132
+ third-party public sources and may be delayed or incomplete. Do your own due
133
+ diligence.
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,9 @@
1
+ """equity_intel_mcp — institutional-grade equity analysis over the Model Context Protocol.
2
+
3
+ Gives any MCP client (Claude Desktop, Claude Code, agents) clean tools for:
4
+ insider activity (SEC Form 4), superinvestor holdings (Dataroma), Wall Street
5
+ consensus, options-implied moves, valuation & financial health, and a composite
6
+ scored verdict — all from free/public data sources with graceful degradation.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow `python -m equity_intel_mcp` to launch the server."""
2
+ from .server import main
3
+
4
+ if __name__ == "__main__":
5
+ main()
@@ -0,0 +1,90 @@
1
+ """Composite verdict — blends every directional source into one scored call.
2
+
3
+ The blend is a confidence-weighted average: each source's directional score is
4
+ weighted by (source importance x its own confidence), so a high-conviction
5
+ insider signal moves the needle more than a thin, low-confidence one. Sources
6
+ that returned no data are simply absent from the average — missing data lowers
7
+ overall confidence but never drags the score toward a false bearish/bullish read.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from statistics import mean
12
+ from typing import Any
13
+
14
+ from .signal import Signal, clamp
15
+
16
+ # Relative importance of each directional source in the final verdict.
17
+ # Sources not yet implemented are listed so `coverage` reflects the full design.
18
+ SOURCE_WEIGHTS: dict[str, float] = {
19
+ "insider": 0.35,
20
+ "superinvestor": 0.30,
21
+ "analysts": 0.20,
22
+ "valuation": 0.15,
23
+ "options": 0.10,
24
+ }
25
+
26
+
27
+ def _verdict(score: float) -> tuple[str, str]:
28
+ """Map a composite score to (verdict label, recommended action)."""
29
+ if score >= 40:
30
+ return "Bullish", "BUY"
31
+ if score >= 15:
32
+ return "Lean bullish", "ACCUMULATE"
33
+ if score > -15:
34
+ return "Neutral", "HOLD"
35
+ if score > -40:
36
+ return "Lean bearish", "TRIM"
37
+ return "Bearish", "AVOID"
38
+
39
+
40
+ def composite(signals: list[Signal]) -> dict[str, Any]:
41
+ """Blend directional signals into a single verdict.
42
+
43
+ Returns a dict: score (-100..100), confidence (0..1), verdict, action,
44
+ n_sources, coverage, and a per-source breakdown list.
45
+ """
46
+ directional = [s for s in signals if s.directional and s.ok]
47
+ if not directional:
48
+ return {
49
+ "score": 0.0,
50
+ "confidence": 0.0,
51
+ "verdict": "No data",
52
+ "action": "INSUFFICIENT DATA",
53
+ "n_sources": 0,
54
+ "coverage": 0.0,
55
+ "breakdown": [],
56
+ }
57
+
58
+ num = den = 0.0
59
+ breakdown = []
60
+ for s in directional:
61
+ weight = SOURCE_WEIGHTS.get(s.source, 0.1) * s.confidence
62
+ num += s.score * weight
63
+ den += weight
64
+ breakdown.append(
65
+ {
66
+ "source": s.source,
67
+ "score": s.score,
68
+ "confidence": s.confidence,
69
+ "effective_weight": round(weight, 3),
70
+ "summary": s.summary,
71
+ }
72
+ )
73
+
74
+ score = clamp(num / den if den else 0.0, -100.0, 100.0)
75
+ verdict, action = _verdict(score)
76
+
77
+ # Confidence reflects both per-source quality and how many of the designed
78
+ # sources actually reported (coverage).
79
+ coverage = len(directional) / len(SOURCE_WEIGHTS)
80
+ confidence = mean(s.confidence for s in directional) * (0.5 + 0.5 * coverage)
81
+
82
+ return {
83
+ "score": round(score, 1),
84
+ "confidence": round(confidence, 2),
85
+ "verdict": verdict,
86
+ "action": action,
87
+ "n_sources": len(directional),
88
+ "coverage": round(coverage, 2),
89
+ "breakdown": breakdown,
90
+ }
@@ -0,0 +1,41 @@
1
+ """Tiny thread-safe TTL cache.
2
+
3
+ External data sources (Yahoo, SEC, Dataroma) are slow and rate-limited. A short
4
+ in-process cache means an agent that calls several tools on the same ticker in
5
+ one turn — or the composite `analyze_ticker` that fans out to every source —
6
+ doesn't refetch the same data seconds apart. TTLs are per-source (a quote ages
7
+ in 60s; a 13F holding list can live for an hour).
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import threading
12
+ import time
13
+ from typing import Any, Callable
14
+
15
+ _lock = threading.Lock()
16
+ _store: dict[str, tuple[float, Any]] = {}
17
+
18
+
19
+ def get_or_fetch(key: str, ttl_s: float, fetch: Callable[[], Any]) -> Any:
20
+ """Return a cached value if fresh, otherwise call `fetch()` and store it.
21
+
22
+ `fetch` runs OUTSIDE the lock so a slow network call never blocks other
23
+ cache readers. A rare duplicate fetch under concurrency is acceptable.
24
+ """
25
+ now = time.monotonic()
26
+ with _lock:
27
+ hit = _store.get(key)
28
+ if hit is not None and (now - hit[0]) < ttl_s:
29
+ return hit[1]
30
+
31
+ value = fetch()
32
+
33
+ with _lock:
34
+ _store[key] = (time.monotonic(), value)
35
+ return value
36
+
37
+
38
+ def clear() -> None:
39
+ """Drop all cached entries (used by tests)."""
40
+ with _lock:
41
+ _store.clear()
@@ -0,0 +1,124 @@
1
+ """Render Signals as Markdown (human) or JSON (machine) for MCP tool output."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from enum import Enum
6
+
7
+ from .signal import Signal, clamp, label
8
+
9
+
10
+ class ResponseFormat(str, Enum):
11
+ """Output format for tool responses."""
12
+
13
+ MARKDOWN = "markdown"
14
+ JSON = "json"
15
+
16
+
17
+ def _gauge(score: float) -> str:
18
+ """A compact ASCII gauge for a -100..+100 score.
19
+
20
+ Bearish fills left of center, bullish fills right, e.g.
21
+ -100 -> `[########|........]`, +50 -> `[........|####....]`.
22
+ """
23
+ n = 8 # half-width
24
+ s = clamp(score, -100, 100)
25
+ filled = int(round(abs(s) / 100 * n))
26
+ if s < 0:
27
+ left, right = "." * (n - filled) + "#" * filled, "." * n
28
+ elif s > 0:
29
+ left, right = "." * n, "#" * filled + "." * (n - filled)
30
+ else:
31
+ left = right = "." * n
32
+ return f"[{left}|{right}]"
33
+
34
+
35
+ def render(signal: Signal, title: str, fmt: ResponseFormat) -> str:
36
+ """Format a single Signal for return to an MCP client."""
37
+ if fmt == ResponseFormat.JSON:
38
+ return json.dumps(signal.to_dict(), indent=2, default=str)
39
+
40
+ lines = [f"# {title}"]
41
+ if not signal.ok:
42
+ lines += ["", f"⚠️ No signal available — {signal.error}"]
43
+ return "\n".join(lines)
44
+
45
+ if signal.directional:
46
+ lines += [
47
+ "",
48
+ f"**Signal:** {label(signal.score)} "
49
+ f"`{_gauge(signal.score)}` {signal.score:+.0f}/100 "
50
+ f"· confidence {signal.confidence:.0%}",
51
+ ]
52
+ if signal.summary:
53
+ lines += ["", signal.summary]
54
+
55
+ if signal.data:
56
+ lines += ["", "## Details"]
57
+ for k, v in signal.data.items():
58
+ if v is None or k == "url":
59
+ continue
60
+ lines.append(f"- **{k.replace('_', ' ')}:** {_fmt_value(v)}")
61
+ url = signal.data.get("url")
62
+ if url:
63
+ lines += ["", f"Source: {url}"]
64
+
65
+ return "\n".join(lines)
66
+
67
+
68
+ def render_composite(ticker: str, result: dict, signals: list[Signal], fmt: ResponseFormat) -> str:
69
+ """Render the composite `analyze_ticker` verdict plus its source breakdown."""
70
+ if fmt == ResponseFormat.JSON:
71
+ return json.dumps(
72
+ {"ticker": ticker, "verdict": result, "signals": [s.to_dict() for s in signals]},
73
+ indent=2,
74
+ default=str,
75
+ )
76
+
77
+ lines = [
78
+ f"# Equity Intelligence — {ticker}",
79
+ "",
80
+ f"## Verdict: {result['verdict']} → **{result['action']}**",
81
+ f"`{_gauge(result['score'])}` {result['score']:+.0f}/100 "
82
+ f"· confidence {result['confidence']:.0%} "
83
+ f"· {result['n_sources']} source(s), {result['coverage']:.0%} coverage",
84
+ ]
85
+
86
+ quote_sig = next((s for s in signals if s.source == "quote" and s.ok), None)
87
+ if quote_sig:
88
+ lines += ["", f"**Price context:** {quote_sig.summary}"]
89
+
90
+ if result["breakdown"]:
91
+ lines += [
92
+ "",
93
+ "## Signal breakdown",
94
+ "| Source | Signal | Score | Conf | Weight |",
95
+ "|---|---|---:|---:|---:|",
96
+ ]
97
+ for b in result["breakdown"]:
98
+ lines.append(
99
+ f"| {b['source']} | {label(b['score'])} | {b['score']:+.0f} "
100
+ f"| {b['confidence']:.0%} | {b['effective_weight']:.2f} |"
101
+ )
102
+ lines += ["", "## What each source says"]
103
+ for b in result["breakdown"]:
104
+ lines.append(f"- **{b['source']}:** {b['summary']}")
105
+
106
+ skipped = [s for s in signals if s.directional and not s.ok]
107
+ if skipped:
108
+ lines += ["", "## No signal (data unavailable)"]
109
+ for s in skipped:
110
+ lines.append(f"- **{s.source}:** {s.error}")
111
+
112
+ return "\n".join(lines)
113
+
114
+
115
+ def _fmt_value(v: object) -> str:
116
+ if isinstance(v, bool):
117
+ return "yes" if v else "no"
118
+ if isinstance(v, float):
119
+ return f"{v:,.2f}"
120
+ if isinstance(v, int):
121
+ return f"{v:,}"
122
+ if isinstance(v, list):
123
+ return ", ".join(str(x) for x in v[:12]) + (" …" if len(v) > 12 else "")
124
+ return str(v)