friday-framework-finance 0.1.0a0__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.
- friday_framework_finance-0.1.0a0/.gitignore +24 -0
- friday_framework_finance-0.1.0a0/PKG-INFO +45 -0
- friday_framework_finance-0.1.0a0/README.md +29 -0
- friday_framework_finance-0.1.0a0/pyproject.toml +32 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/__init__.py +37 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/demo.py +129 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/providers/__init__.py +2 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/providers/massive/__init__.py +30 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/providers/massive/client.py +109 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/providers/massive/exceptions.py +37 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/providers/massive/governor.py +56 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/providers/massive/modules/__init__.py +6 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/providers/massive/modules/crypto.py +136 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/providers/massive/modules/equities.py +134 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/providers/massive/modules/indicators.py +143 -0
- friday_framework_finance-0.1.0a0/src/friday_finance/providers/massive/registry.py +43 -0
- friday_framework_finance-0.1.0a0/tests/test_basics.py +103 -0
- friday_framework_finance-0.1.0a0/tests/test_integration_massive.py +157 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
__pycache__/
|
|
2
|
+
*.pyc
|
|
3
|
+
.env
|
|
4
|
+
.venv/
|
|
5
|
+
.idea/
|
|
6
|
+
.vscode/
|
|
7
|
+
dist/
|
|
8
|
+
build/
|
|
9
|
+
*.egg-info/
|
|
10
|
+
chroma_db/
|
|
11
|
+
.friday/
|
|
12
|
+
/vector/
|
|
13
|
+
/exports/
|
|
14
|
+
tests/integration/memory/vector/*/
|
|
15
|
+
tests/integration/memory/vector/backups/*/
|
|
16
|
+
tests/integration/memory/vector/reorganization_logs/*
|
|
17
|
+
*.gpickle
|
|
18
|
+
keys.txt
|
|
19
|
+
experiments/*/config/runtime.local.yaml
|
|
20
|
+
experiments/*/artifacts/*
|
|
21
|
+
!experiments/*/artifacts/.gitkeep
|
|
22
|
+
scripts/source-friday-chat-example-env.sh
|
|
23
|
+
scripts/pypi.sh
|
|
24
|
+
/docs/reference/PyPI-Recovery-Codes-cichuck-2026-07-20T15_36_49.709334.txt
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: friday-framework-finance
|
|
3
|
+
Version: 0.1.0a0
|
|
4
|
+
Summary: Financial data provider integration for Friday Agent Framework
|
|
5
|
+
Project-URL: Homepage, https://github.com/CIChuck/agent-framework
|
|
6
|
+
Project-URL: Repository, https://github.com/CIChuck/agent-framework
|
|
7
|
+
Project-URL: Issues, https://github.com/CIChuck/agent-framework/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/CIChuck/agent-framework/tree/main/docs
|
|
9
|
+
Project-URL: Source, https://github.com/CIChuck/agent-framework/tree/main/packages/friday-finance
|
|
10
|
+
Author: Friday Team
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Requires-Dist: friday-framework-core==0.1.0a0
|
|
14
|
+
Requires-Dist: massive
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# Friday Finance
|
|
18
|
+
|
|
19
|
+
This package provides financial data integration for the Friday Agent Framework.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install friday-framework-finance
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Import Package
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import friday_finance
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Providers
|
|
34
|
+
|
|
35
|
+
### Massive (Massive Agent Core)
|
|
36
|
+
A robust, asynchronous wrapper for the Massive API, designed for AI agents.
|
|
37
|
+
|
|
38
|
+
- **Equities**: Stock data with market-day resolution.
|
|
39
|
+
- **Crypto**: Real-time cryptocurrency snapshots.
|
|
40
|
+
- **Indicators**: Technical analysis (SMA, RSI, Trend).
|
|
41
|
+
- **Governor**: Tier-sensitive rate limiting.
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Friday Finance
|
|
2
|
+
|
|
3
|
+
This package provides financial data integration for the Friday Agent Framework.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install friday-framework-finance
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Import Package
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
import friday_finance
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Providers
|
|
18
|
+
|
|
19
|
+
### Massive (Massive Agent Core)
|
|
20
|
+
A robust, asynchronous wrapper for the Massive API, designed for AI agents.
|
|
21
|
+
|
|
22
|
+
- **Equities**: Stock data with market-day resolution.
|
|
23
|
+
- **Crypto**: Real-time cryptocurrency snapshots.
|
|
24
|
+
- **Indicators**: Technical analysis (SMA, RSI, Trend).
|
|
25
|
+
- **Governor**: Tier-sensitive rate limiting.
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
|
|
29
|
+
MIT
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "friday-framework-finance"
|
|
3
|
+
version = "0.1.0a0"
|
|
4
|
+
description = "Financial data provider integration for Friday Agent Framework"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
authors = [{ name = "Friday Team" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"massive",
|
|
11
|
+
"friday-framework-core==0.1.0a0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/CIChuck/agent-framework"
|
|
16
|
+
Repository = "https://github.com/CIChuck/agent-framework"
|
|
17
|
+
Issues = "https://github.com/CIChuck/agent-framework/issues"
|
|
18
|
+
Documentation = "https://github.com/CIChuck/agent-framework/tree/main/docs"
|
|
19
|
+
Source = "https://github.com/CIChuck/agent-framework/tree/main/packages/friday-finance"
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/friday_finance"]
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
friday-finance-demo = "friday_finance.demo:main"
|
|
30
|
+
|
|
31
|
+
[tool.uv.sources]
|
|
32
|
+
friday-framework-core = { workspace = true }
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
"""
|
|
3
|
+
Friday Finance - Financial data provider integration.
|
|
4
|
+
|
|
5
|
+
This package provides data-access modules for financial APIs.
|
|
6
|
+
Tool wrappers (BaseTool subclasses) live in ``friday_tools.finance``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .providers.massive import (
|
|
10
|
+
AssetRegistry,
|
|
11
|
+
CryptoModule,
|
|
12
|
+
EquitiesModule,
|
|
13
|
+
IndicatorsModule,
|
|
14
|
+
MassiveAgentClient,
|
|
15
|
+
MassiveAuthError,
|
|
16
|
+
MassiveBaseException,
|
|
17
|
+
MassiveGovernor,
|
|
18
|
+
MassiveMarketClosedError,
|
|
19
|
+
MassiveNotFoundError,
|
|
20
|
+
MassiveRateLimitError,
|
|
21
|
+
MassiveTimeoutError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"MassiveAgentClient",
|
|
26
|
+
"MassiveGovernor",
|
|
27
|
+
"AssetRegistry",
|
|
28
|
+
"EquitiesModule",
|
|
29
|
+
"CryptoModule",
|
|
30
|
+
"IndicatorsModule",
|
|
31
|
+
"MassiveAuthError",
|
|
32
|
+
"MassiveBaseException",
|
|
33
|
+
"MassiveMarketClosedError",
|
|
34
|
+
"MassiveNotFoundError",
|
|
35
|
+
"MassiveRateLimitError",
|
|
36
|
+
"MassiveTimeoutError",
|
|
37
|
+
]
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Coroutine
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from friday_finance import MassiveAgentClient
|
|
10
|
+
|
|
11
|
+
# ANSI Colors for nicer CLI output
|
|
12
|
+
CYAN = "\033[96m"
|
|
13
|
+
GREEN = "\033[92m"
|
|
14
|
+
YELLOW = "\033[93m"
|
|
15
|
+
RED = "\033[91m"
|
|
16
|
+
RESET = "\033[0m"
|
|
17
|
+
BOLD = "\033[1m"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def print_header(title: str):
|
|
21
|
+
print(f"\n{BOLD}{CYAN}=== {title} ==={RESET}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def print_result(label: str, data: Any):
|
|
25
|
+
if isinstance(data, (dict, list)):
|
|
26
|
+
formatted = json.dumps(data, indent=2, default=str)
|
|
27
|
+
print(f"{GREEN}{label}:{RESET}\n{formatted}")
|
|
28
|
+
else:
|
|
29
|
+
print(f"{GREEN}{label}:{RESET} {data}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def print_error(label: str, error: Exception):
|
|
33
|
+
print(f"{RED}{label} FAILED:{RESET} {error}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def run_step(name: str, coro: Coroutine):
|
|
37
|
+
print(f"\n{YELLOW}Running: {name}...{RESET}")
|
|
38
|
+
start = time.time()
|
|
39
|
+
try:
|
|
40
|
+
result = await coro
|
|
41
|
+
elapsed = time.time() - start
|
|
42
|
+
print_result(f"Result ({elapsed:.2f}s)", result)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
print_error(name, e)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def async_main():
|
|
48
|
+
print_header("Friday Finance - Massive Provider Reference App")
|
|
49
|
+
|
|
50
|
+
# 1. Setup & Auth
|
|
51
|
+
api_key = os.getenv("MASSIVE_API_KEY")
|
|
52
|
+
if not api_key:
|
|
53
|
+
print(f"{RED}Error: MASSIVE_API_KEY environment variable is not set.{RESET}")
|
|
54
|
+
print("Please export it and try again:")
|
|
55
|
+
print(" export MASSIVE_API_KEY='your_key_here'")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
# Check for tier override
|
|
59
|
+
tier = os.getenv("MASSIVE_TIER", "free")
|
|
60
|
+
print(f"Initializing Client (Tier: {BOLD}{tier}{RESET})...")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
client = MassiveAgentClient(api_key=api_key, tier=tier)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
print_error("Initialization", e)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# 2. Equities Module Tests
|
|
69
|
+
print_header("Testing Equities Module")
|
|
70
|
+
|
|
71
|
+
# A. Company Details
|
|
72
|
+
await run_step("get_details('AAPL')", client.equities.get_details("AAPL"))
|
|
73
|
+
|
|
74
|
+
# B. EOD Price
|
|
75
|
+
await run_step("get_eod_price('MSFT')", client.equities.get_eod_price("MSFT"))
|
|
76
|
+
|
|
77
|
+
# C. Market Summary (Combines Details + Price)
|
|
78
|
+
await run_step(
|
|
79
|
+
"get_market_summary('NVDA')", client.equities.get_market_summary("NVDA")
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# D. ETF Proxy Lookup (Registry Test)
|
|
83
|
+
print(f"\n{YELLOW}Testing Registry Lookup (sp500 -> SPY)...{RESET}")
|
|
84
|
+
await run_step("get_eod_price('sp500')", client.equities.get_eod_price("sp500"))
|
|
85
|
+
|
|
86
|
+
# 3. Crypto Module Tests
|
|
87
|
+
print_header("Testing Crypto Module (24/7)")
|
|
88
|
+
|
|
89
|
+
# A. Snapshot
|
|
90
|
+
await run_step("get_snapshot('BTC')", client.crypto.get_snapshot("BTC"))
|
|
91
|
+
|
|
92
|
+
# B. Risk On/Off Comparison
|
|
93
|
+
await run_step(
|
|
94
|
+
"get_risk_on_comparison() [BTC vs AGG]", client.crypto.get_risk_on_comparison()
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# 4. Indicators Module Tests
|
|
98
|
+
print_header("Testing Indicators Module")
|
|
99
|
+
|
|
100
|
+
# A. SMA (Simple Moving Average)
|
|
101
|
+
await run_step(
|
|
102
|
+
"get_sma('SPY', window=50)", client.indicators.get_sma("SPY", window=50)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
# B. RSI (Relative Strength Index)
|
|
106
|
+
await run_step(
|
|
107
|
+
"get_rsi('SPY', window=14)", client.indicators.get_rsi("SPY", window=14)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# C. Trend Analysis (Golden Cross Check)
|
|
111
|
+
# Note: This makes 2 calls (SMA50 + SMA200).
|
|
112
|
+
# On Free tier, this will demonstrate the Governor waiting 12s between calls.
|
|
113
|
+
print(f"\n{YELLOW}Testing Trend Analysis (Expect delays on Free Tier)...{RESET}")
|
|
114
|
+
await run_step(
|
|
115
|
+
"get_trend_analysis('SPY')", client.indicators.get_trend_analysis("SPY")
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
print_header("Test Complete")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main():
|
|
122
|
+
try:
|
|
123
|
+
asyncio.run(async_main())
|
|
124
|
+
except KeyboardInterrupt:
|
|
125
|
+
print(f"\n{RED}Test interrupted by user.{RESET}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
if __name__ == "__main__":
|
|
129
|
+
main()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
from .client import MassiveAgentClient
|
|
3
|
+
from .exceptions import (
|
|
4
|
+
MassiveAuthError,
|
|
5
|
+
MassiveBaseException,
|
|
6
|
+
MassiveMarketClosedError,
|
|
7
|
+
MassiveNotFoundError,
|
|
8
|
+
MassiveRateLimitError,
|
|
9
|
+
MassiveTimeoutError,
|
|
10
|
+
)
|
|
11
|
+
from .governor import MassiveGovernor
|
|
12
|
+
from .modules.crypto import CryptoModule
|
|
13
|
+
from .modules.equities import EquitiesModule
|
|
14
|
+
from .modules.indicators import IndicatorsModule
|
|
15
|
+
from .registry import AssetRegistry
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"MassiveAgentClient",
|
|
19
|
+
"MassiveGovernor",
|
|
20
|
+
"AssetRegistry",
|
|
21
|
+
"MassiveAuthError",
|
|
22
|
+
"MassiveBaseException",
|
|
23
|
+
"MassiveMarketClosedError",
|
|
24
|
+
"MassiveNotFoundError",
|
|
25
|
+
"MassiveRateLimitError",
|
|
26
|
+
"MassiveTimeoutError",
|
|
27
|
+
"EquitiesModule",
|
|
28
|
+
"CryptoModule",
|
|
29
|
+
"IndicatorsModule",
|
|
30
|
+
]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
from friday_core.logging import get_logger
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
from massive import RESTClient
|
|
9
|
+
except ImportError:
|
|
10
|
+
# Placeholder for development if the SDK is not installed
|
|
11
|
+
class RESTClient:
|
|
12
|
+
"""Stub RESTClient for development without the Massive SDK installed."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, api_key):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
from .exceptions import MassiveAuthError, MassiveRateLimitError
|
|
19
|
+
from .governor import MassiveGovernor
|
|
20
|
+
from .modules.crypto import CryptoModule
|
|
21
|
+
from .modules.equities import EquitiesModule
|
|
22
|
+
from .modules.indicators import IndicatorsModule
|
|
23
|
+
|
|
24
|
+
logger = get_logger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MassiveAgentClient:
|
|
28
|
+
"""
|
|
29
|
+
Main entry point for the Massive Agent Core.
|
|
30
|
+
|
|
31
|
+
Orchestrates modules (Equities, Crypto, Indicators) and manages
|
|
32
|
+
the async Governor for tier-aware rate limiting. All SDK calls
|
|
33
|
+
should go through ``execute_query`` to respect rate limits.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, api_key: str = None, tier: str = "free"):
|
|
37
|
+
"""
|
|
38
|
+
Initialise the Massive Agent client.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
api_key: Massive API key. Falls back to the ``MASSIVE_API_KEY``
|
|
42
|
+
environment variable when *None*.
|
|
43
|
+
tier: API tier — ``"free"`` (5 req/min) or ``"starter"``.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
MassiveAuthError: If no API key is provided or found in the
|
|
47
|
+
environment.
|
|
48
|
+
"""
|
|
49
|
+
self.api_key = api_key or os.getenv("MASSIVE_API_KEY")
|
|
50
|
+
if not self.api_key:
|
|
51
|
+
raise MassiveAuthError(
|
|
52
|
+
"API key is missing. Provide it or set the MASSIVE_API_KEY environment variable." # noqa: E501
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
self.tier = tier
|
|
56
|
+
self.sdk = RESTClient(self.api_key)
|
|
57
|
+
self.governor = MassiveGovernor(tier=self.tier)
|
|
58
|
+
|
|
59
|
+
# Initialize Modules
|
|
60
|
+
self.equities = EquitiesModule(self)
|
|
61
|
+
self.crypto = CryptoModule(self)
|
|
62
|
+
self.indicators = IndicatorsModule(self)
|
|
63
|
+
|
|
64
|
+
logger.info(
|
|
65
|
+
"MassiveAgentClient initialised",
|
|
66
|
+
tier=self.tier,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
async def probe_tier(self):
|
|
70
|
+
"""
|
|
71
|
+
Automatically detect the API tier from response headers.
|
|
72
|
+
|
|
73
|
+
Not yet implemented — the client relies on the *tier* constructor
|
|
74
|
+
parameter for now.
|
|
75
|
+
"""
|
|
76
|
+
# For now, we rely on the manual 'tier' parameter
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
async def execute_query(self, func, *args, **kwargs):
|
|
80
|
+
"""
|
|
81
|
+
Execute an SDK call through the Governor rate limiter.
|
|
82
|
+
|
|
83
|
+
Synchronous SDK functions are dispatched to a thread-pool executor
|
|
84
|
+
so the async event loop is never blocked.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
func: The SDK callable to invoke.
|
|
88
|
+
*args: Positional arguments forwarded to *func*.
|
|
89
|
+
**kwargs: Keyword arguments forwarded to *func*.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
The raw result from the SDK call.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
MassiveRateLimitError: If the API returns HTTP 429 despite the
|
|
96
|
+
governor.
|
|
97
|
+
"""
|
|
98
|
+
await self.governor.wait()
|
|
99
|
+
try:
|
|
100
|
+
loop = asyncio.get_running_loop()
|
|
101
|
+
result = await loop.run_in_executor(None, lambda: func(*args, **kwargs))
|
|
102
|
+
logger.debug("SDK query succeeded", func=func.__name__)
|
|
103
|
+
return result
|
|
104
|
+
except Exception as e:
|
|
105
|
+
if "429" in str(e):
|
|
106
|
+
logger.error("Rate limit hit despite governor", func=func.__name__)
|
|
107
|
+
raise MassiveRateLimitError("Rate limit hit despite governor.") from e
|
|
108
|
+
logger.error("SDK query failed", func=func.__name__, error=str(e))
|
|
109
|
+
raise
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
class MassiveBaseException(Exception):
|
|
3
|
+
"""Base exception for all massive-agent-core errors."""
|
|
4
|
+
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class MassiveAuthError(MassiveBaseException):
|
|
9
|
+
"""Raised when the API Key is invalid or missing."""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class MassiveRateLimitError(MassiveBaseException):
|
|
15
|
+
"""Raised when the 5 calls/minute limit is exceeded."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, message="Rate limit exceeded. Wait 60 seconds.", retry_after=60):
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.retry_after = retry_after
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class MassiveMarketClosedError(MassiveBaseException):
|
|
23
|
+
"""Raised when data is requested for a time the market was closed."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class MassiveNotFoundError(MassiveBaseException):
|
|
29
|
+
"""Raised when a ticker or indicator is not found."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class MassiveTimeoutError(MassiveBaseException):
|
|
35
|
+
"""Raised when a query exceeds the defined timeout."""
|
|
36
|
+
|
|
37
|
+
pass
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
import asyncio
|
|
3
|
+
|
|
4
|
+
from friday_core.logging import get_logger
|
|
5
|
+
|
|
6
|
+
logger = get_logger(__name__)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MassiveGovernor:
|
|
10
|
+
"""
|
|
11
|
+
A tier-aware rate limiter using a modified Token Bucket algorithm.
|
|
12
|
+
|
|
13
|
+
Enforces a minimum interval between successive API calls so that
|
|
14
|
+
the Massive free-tier limit (5 requests / minute) is never exceeded.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
TIER_INTERVALS = {
|
|
18
|
+
"free": 12.05, # 5 per minute ≈ 12 s spacing
|
|
19
|
+
"starter": 0.05, # Effectively unlimited
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
def __init__(self, tier: str = "free"):
|
|
23
|
+
"""
|
|
24
|
+
Initialise the governor.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
tier: API tier — ``"free"`` or ``"starter"``.
|
|
28
|
+
"""
|
|
29
|
+
self.tier = tier.lower()
|
|
30
|
+
self.interval = self.TIER_INTERVALS.get(self.tier, 12.05)
|
|
31
|
+
self.last_call = 0.0
|
|
32
|
+
self.lock = asyncio.Lock()
|
|
33
|
+
|
|
34
|
+
async def wait(self):
|
|
35
|
+
"""Ensure the next call respects the tier's time boundary."""
|
|
36
|
+
async with self.lock:
|
|
37
|
+
now = asyncio.get_running_loop().time()
|
|
38
|
+
elapsed = now - self.last_call
|
|
39
|
+
|
|
40
|
+
if elapsed < self.interval:
|
|
41
|
+
sleep_time = self.interval - elapsed
|
|
42
|
+
logger.debug("Governor throttling", sleep_seconds=round(sleep_time, 2))
|
|
43
|
+
await asyncio.sleep(sleep_time)
|
|
44
|
+
|
|
45
|
+
self.last_call = asyncio.get_running_loop().time()
|
|
46
|
+
|
|
47
|
+
def update_tier(self, tier: str):
|
|
48
|
+
"""
|
|
49
|
+
Switch to a different API tier at runtime.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
tier: The new tier name (``"free"`` or ``"starter"``).
|
|
53
|
+
"""
|
|
54
|
+
self.tier = tier.lower()
|
|
55
|
+
self.interval = self.TIER_INTERVALS.get(self.tier, 12.05)
|
|
56
|
+
logger.info("Governor tier updated", tier=self.tier, interval=self.interval)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from friday_core.logging import get_logger
|
|
6
|
+
|
|
7
|
+
from ..registry import AssetRegistry
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CryptoModule:
|
|
13
|
+
"""
|
|
14
|
+
Handles Cryptocurrency pairs (e.g., BTC/USD, ETH/USD).
|
|
15
|
+
|
|
16
|
+
Crypto markets are 24/7 so there is no Market Day logic; the most
|
|
17
|
+
recent previous-close aggregate is used to stay free-tier compatible.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client):
|
|
21
|
+
"""
|
|
22
|
+
Initialise the crypto module.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
client: A ``MassiveAgentClient`` instance used for SDK calls.
|
|
26
|
+
"""
|
|
27
|
+
self.client = client
|
|
28
|
+
|
|
29
|
+
def _format_ticker(self, symbol: str) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Standardise a symbol to the Massive ``X:BTCUSD`` format.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
symbol: Raw symbol string (e.g. ``"BTC"``, ``"BTC/USD"``,
|
|
35
|
+
``"X:BTCUSD"``).
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The normalised ticker string.
|
|
39
|
+
"""
|
|
40
|
+
symbol = symbol.upper().replace("/", "")
|
|
41
|
+
if not symbol.startswith("X:"):
|
|
42
|
+
if "USD" not in symbol:
|
|
43
|
+
symbol += "USD"
|
|
44
|
+
return f"X:{symbol}"
|
|
45
|
+
return symbol
|
|
46
|
+
|
|
47
|
+
async def get_snapshot(self, symbol: str) -> dict[str, Any]:
|
|
48
|
+
"""
|
|
49
|
+
Get the most recent daily close (Previous Close) for a crypto pair.
|
|
50
|
+
|
|
51
|
+
Uses ``get_previous_close_agg`` to ensure compatibility with the
|
|
52
|
+
free tier.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
symbol: Crypto symbol (e.g. ``"BTC"``, ``"ETH"``).
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
A dict with ``symbol``, ``price``, ``day_change``,
|
|
59
|
+
``day_change_percent``, and ``last_updated``.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
MassiveNotFoundError: When no data is returned for the symbol.
|
|
63
|
+
"""
|
|
64
|
+
ticker = self._format_ticker(symbol)
|
|
65
|
+
logger.info("Fetching crypto snapshot", ticker=ticker)
|
|
66
|
+
|
|
67
|
+
func = self.client.sdk.get_previous_close_agg
|
|
68
|
+
result = await self.client.execute_query(func, ticker)
|
|
69
|
+
|
|
70
|
+
if not result or len(result) == 0:
|
|
71
|
+
logger.warning("No crypto data returned", ticker=ticker)
|
|
72
|
+
return {"symbol": ticker, "error": "No data found."}
|
|
73
|
+
|
|
74
|
+
last_bar = result[0]
|
|
75
|
+
|
|
76
|
+
change_percent = 0.0
|
|
77
|
+
if last_bar.open and last_bar.open != 0:
|
|
78
|
+
change_percent = ((last_bar.close - last_bar.open) / last_bar.open) * 100
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
"symbol": ticker,
|
|
82
|
+
"price": last_bar.close,
|
|
83
|
+
"day_change": last_bar.close - last_bar.open,
|
|
84
|
+
"day_change_percent": round(change_percent, 2),
|
|
85
|
+
"last_updated": (
|
|
86
|
+
datetime.fromtimestamp(last_bar.timestamp / 1000).isoformat()
|
|
87
|
+
if hasattr(last_bar, "timestamp")
|
|
88
|
+
else datetime.now().isoformat()
|
|
89
|
+
),
|
|
90
|
+
"note": "Data is Previous Close (Free Tier compatible)",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async def get_risk_on_comparison(self) -> dict[str, Any]:
|
|
94
|
+
"""
|
|
95
|
+
Compare Bitcoin vs Bonds for a Risk-On / Risk-Off signal.
|
|
96
|
+
|
|
97
|
+
Fetches the latest BTC snapshot and the latest bond (AGG) EOD
|
|
98
|
+
price so the agent can assess relative momentum.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
A dict with ``risk_on_proxy`` (BTC data) and
|
|
102
|
+
``risk_off_proxy`` (AGG bond data).
|
|
103
|
+
"""
|
|
104
|
+
btc = await self.get_snapshot("BTC")
|
|
105
|
+
|
|
106
|
+
bond_ticker = AssetRegistry.resolve_ticker("total_bond") # AGG
|
|
107
|
+
logger.info("Fetching bond data for risk comparison", ticker=bond_ticker)
|
|
108
|
+
|
|
109
|
+
# Fetch actual bond price data for a meaningful comparison
|
|
110
|
+
bond_result = await self.client.equities.get_eod_price(bond_ticker)
|
|
111
|
+
|
|
112
|
+
bond_proxy: dict[str, Any] = {
|
|
113
|
+
"asset": "AGG (Bonds)",
|
|
114
|
+
"ticker": bond_ticker,
|
|
115
|
+
}
|
|
116
|
+
if "error" not in bond_result:
|
|
117
|
+
bond_proxy.update(
|
|
118
|
+
{
|
|
119
|
+
"price": bond_result["close"],
|
|
120
|
+
"date": bond_result["date"],
|
|
121
|
+
}
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Bond data unavailable for risk comparison", error=bond_result["error"]
|
|
126
|
+
)
|
|
127
|
+
bond_proxy["error"] = bond_result["error"]
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"risk_on_proxy": {
|
|
131
|
+
"asset": "Bitcoin",
|
|
132
|
+
"price": btc.get("price"),
|
|
133
|
+
"change": btc.get("day_change_percent"),
|
|
134
|
+
},
|
|
135
|
+
"risk_off_proxy": bond_proxy,
|
|
136
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
from datetime import datetime, timedelta
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from friday_core.logging import get_logger
|
|
6
|
+
|
|
7
|
+
from ..registry import AssetRegistry
|
|
8
|
+
|
|
9
|
+
logger = get_logger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EquitiesModule:
|
|
13
|
+
"""
|
|
14
|
+
Handles Stock and Index-proxy data fetching.
|
|
15
|
+
|
|
16
|
+
Includes 'Market Day' logic so free-tier queries always target a
|
|
17
|
+
valid trading day with available data.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, client):
|
|
21
|
+
"""
|
|
22
|
+
Initialise the equities module.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
client: A ``MassiveAgentClient`` instance used for SDK calls.
|
|
26
|
+
"""
|
|
27
|
+
self.client = client
|
|
28
|
+
|
|
29
|
+
def _resolve_market_day(self, target_date: datetime = None) -> str:
|
|
30
|
+
"""
|
|
31
|
+
Ensure we are querying a valid trading day.
|
|
32
|
+
|
|
33
|
+
If *target_date* falls on a weekend it is rolled back to the
|
|
34
|
+
previous Friday. Defaults to T-1 (yesterday) to guarantee data
|
|
35
|
+
availability on the free tier.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
target_date: The desired date. Defaults to yesterday.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
An ISO-formatted date string (``YYYY-MM-DD``).
|
|
42
|
+
"""
|
|
43
|
+
if target_date is None:
|
|
44
|
+
# Default to yesterday to avoid "restricted timeframe" errors on Free Tier
|
|
45
|
+
target_date = datetime.now() - timedelta(days=1)
|
|
46
|
+
|
|
47
|
+
# Simplistic business day roll-back
|
|
48
|
+
while target_date.weekday() > 4: # Saturday or Sunday
|
|
49
|
+
target_date -= timedelta(days=1)
|
|
50
|
+
|
|
51
|
+
return target_date.strftime("%Y-%m-%d")
|
|
52
|
+
|
|
53
|
+
async def get_details(self, ticker: str) -> dict[str, Any]:
|
|
54
|
+
"""
|
|
55
|
+
Fetch company / ETF background information.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
ticker: Symbol or alias (e.g. ``"AAPL"``, ``"sp500"``).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
A dict with keys ``ticker``, ``name``, ``description``,
|
|
62
|
+
``homepage``, and ``market_cap``.
|
|
63
|
+
"""
|
|
64
|
+
ticker = AssetRegistry.resolve_ticker(ticker)
|
|
65
|
+
logger.info("Fetching ticker details", ticker=ticker)
|
|
66
|
+
|
|
67
|
+
func = self.client.sdk.get_ticker_details
|
|
68
|
+
result = await self.client.execute_query(func, ticker)
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
"ticker": result.ticker,
|
|
72
|
+
"name": result.name,
|
|
73
|
+
"description": getattr(result, "description", "No description available."),
|
|
74
|
+
"homepage": getattr(result, "homepage_url", ""),
|
|
75
|
+
"market_cap": getattr(result, "market_cap", None),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async def get_eod_price(self, ticker: str) -> dict[str, Any]:
|
|
79
|
+
"""
|
|
80
|
+
Fetch the most recent daily close (End-of-Day) OHLCV bar.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
ticker: Symbol or alias.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
A dict with ``ticker``, ``date``, ``open``, ``high``,
|
|
87
|
+
``low``, ``close``, and ``volume``.
|
|
88
|
+
"""
|
|
89
|
+
ticker = AssetRegistry.resolve_ticker(ticker)
|
|
90
|
+
market_day = self._resolve_market_day()
|
|
91
|
+
logger.info("Fetching EOD price", ticker=ticker, market_day=market_day)
|
|
92
|
+
|
|
93
|
+
func = self.client.sdk.get_aggs
|
|
94
|
+
|
|
95
|
+
result = await self.client.execute_query(
|
|
96
|
+
func, ticker, 1, "day", market_day, market_day
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
if not result or len(result) == 0:
|
|
100
|
+
return {"error": "No data found for the resolved market day."}
|
|
101
|
+
|
|
102
|
+
last_bar = result[0]
|
|
103
|
+
return {
|
|
104
|
+
"ticker": ticker,
|
|
105
|
+
"date": market_day,
|
|
106
|
+
"open": last_bar.open,
|
|
107
|
+
"high": last_bar.high,
|
|
108
|
+
"low": last_bar.low,
|
|
109
|
+
"close": last_bar.close,
|
|
110
|
+
"volume": last_bar.volume,
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async def get_market_summary(self, ticker: str) -> dict[str, Any]:
|
|
114
|
+
"""
|
|
115
|
+
High-level tool call for agents.
|
|
116
|
+
|
|
117
|
+
Combines identity details and price data into a single summary.
|
|
118
|
+
On the free tier the Governor will space the two underlying calls.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
ticker: Symbol or alias.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
A dict with ``summary`` (human-readable string), ``details``,
|
|
125
|
+
and ``price_data``.
|
|
126
|
+
"""
|
|
127
|
+
details = await self.get_details(ticker)
|
|
128
|
+
price = await self.get_eod_price(ticker)
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"summary": f"{details['name']} is trading at ${price['close']} as of {price['date']}.", # noqa: E501
|
|
132
|
+
"details": details,
|
|
133
|
+
"price_data": price,
|
|
134
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from friday_core.logging import get_logger
|
|
5
|
+
|
|
6
|
+
from ..registry import AssetRegistry
|
|
7
|
+
|
|
8
|
+
logger = get_logger(__name__)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IndicatorsModule:
|
|
12
|
+
"""
|
|
13
|
+
Interface to Massive's Technical Indicators API.
|
|
14
|
+
|
|
15
|
+
Supports Simple Moving Average (SMA), Relative Strength Index (RSI),
|
|
16
|
+
and trend analysis via Golden / Death Cross detection.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, client):
|
|
20
|
+
"""
|
|
21
|
+
Initialise the indicators module.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
client: A ``MassiveAgentClient`` instance used for SDK calls.
|
|
25
|
+
"""
|
|
26
|
+
self.client = client
|
|
27
|
+
|
|
28
|
+
async def get_sma(
|
|
29
|
+
self, ticker: str, window: int = 50, timespan: str = "day"
|
|
30
|
+
) -> dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Fetch the Simple Moving Average for a ticker.
|
|
33
|
+
|
|
34
|
+
Common windows: 50 (intermediate trend), 200 (long-term trend).
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
ticker: Symbol or alias.
|
|
38
|
+
window: Number of periods for the moving average.
|
|
39
|
+
timespan: Bar size — ``"day"``, ``"week"``, etc.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
A dict with ``ticker``, ``window``, ``value``, ``timestamp``,
|
|
43
|
+
and ``indicator``.
|
|
44
|
+
"""
|
|
45
|
+
ticker = AssetRegistry.resolve_ticker(ticker)
|
|
46
|
+
logger.info("Fetching SMA", ticker=ticker, window=window, timespan=timespan)
|
|
47
|
+
|
|
48
|
+
func = self.client.sdk.get_sma
|
|
49
|
+
result = await self.client.execute_query(
|
|
50
|
+
func,
|
|
51
|
+
ticker,
|
|
52
|
+
timestamp=None, # Gets most recent
|
|
53
|
+
timespan=timespan,
|
|
54
|
+
window=window,
|
|
55
|
+
series_type="close",
|
|
56
|
+
order="desc",
|
|
57
|
+
limit=1,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if not result or not hasattr(result, "values") or len(result.values) == 0:
|
|
61
|
+
logger.warning("No SMA data returned", ticker=ticker, window=window)
|
|
62
|
+
return {
|
|
63
|
+
"ticker": ticker,
|
|
64
|
+
"window": window,
|
|
65
|
+
"value": None,
|
|
66
|
+
"status": "no_data",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
latest_value = result.values[0].value
|
|
70
|
+
return {
|
|
71
|
+
"ticker": ticker,
|
|
72
|
+
"window": window,
|
|
73
|
+
"value": round(latest_value, 2),
|
|
74
|
+
"timestamp": result.values[0].timestamp,
|
|
75
|
+
"indicator": "SMA",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async def get_rsi(self, ticker: str, window: int = 14) -> dict[str, Any]:
|
|
79
|
+
"""
|
|
80
|
+
Fetch the Relative Strength Index (momentum indicator).
|
|
81
|
+
|
|
82
|
+
The standard RSI window is 14 periods.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
ticker: Symbol or alias.
|
|
86
|
+
window: Number of periods (default 14).
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
A dict with ``ticker``, ``window``, ``value``, and
|
|
90
|
+
``indicator``.
|
|
91
|
+
"""
|
|
92
|
+
ticker = AssetRegistry.resolve_ticker(ticker)
|
|
93
|
+
logger.info("Fetching RSI", ticker=ticker, window=window)
|
|
94
|
+
|
|
95
|
+
func = self.client.sdk.get_rsi
|
|
96
|
+
result = await self.client.execute_query(
|
|
97
|
+
func,
|
|
98
|
+
ticker,
|
|
99
|
+
timespan="day",
|
|
100
|
+
window=window,
|
|
101
|
+
series_type="close",
|
|
102
|
+
order="desc",
|
|
103
|
+
limit=1,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
latest_value = (
|
|
107
|
+
result.values[0].value if result and hasattr(result, "values") else None
|
|
108
|
+
)
|
|
109
|
+
return {
|
|
110
|
+
"ticker": ticker,
|
|
111
|
+
"window": window,
|
|
112
|
+
"value": round(latest_value, 2) if latest_value else None,
|
|
113
|
+
"indicator": "RSI",
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async def get_trend_analysis(self, ticker: str) -> dict[str, Any]:
|
|
117
|
+
"""
|
|
118
|
+
Determine Golden Cross / Death Cross status.
|
|
119
|
+
|
|
120
|
+
Compares SMA-50 against SMA-200. Requires two API calls (spaced
|
|
121
|
+
by ~12 s on the free tier).
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
ticker: Symbol or alias.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
A dict with ``ticker``, ``sma50``, ``sma200``, and
|
|
128
|
+
``sentiment`` (``"bullish"``, ``"bearish"``, or ``"neutral"``).
|
|
129
|
+
"""
|
|
130
|
+
logger.info("Running trend analysis", ticker=ticker)
|
|
131
|
+
sma50 = await self.get_sma(ticker, 50)
|
|
132
|
+
sma200 = await self.get_sma(ticker, 200)
|
|
133
|
+
|
|
134
|
+
status = "neutral"
|
|
135
|
+
if sma50["value"] and sma200["value"]:
|
|
136
|
+
status = "bullish" if sma50["value"] > sma200["value"] else "bearish"
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
"ticker": ticker,
|
|
140
|
+
"sma50": sma50["value"],
|
|
141
|
+
"sma200": sma200["value"],
|
|
142
|
+
"sentiment": status,
|
|
143
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
class AssetRegistry:
|
|
3
|
+
"""
|
|
4
|
+
Manages the translation of human-readable concepts into Massive-compatible tickers.
|
|
5
|
+
Specifically designed to provide ETF proxies for Indices and Bonds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Mapping of categories to their most liquid ETF proxy
|
|
9
|
+
PROXIES = {
|
|
10
|
+
# Indices
|
|
11
|
+
"sp500": {"ticker": "SPY", "name": "S&P 500 Index (via SPY)"},
|
|
12
|
+
"nasdaq100": {"ticker": "QQQ", "name": "Nasdaq-100 (via QQQ)"},
|
|
13
|
+
"dowjones": {"ticker": "DIA", "name": "Dow Jones Industrial Average (via DIA)"},
|
|
14
|
+
"russell2000": {"ticker": "IWM", "name": "Russell 2000 (via IWM)"},
|
|
15
|
+
# Bonds / Fixed Income
|
|
16
|
+
"total_bond": {"ticker": "AGG", "name": "U.S. Aggregate Bond Index"},
|
|
17
|
+
"treasury_long": {"ticker": "TLT", "name": "20+ Year Treasury Bond"},
|
|
18
|
+
"treasury_mid": {"ticker": "IEF", "name": "7-10 Year Treasury Bond"},
|
|
19
|
+
"treasury_short": {"ticker": "SHY", "name": "1-3 Year Treasury Bond"},
|
|
20
|
+
"corp_bond": {"ticker": "LQD", "name": "Investment Grade Corporate Bond"},
|
|
21
|
+
"high_yield": {"ticker": "HYG", "name": "High Yield Corporate Bond"},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def resolve_ticker(cls, query: str) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Translates a common name or alias into a ticker.
|
|
28
|
+
If it's already a ticker (4 characters or fewer), it returns it as-is.
|
|
29
|
+
"""
|
|
30
|
+
query_clean = query.lower().replace(" ", "_")
|
|
31
|
+
if query_clean in cls.PROXIES:
|
|
32
|
+
return cls.PROXIES[query_clean]["ticker"]
|
|
33
|
+
|
|
34
|
+
# Fallback: Assume it's already a ticker
|
|
35
|
+
return query.upper()
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def get_metadata(cls, ticker: str) -> dict | None:
|
|
39
|
+
"""Returns internal registry metadata for a given ticker proxy."""
|
|
40
|
+
for _key, data in cls.PROXIES.items():
|
|
41
|
+
if data["ticker"] == ticker.upper():
|
|
42
|
+
return data
|
|
43
|
+
return None
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from friday_finance.providers.massive.client import MassiveAgentClient
|
|
6
|
+
from friday_finance.providers.massive.exceptions import MassiveAuthError
|
|
7
|
+
from friday_finance.providers.massive.governor import MassiveGovernor
|
|
8
|
+
from friday_finance.providers.massive.registry import AssetRegistry
|
|
9
|
+
|
|
10
|
+
# ---------------------------------------------------------------------------
|
|
11
|
+
# Registry tests
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_registry_resolution():
|
|
16
|
+
"""Verify alias-to-ticker translation and passthrough."""
|
|
17
|
+
assert AssetRegistry.resolve_ticker("sp500") == "SPY"
|
|
18
|
+
assert AssetRegistry.resolve_ticker("AAPL") == "AAPL"
|
|
19
|
+
assert AssetRegistry.resolve_ticker("total bond") == "AGG"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_registry_metadata():
|
|
23
|
+
"""Verify metadata lookup for a resolved ticker."""
|
|
24
|
+
meta = AssetRegistry.get_metadata("SPY")
|
|
25
|
+
assert meta is not None
|
|
26
|
+
assert meta["ticker"] == "SPY"
|
|
27
|
+
assert "S&P 500" in meta["name"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_registry_metadata_unknown():
|
|
31
|
+
"""Verify metadata returns None for unknown tickers."""
|
|
32
|
+
assert AssetRegistry.get_metadata("ZZZZZ") is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Governor tests
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_governor_free_tier():
|
|
42
|
+
"""Free tier governor should enforce >= 12 s spacing between calls."""
|
|
43
|
+
gov = MassiveGovernor(tier="free")
|
|
44
|
+
assert gov.interval > 12
|
|
45
|
+
|
|
46
|
+
start = time.time()
|
|
47
|
+
await gov.wait() # First call is instant
|
|
48
|
+
await gov.wait() # Second call should wait
|
|
49
|
+
end = time.time()
|
|
50
|
+
|
|
51
|
+
assert (end - start) >= 12
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.asyncio
|
|
55
|
+
async def test_governor_starter_tier():
|
|
56
|
+
"""Starter tier governor should impose near-zero wait."""
|
|
57
|
+
gov = MassiveGovernor(tier="starter")
|
|
58
|
+
assert gov.interval < 1
|
|
59
|
+
|
|
60
|
+
start = time.time()
|
|
61
|
+
await gov.wait()
|
|
62
|
+
await gov.wait()
|
|
63
|
+
end = time.time()
|
|
64
|
+
|
|
65
|
+
assert (end - start) < 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.asyncio
|
|
69
|
+
async def test_governor_update_tier():
|
|
70
|
+
"""Updating tier should change the interval immediately."""
|
|
71
|
+
gov = MassiveGovernor(tier="free")
|
|
72
|
+
assert gov.interval > 12
|
|
73
|
+
|
|
74
|
+
gov.update_tier("starter")
|
|
75
|
+
assert gov.interval < 1
|
|
76
|
+
assert gov.tier == "starter"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Client tests
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_client_raises_on_missing_api_key(monkeypatch):
|
|
85
|
+
"""Client must raise MassiveAuthError when no API key is available."""
|
|
86
|
+
monkeypatch.delenv("MASSIVE_API_KEY", raising=False)
|
|
87
|
+
with pytest.raises(MassiveAuthError):
|
|
88
|
+
MassiveAgentClient(api_key=None)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def test_client_accepts_explicit_api_key(monkeypatch):
|
|
92
|
+
"""Client should initialise successfully with an explicit key."""
|
|
93
|
+
monkeypatch.delenv("MASSIVE_API_KEY", raising=False)
|
|
94
|
+
client = MassiveAgentClient(api_key="test-key-123", tier="starter")
|
|
95
|
+
assert client.api_key == "test-key-123"
|
|
96
|
+
assert client.tier == "starter"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_client_reads_env_api_key(monkeypatch):
|
|
100
|
+
"""Client should fall back to MASSIVE_API_KEY env var."""
|
|
101
|
+
monkeypatch.setenv("MASSIVE_API_KEY", "env-key-456")
|
|
102
|
+
client = MassiveAgentClient()
|
|
103
|
+
assert client.api_key == "env-key-456"
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# mypy: ignore-errors
|
|
2
|
+
"""
|
|
3
|
+
Integration tests for the Massive API provider.
|
|
4
|
+
|
|
5
|
+
These tests use a real API key and hit the live Massive API.
|
|
6
|
+
They are rate-limited by the MassiveGovernor (~12 s between calls
|
|
7
|
+
on the free tier), so individual tests may take a while.
|
|
8
|
+
|
|
9
|
+
Run with:
|
|
10
|
+
pytest packages/friday-finance/tests/test_integration_massive.py -v -s
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
from friday_finance import MassiveAgentClient
|
|
15
|
+
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
# Fixtures
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
API_KEY = "pQBVbd4spIQEVEfc5WRjAonQXwBl_grA"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture(scope="module")
|
|
24
|
+
def client():
|
|
25
|
+
"""
|
|
26
|
+
Create a module-scoped MassiveAgentClient.
|
|
27
|
+
|
|
28
|
+
Sharing one client across all tests ensures the Governor
|
|
29
|
+
rate-limiter is honoured correctly.
|
|
30
|
+
"""
|
|
31
|
+
return MassiveAgentClient(api_key=API_KEY, tier="free")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Equities
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@pytest.mark.asyncio
|
|
40
|
+
async def test_get_eod_price(client):
|
|
41
|
+
"""Fetch EOD price for AAPL — should return a dict with close price."""
|
|
42
|
+
result = await client.equities.get_eod_price("AAPL")
|
|
43
|
+
assert isinstance(result, dict)
|
|
44
|
+
assert result["ticker"] == "AAPL"
|
|
45
|
+
assert "close" in result
|
|
46
|
+
assert isinstance(result["close"], (int, float))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.asyncio
|
|
50
|
+
async def test_get_details(client):
|
|
51
|
+
"""Fetch company details for AAPL — should include name and market_cap."""
|
|
52
|
+
result = await client.equities.get_details("AAPL")
|
|
53
|
+
assert isinstance(result, dict)
|
|
54
|
+
assert result["ticker"] == "AAPL"
|
|
55
|
+
assert "name" in result
|
|
56
|
+
assert "market_cap" in result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_get_market_summary(client):
|
|
61
|
+
"""Market summary combines price + details for a ticker."""
|
|
62
|
+
result = await client.equities.get_market_summary("AAPL")
|
|
63
|
+
assert isinstance(result, dict)
|
|
64
|
+
assert "summary" in result
|
|
65
|
+
assert "details" in result
|
|
66
|
+
assert "price_data" in result
|
|
67
|
+
assert result["details"]["ticker"] == "AAPL"
|
|
68
|
+
assert "close" in result["price_data"]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_alias_resolution(client):
|
|
73
|
+
"""Alias 'sp500' should resolve to SPY and return valid data."""
|
|
74
|
+
result = await client.equities.get_eod_price("sp500")
|
|
75
|
+
assert isinstance(result, dict)
|
|
76
|
+
assert result["ticker"] == "SPY"
|
|
77
|
+
assert "close" in result
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# Crypto
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_get_crypto_snapshot(client):
|
|
87
|
+
"""Fetch a BTC snapshot — should include price and day_change fields."""
|
|
88
|
+
result = await client.crypto.get_snapshot("BTC")
|
|
89
|
+
assert isinstance(result, dict)
|
|
90
|
+
assert "price" in result
|
|
91
|
+
assert "day_change" in result
|
|
92
|
+
assert "day_change_percent" in result
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Technical Indicators
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@pytest.mark.asyncio
|
|
101
|
+
async def test_get_sma(client):
|
|
102
|
+
"""Fetch SMA-50 for SPY — should return a numeric value."""
|
|
103
|
+
result = await client.indicators.get_sma("SPY", window=50)
|
|
104
|
+
assert isinstance(result, dict)
|
|
105
|
+
assert result["indicator"] == "SMA"
|
|
106
|
+
assert result["window"] == 50
|
|
107
|
+
assert result["value"] is not None
|
|
108
|
+
assert isinstance(result["value"], (int, float))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_get_rsi(client):
|
|
113
|
+
"""Fetch RSI-14 for AAPL — value should be between 0 and 100."""
|
|
114
|
+
result = await client.indicators.get_rsi("AAPL", window=14)
|
|
115
|
+
assert isinstance(result, dict)
|
|
116
|
+
assert result["indicator"] == "RSI"
|
|
117
|
+
assert result["value"] is not None
|
|
118
|
+
assert 0 <= result["value"] <= 100
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
# Tool wrappers (via friday_tools.finance)
|
|
123
|
+
# ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.asyncio
|
|
127
|
+
async def test_market_data_tool(client):
|
|
128
|
+
"""GetMarketDataTool should return a string result via execute()."""
|
|
129
|
+
from friday_tools.finance import GetMarketDataTool
|
|
130
|
+
|
|
131
|
+
tool = GetMarketDataTool(client=client)
|
|
132
|
+
result = await tool.execute(ticker="AAPL", data_type="price")
|
|
133
|
+
assert isinstance(result, str)
|
|
134
|
+
assert "AAPL" in result
|
|
135
|
+
assert "close" in result
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.asyncio
|
|
139
|
+
async def test_crypto_data_tool(client):
|
|
140
|
+
"""GetCryptoDataTool should return a string result via execute()."""
|
|
141
|
+
from friday_tools.finance import GetCryptoDataTool
|
|
142
|
+
|
|
143
|
+
tool = GetCryptoDataTool(client=client)
|
|
144
|
+
result = await tool.execute(symbol="BTC")
|
|
145
|
+
assert isinstance(result, str)
|
|
146
|
+
assert "price" in result
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_technical_analysis_tool(client):
|
|
151
|
+
"""GetTechnicalAnalysisTool should return SMA data as a string."""
|
|
152
|
+
from friday_tools.finance import GetTechnicalAnalysisTool
|
|
153
|
+
|
|
154
|
+
tool = GetTechnicalAnalysisTool(client=client)
|
|
155
|
+
result = await tool.execute(ticker="SPY", indicator="sma", window=50)
|
|
156
|
+
assert isinstance(result, str)
|
|
157
|
+
assert "SMA" in result
|