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.
@@ -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,2 @@
1
+ # mypy: ignore-errors
2
+ # Friday Finance Providers
@@ -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,6 @@
1
+ # mypy: ignore-errors
2
+ from .crypto import CryptoModule
3
+ from .equities import EquitiesModule
4
+ from .indicators import IndicatorsModule
5
+
6
+ __all__ = ["EquitiesModule", "CryptoModule", "IndicatorsModule"]
@@ -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