avanza-mcp 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- avanza_mcp/__init__.py +29 -0
- avanza_mcp/client/__init__.py +25 -0
- avanza_mcp/client/base.py +375 -0
- avanza_mcp/client/endpoints.py +41 -0
- avanza_mcp/client/exceptions.py +66 -0
- avanza_mcp/models/__init__.py +41 -0
- avanza_mcp/models/common.py +50 -0
- avanza_mcp/models/fund.py +246 -0
- avanza_mcp/models/search.py +123 -0
- avanza_mcp/models/stock.py +268 -0
- avanza_mcp/prompts/__init__.py +6 -0
- avanza_mcp/prompts/analysis.py +116 -0
- avanza_mcp/resources/__init__.py +6 -0
- avanza_mcp/resources/instruments.py +127 -0
- avanza_mcp/services/__init__.py +6 -0
- avanza_mcp/services/market_data_service.py +298 -0
- avanza_mcp/services/search_service.py +64 -0
- avanza_mcp/tools/__init__.py +8 -0
- avanza_mcp/tools/funds.py +267 -0
- avanza_mcp/tools/market_data.py +508 -0
- avanza_mcp/tools/search.py +119 -0
- avanza_mcp-1.0.0.dist-info/METADATA +99 -0
- avanza_mcp-1.0.0.dist-info/RECORD +26 -0
- avanza_mcp-1.0.0.dist-info/WHEEL +4 -0
- avanza_mcp-1.0.0.dist-info/entry_points.txt +2 -0
- avanza_mcp-1.0.0.dist-info/licenses/LICENSE.md +21 -0
avanza_mcp/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Avanza MCP Server - Public API Access via FastMCP.
|
|
2
|
+
|
|
3
|
+
This server provides read-only access to Avanza's public market data API.
|
|
4
|
+
No authentication required - all endpoints are publicly accessible.
|
|
5
|
+
|
|
6
|
+
Provides access to:
|
|
7
|
+
- Stock information, quotes, and charts
|
|
8
|
+
- Fund information, sustainability metrics, and performance
|
|
9
|
+
- Market data including order depth, trades, and broker activity
|
|
10
|
+
- Real-time market status and trading hours
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "1.0.0"
|
|
14
|
+
|
|
15
|
+
from fastmcp import FastMCP
|
|
16
|
+
|
|
17
|
+
# Create FastMCP instance
|
|
18
|
+
mcp = FastMCP("Avanza MCP Server")
|
|
19
|
+
|
|
20
|
+
# Import modules to register tools/resources/prompts via decorators
|
|
21
|
+
# The @mcp.tool/@mcp.resource/@mcp.prompt decorators handle registration
|
|
22
|
+
from . import prompts # noqa: F401, E402
|
|
23
|
+
from . import resources # noqa: F401, E402
|
|
24
|
+
from . import tools # noqa: F401, E402
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> None:
|
|
28
|
+
"""Entry point for the MCP server."""
|
|
29
|
+
mcp.run()
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Avanza API client module."""
|
|
2
|
+
|
|
3
|
+
from .base import AvanzaClient
|
|
4
|
+
from .endpoints import PublicEndpoint
|
|
5
|
+
from .exceptions import (
|
|
6
|
+
AvanzaAPIError,
|
|
7
|
+
AvanzaAuthError,
|
|
8
|
+
AvanzaError,
|
|
9
|
+
AvanzaNetworkError,
|
|
10
|
+
AvanzaNotFoundError,
|
|
11
|
+
AvanzaRateLimitError,
|
|
12
|
+
AvanzaTimeoutError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AvanzaClient",
|
|
17
|
+
"PublicEndpoint",
|
|
18
|
+
"AvanzaError",
|
|
19
|
+
"AvanzaAPIError",
|
|
20
|
+
"AvanzaAuthError",
|
|
21
|
+
"AvanzaNetworkError",
|
|
22
|
+
"AvanzaNotFoundError",
|
|
23
|
+
"AvanzaRateLimitError",
|
|
24
|
+
"AvanzaTimeoutError",
|
|
25
|
+
]
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Base HTTP client for Avanza API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from tenacity import (
|
|
9
|
+
retry,
|
|
10
|
+
retry_if_exception_type,
|
|
11
|
+
stop_after_attempt,
|
|
12
|
+
wait_exponential,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from .. import __version__
|
|
16
|
+
from .exceptions import (
|
|
17
|
+
AvanzaAPIError,
|
|
18
|
+
AvanzaAuthError,
|
|
19
|
+
AvanzaNetworkError,
|
|
20
|
+
AvanzaNotFoundError,
|
|
21
|
+
AvanzaRateLimitError,
|
|
22
|
+
AvanzaRetryableError,
|
|
23
|
+
AvanzaTimeoutError,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AvanzaClient:
|
|
30
|
+
"""Async HTTP client for Avanza public API."""
|
|
31
|
+
|
|
32
|
+
# Default configuration
|
|
33
|
+
DEFAULT_BASE_URL = "https://www.avanza.se"
|
|
34
|
+
DEFAULT_TIMEOUT = 30.0
|
|
35
|
+
DEFAULT_CONNECT_TIMEOUT = 5.0
|
|
36
|
+
DEFAULT_MAX_CONNECTIONS = 10
|
|
37
|
+
DEFAULT_MAX_KEEPALIVE = 5
|
|
38
|
+
DEFAULT_MAX_RETRIES = 3
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
43
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
44
|
+
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
|
|
45
|
+
max_connections: int = DEFAULT_MAX_CONNECTIONS,
|
|
46
|
+
max_keepalive_connections: int = DEFAULT_MAX_KEEPALIVE,
|
|
47
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Initialize Avanza client.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
base_url: Base URL for Avanza API
|
|
53
|
+
timeout: Read timeout in seconds
|
|
54
|
+
connect_timeout: Connection timeout in seconds
|
|
55
|
+
max_connections: Maximum number of concurrent connections
|
|
56
|
+
max_keepalive_connections: Maximum number of keepalive connections
|
|
57
|
+
max_retries: Maximum number of retry attempts for transient failures
|
|
58
|
+
"""
|
|
59
|
+
self._base_url = base_url
|
|
60
|
+
self._timeout = timeout
|
|
61
|
+
self._connect_timeout = connect_timeout
|
|
62
|
+
self._max_connections = max_connections
|
|
63
|
+
self._max_keepalive_connections = max_keepalive_connections
|
|
64
|
+
self._max_retries = max_retries
|
|
65
|
+
self._client: httpx.AsyncClient | None = None
|
|
66
|
+
|
|
67
|
+
async def __aenter__(self) -> "AvanzaClient":
|
|
68
|
+
"""Initialize httpx client with connection pooling.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Self for context manager usage
|
|
72
|
+
"""
|
|
73
|
+
headers = self._build_headers()
|
|
74
|
+
|
|
75
|
+
# Configure timeouts with separate connect and read values
|
|
76
|
+
timeout = httpx.Timeout(
|
|
77
|
+
self._timeout,
|
|
78
|
+
connect=self._connect_timeout,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Configure connection pooling limits
|
|
82
|
+
limits = httpx.Limits(
|
|
83
|
+
max_connections=self._max_connections,
|
|
84
|
+
max_keepalive_connections=self._max_keepalive_connections,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
self._client = httpx.AsyncClient(
|
|
88
|
+
base_url=self._base_url,
|
|
89
|
+
headers=headers,
|
|
90
|
+
timeout=timeout,
|
|
91
|
+
limits=limits,
|
|
92
|
+
follow_redirects=True,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
async def __aexit__(
|
|
98
|
+
self,
|
|
99
|
+
exc_type: type[BaseException] | None,
|
|
100
|
+
exc_val: BaseException | None,
|
|
101
|
+
exc_tb: Any,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Clean up httpx client.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
exc_type: Exception type if an error occurred
|
|
107
|
+
exc_val: Exception value if an error occurred
|
|
108
|
+
exc_tb: Exception traceback if an error occurred
|
|
109
|
+
"""
|
|
110
|
+
if self._client:
|
|
111
|
+
await self._client.aclose()
|
|
112
|
+
|
|
113
|
+
def _build_headers(self) -> dict[str, str]:
|
|
114
|
+
"""Build request headers.
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Dictionary of HTTP headers
|
|
118
|
+
"""
|
|
119
|
+
return {
|
|
120
|
+
"User-Agent": f"avanza-mcp/{__version__}",
|
|
121
|
+
"Accept": "application/json",
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
def _generate_request_id(self) -> str:
|
|
125
|
+
"""Generate a unique request ID for debugging.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Short unique identifier string
|
|
129
|
+
"""
|
|
130
|
+
return str(uuid.uuid4())[:8]
|
|
131
|
+
|
|
132
|
+
def _handle_error(
|
|
133
|
+
self,
|
|
134
|
+
response: httpx.Response,
|
|
135
|
+
path: str,
|
|
136
|
+
request_id: str,
|
|
137
|
+
params: dict[str, Any] | None = None,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Handle HTTP error responses with enhanced context.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
response: HTTP response object
|
|
143
|
+
path: Request path for error context
|
|
144
|
+
request_id: Request ID for debugging
|
|
145
|
+
params: Query parameters for error context
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
AvanzaNotFoundError: If resource not found (404)
|
|
149
|
+
AvanzaAuthError: If authentication failed (401, 403)
|
|
150
|
+
AvanzaRateLimitError: If rate limit exceeded (429)
|
|
151
|
+
AvanzaAPIError: For other API errors
|
|
152
|
+
"""
|
|
153
|
+
status_code = response.status_code
|
|
154
|
+
|
|
155
|
+
# Try to extract error message from response
|
|
156
|
+
try:
|
|
157
|
+
error_data = response.json()
|
|
158
|
+
message = error_data.get("message", response.text)
|
|
159
|
+
except Exception:
|
|
160
|
+
message = response.text or f"HTTP {status_code}"
|
|
161
|
+
|
|
162
|
+
# Add request context to error message
|
|
163
|
+
context = f"[{request_id}] {path}"
|
|
164
|
+
if params:
|
|
165
|
+
context += f" params={params}"
|
|
166
|
+
|
|
167
|
+
logger.warning(
|
|
168
|
+
"API error: status=%d path=%s request_id=%s message=%s",
|
|
169
|
+
status_code,
|
|
170
|
+
path,
|
|
171
|
+
request_id,
|
|
172
|
+
message[:200], # Truncate long messages
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
# Handle specific error types
|
|
176
|
+
if status_code == 404:
|
|
177
|
+
raise AvanzaNotFoundError(f"{context}: {message}")
|
|
178
|
+
elif status_code in (401, 403):
|
|
179
|
+
raise AvanzaAuthError(f"{context}: {message}")
|
|
180
|
+
elif status_code == 429:
|
|
181
|
+
retry_after = response.headers.get("Retry-After")
|
|
182
|
+
retry_after_int = int(retry_after) if retry_after else None
|
|
183
|
+
raise AvanzaRateLimitError(retry_after_int, f"{context}: {message}")
|
|
184
|
+
else:
|
|
185
|
+
try:
|
|
186
|
+
response_dict = response.json()
|
|
187
|
+
except Exception:
|
|
188
|
+
response_dict = None
|
|
189
|
+
raise AvanzaAPIError(status_code, f"{context}: {message}", response_dict)
|
|
190
|
+
|
|
191
|
+
def _is_retryable_status(self, status_code: int) -> bool:
|
|
192
|
+
"""Check if HTTP status code is retryable.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
status_code: HTTP status code
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
True if request should be retried
|
|
199
|
+
"""
|
|
200
|
+
# Retry on server errors (5xx) but not client errors (4xx)
|
|
201
|
+
# Exception: 429 (rate limit) is handled separately with backoff
|
|
202
|
+
return status_code >= 500
|
|
203
|
+
|
|
204
|
+
async def get(
|
|
205
|
+
self, path: str, params: dict[str, Any] | None = None
|
|
206
|
+
) -> dict[str, Any]:
|
|
207
|
+
"""GET request with retry logic, error handling, and JSON parsing.
|
|
208
|
+
|
|
209
|
+
Automatically retries on transient failures (network errors, timeouts,
|
|
210
|
+
server errors) with exponential backoff.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
path: API endpoint path
|
|
214
|
+
params: Optional query parameters
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
JSON response as dictionary
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
AvanzaError: If request fails after all retries
|
|
221
|
+
"""
|
|
222
|
+
if not self._client:
|
|
223
|
+
raise RuntimeError("Client not initialized. Use async context manager.")
|
|
224
|
+
|
|
225
|
+
request_id = self._generate_request_id()
|
|
226
|
+
|
|
227
|
+
@retry(
|
|
228
|
+
retry=retry_if_exception_type(
|
|
229
|
+
(httpx.TimeoutException, httpx.NetworkError, AvanzaRetryableError)
|
|
230
|
+
),
|
|
231
|
+
stop=stop_after_attempt(self._max_retries),
|
|
232
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
233
|
+
reraise=True,
|
|
234
|
+
before_sleep=lambda retry_state: logger.info(
|
|
235
|
+
"Retrying request [%s] %s, attempt %d after %s",
|
|
236
|
+
request_id,
|
|
237
|
+
path,
|
|
238
|
+
retry_state.attempt_number,
|
|
239
|
+
type(retry_state.outcome.exception()).__name__
|
|
240
|
+
if retry_state.outcome
|
|
241
|
+
else "unknown",
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
async def _get_with_retry() -> dict[str, Any]:
|
|
245
|
+
try:
|
|
246
|
+
response = await self._client.get(path, params=params) # type: ignore
|
|
247
|
+
except httpx.TimeoutException as e:
|
|
248
|
+
logger.warning(
|
|
249
|
+
"Request timeout [%s] %s: %s", request_id, path, str(e)
|
|
250
|
+
)
|
|
251
|
+
raise AvanzaTimeoutError(
|
|
252
|
+
f"[{request_id}] Request timeout after {self._timeout}s: {path}"
|
|
253
|
+
) from e
|
|
254
|
+
except httpx.NetworkError as e:
|
|
255
|
+
logger.warning(
|
|
256
|
+
"Network error [%s] %s: %s", request_id, path, str(e)
|
|
257
|
+
)
|
|
258
|
+
raise AvanzaNetworkError(
|
|
259
|
+
f"[{request_id}] Network error: {path} - {str(e)}"
|
|
260
|
+
) from e
|
|
261
|
+
|
|
262
|
+
if not response.is_success:
|
|
263
|
+
# Check if this is a retryable server error
|
|
264
|
+
if self._is_retryable_status(response.status_code):
|
|
265
|
+
# Raise specific retryable error to trigger retry
|
|
266
|
+
raise AvanzaRetryableError(
|
|
267
|
+
response.status_code,
|
|
268
|
+
f"[{request_id}] Server error (will retry): {path}",
|
|
269
|
+
)
|
|
270
|
+
# Non-retryable errors
|
|
271
|
+
self._handle_error(response, path, request_id, params)
|
|
272
|
+
|
|
273
|
+
# Handle empty responses
|
|
274
|
+
if not response.content:
|
|
275
|
+
logger.debug("Empty response [%s] %s", request_id, path)
|
|
276
|
+
return {}
|
|
277
|
+
|
|
278
|
+
# Parse JSON response
|
|
279
|
+
try:
|
|
280
|
+
return response.json()
|
|
281
|
+
except Exception as e:
|
|
282
|
+
logger.error(
|
|
283
|
+
"JSON parse error [%s] %s: %s", request_id, path, str(e)
|
|
284
|
+
)
|
|
285
|
+
raise AvanzaAPIError(
|
|
286
|
+
response.status_code,
|
|
287
|
+
f"[{request_id}] Invalid JSON response: {path}",
|
|
288
|
+
) from e
|
|
289
|
+
|
|
290
|
+
logger.debug("GET [%s] %s params=%s", request_id, path, params)
|
|
291
|
+
return await _get_with_retry()
|
|
292
|
+
|
|
293
|
+
async def post(
|
|
294
|
+
self, path: str, json: dict[str, Any] | None = None
|
|
295
|
+
) -> dict[str, Any]:
|
|
296
|
+
"""POST request with retry logic, error handling, and JSON parsing.
|
|
297
|
+
|
|
298
|
+
Automatically retries on transient failures (network errors, timeouts,
|
|
299
|
+
server errors) with exponential backoff.
|
|
300
|
+
|
|
301
|
+
Args:
|
|
302
|
+
path: API endpoint path
|
|
303
|
+
json: Optional JSON body
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
JSON response as dictionary
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
AvanzaError: If request fails after all retries
|
|
310
|
+
"""
|
|
311
|
+
if not self._client:
|
|
312
|
+
raise RuntimeError("Client not initialized. Use async context manager.")
|
|
313
|
+
|
|
314
|
+
request_id = self._generate_request_id()
|
|
315
|
+
|
|
316
|
+
@retry(
|
|
317
|
+
retry=retry_if_exception_type(
|
|
318
|
+
(httpx.TimeoutException, httpx.NetworkError, AvanzaRetryableError)
|
|
319
|
+
),
|
|
320
|
+
stop=stop_after_attempt(self._max_retries),
|
|
321
|
+
wait=wait_exponential(multiplier=1, min=2, max=10),
|
|
322
|
+
reraise=True,
|
|
323
|
+
before_sleep=lambda retry_state: logger.info(
|
|
324
|
+
"Retrying POST request [%s] %s, attempt %d after %s",
|
|
325
|
+
request_id,
|
|
326
|
+
path,
|
|
327
|
+
retry_state.attempt_number,
|
|
328
|
+
type(retry_state.outcome.exception()).__name__
|
|
329
|
+
if retry_state.outcome
|
|
330
|
+
else "unknown",
|
|
331
|
+
),
|
|
332
|
+
)
|
|
333
|
+
async def _post_with_retry() -> dict[str, Any]:
|
|
334
|
+
try:
|
|
335
|
+
response = await self._client.post(path, json=json) # type: ignore
|
|
336
|
+
except httpx.TimeoutException as e:
|
|
337
|
+
logger.warning(
|
|
338
|
+
"POST timeout [%s] %s: %s", request_id, path, str(e)
|
|
339
|
+
)
|
|
340
|
+
raise AvanzaTimeoutError(
|
|
341
|
+
f"[{request_id}] Request timeout after {self._timeout}s: {path}"
|
|
342
|
+
) from e
|
|
343
|
+
except httpx.NetworkError as e:
|
|
344
|
+
logger.warning(
|
|
345
|
+
"POST network error [%s] %s: %s", request_id, path, str(e)
|
|
346
|
+
)
|
|
347
|
+
raise AvanzaNetworkError(
|
|
348
|
+
f"[{request_id}] Network error: {path} - {str(e)}"
|
|
349
|
+
) from e
|
|
350
|
+
|
|
351
|
+
if not response.is_success:
|
|
352
|
+
if self._is_retryable_status(response.status_code):
|
|
353
|
+
raise AvanzaRetryableError(
|
|
354
|
+
response.status_code,
|
|
355
|
+
f"[{request_id}] Server error (will retry): {path}",
|
|
356
|
+
)
|
|
357
|
+
self._handle_error(response, path, request_id)
|
|
358
|
+
|
|
359
|
+
if not response.content:
|
|
360
|
+
logger.debug("Empty POST response [%s] %s", request_id, path)
|
|
361
|
+
return {}
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
return response.json()
|
|
365
|
+
except Exception as e:
|
|
366
|
+
logger.error(
|
|
367
|
+
"POST JSON parse error [%s] %s: %s", request_id, path, str(e)
|
|
368
|
+
)
|
|
369
|
+
raise AvanzaAPIError(
|
|
370
|
+
response.status_code,
|
|
371
|
+
f"[{request_id}] Invalid JSON response: {path}",
|
|
372
|
+
) from e
|
|
373
|
+
|
|
374
|
+
logger.debug("POST [%s] %s", request_id, path)
|
|
375
|
+
return await _post_with_retry()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Avanza API endpoint definitions.
|
|
2
|
+
|
|
3
|
+
All endpoints are public and require no authentication.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from enum import Enum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PublicEndpoint(Enum):
|
|
10
|
+
"""Public Avanza API endpoints - no authentication required."""
|
|
11
|
+
|
|
12
|
+
# Search
|
|
13
|
+
SEARCH = "/_api/search/filtered-search"
|
|
14
|
+
|
|
15
|
+
# Market data - Stocks
|
|
16
|
+
STOCK_INFO = "/_api/market-guide/stock/{id}"
|
|
17
|
+
STOCK_ANALYSIS = "/_api/market-guide/stock/{id}/analysis"
|
|
18
|
+
STOCK_QUOTE = "/_api/market-guide/stock/{id}/quote"
|
|
19
|
+
STOCK_MARKETPLACE = "/_api/market-guide/stock/{id}/marketplace"
|
|
20
|
+
STOCK_ORDERDEPTH = "/_api/market-guide/stock/{id}/orderdepth"
|
|
21
|
+
STOCK_TRADES = "/_api/market-guide/stock/{id}/trades"
|
|
22
|
+
STOCK_BROKER_TRADES = "/_api/market-guide/stock/{id}/broker-trade-summaries"
|
|
23
|
+
STOCK_CHART = "/_api/price-chart/stock/{id}" # Requires timePeriod param
|
|
24
|
+
|
|
25
|
+
# Market data - Funds
|
|
26
|
+
FUND_INFO = "/_api/fund-guide/guide/{id}"
|
|
27
|
+
FUND_SUSTAINABILITY = "/_api/fund-reference/sustainability/{id}"
|
|
28
|
+
FUND_CHART = "/_api/fund-guide/chart/{id}/{time_period}" # time_period: three_years, etc.
|
|
29
|
+
FUND_CHART_PERIODS = "/_api/fund-guide/chart/timeperiods/{id}"
|
|
30
|
+
FUND_DESCRIPTION = "/_api/fund-guide/description/{id}"
|
|
31
|
+
|
|
32
|
+
def format(self, **kwargs: str | int) -> str:
|
|
33
|
+
"""Format endpoint path with variables.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
**kwargs: Variables to format into the endpoint path
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Formatted endpoint path
|
|
40
|
+
"""
|
|
41
|
+
return self.value.format(**kwargs)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Custom exceptions for Avanza client."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AvanzaError(Exception):
|
|
5
|
+
"""Base exception for Avanza API errors."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AvanzaAPIError(AvanzaError):
|
|
11
|
+
"""API returned an error response."""
|
|
12
|
+
|
|
13
|
+
def __init__(
|
|
14
|
+
self, status_code: int, message: str, response: dict | None = None
|
|
15
|
+
) -> None:
|
|
16
|
+
self.status_code = status_code
|
|
17
|
+
self.message = message
|
|
18
|
+
self.response = response
|
|
19
|
+
super().__init__(f"API error {status_code}: {message}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AvanzaAuthError(AvanzaError):
|
|
23
|
+
"""Authentication failed or token expired."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AvanzaNotFoundError(AvanzaError):
|
|
29
|
+
"""Requested resource not found."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AvanzaRateLimitError(AvanzaError):
|
|
35
|
+
"""Rate limit exceeded."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, retry_after: int | None = None, message: str | None = None) -> None:
|
|
38
|
+
self.retry_after = retry_after
|
|
39
|
+
msg = message or "Rate limit exceeded"
|
|
40
|
+
if retry_after and not message:
|
|
41
|
+
msg += f", retry after {retry_after}s"
|
|
42
|
+
super().__init__(msg)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AvanzaTimeoutError(AvanzaError):
|
|
46
|
+
"""Request timed out."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AvanzaNetworkError(AvanzaError):
|
|
52
|
+
"""Network error occurred during request."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AvanzaRetryableError(AvanzaError):
|
|
58
|
+
"""Transient error that should trigger a retry.
|
|
59
|
+
|
|
60
|
+
This is an internal exception used for retry logic.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, status_code: int, message: str) -> None:
|
|
64
|
+
self.status_code = status_code
|
|
65
|
+
self.message = message
|
|
66
|
+
super().__init__(f"Retryable error {status_code}: {message}")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Pydantic models for Avanza API responses."""
|
|
2
|
+
|
|
3
|
+
from .common import InstrumentType, TimePeriod
|
|
4
|
+
from .fund import (
|
|
5
|
+
FundChart,
|
|
6
|
+
FundChartPeriod,
|
|
7
|
+
FundDescription,
|
|
8
|
+
FundInfo,
|
|
9
|
+
FundPerformance,
|
|
10
|
+
FundSustainability,
|
|
11
|
+
)
|
|
12
|
+
from .search import SearchResponse, SearchHit
|
|
13
|
+
from .stock import (
|
|
14
|
+
BrokerTradeSummary,
|
|
15
|
+
MarketplaceInfo,
|
|
16
|
+
OrderDepth,
|
|
17
|
+
Quote,
|
|
18
|
+
StockChart,
|
|
19
|
+
StockInfo,
|
|
20
|
+
Trade,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"InstrumentType",
|
|
25
|
+
"TimePeriod",
|
|
26
|
+
"SearchResponse",
|
|
27
|
+
"SearchHit",
|
|
28
|
+
"Quote",
|
|
29
|
+
"StockInfo",
|
|
30
|
+
"FundInfo",
|
|
31
|
+
"FundPerformance",
|
|
32
|
+
"FundChart",
|
|
33
|
+
"FundChartPeriod",
|
|
34
|
+
"FundDescription",
|
|
35
|
+
"FundSustainability",
|
|
36
|
+
"StockChart",
|
|
37
|
+
"MarketplaceInfo",
|
|
38
|
+
"BrokerTradeSummary",
|
|
39
|
+
"Trade",
|
|
40
|
+
"OrderDepth",
|
|
41
|
+
]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Common models and enums shared across Avanza API."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InstrumentType(str, Enum):
|
|
7
|
+
"""Types of financial instruments available on Avanza."""
|
|
8
|
+
|
|
9
|
+
STOCK = "STOCK"
|
|
10
|
+
FUND = "FUND"
|
|
11
|
+
BOND = "BOND"
|
|
12
|
+
OPTION = "OPTION"
|
|
13
|
+
FUTURE_FORWARD = "FUTURE_FORWARD"
|
|
14
|
+
CERTIFICATE = "CERTIFICATE"
|
|
15
|
+
WARRANT = "WARRANT"
|
|
16
|
+
ETF = "ETF"
|
|
17
|
+
EXCHANGE_TRADED_FUND = "EXCHANGE_TRADED_FUND"
|
|
18
|
+
INDEX = "INDEX"
|
|
19
|
+
PREMIUM_BOND = "PREMIUM_BOND"
|
|
20
|
+
SUBSCRIPTION_OPTION = "SUBSCRIPTION_OPTION"
|
|
21
|
+
EQUITY_LINKED_BOND = "EQUITY_LINKED_BOND"
|
|
22
|
+
CONVERTIBLE = "CONVERTIBLE"
|
|
23
|
+
FAQ = "FAQ"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TimePeriod(str, Enum):
|
|
27
|
+
"""Time periods for chart data and performance metrics."""
|
|
28
|
+
|
|
29
|
+
TODAY = "TODAY"
|
|
30
|
+
ONE_WEEK = "ONE_WEEK"
|
|
31
|
+
ONE_MONTH = "ONE_MONTH"
|
|
32
|
+
THREE_MONTHS = "THREE_MONTHS"
|
|
33
|
+
THIS_YEAR = "THIS_YEAR"
|
|
34
|
+
ONE_YEAR = "ONE_YEAR"
|
|
35
|
+
THREE_YEARS = "THREE_YEARS"
|
|
36
|
+
FIVE_YEARS = "FIVE_YEARS"
|
|
37
|
+
ALL_TIME = "ALL_TIME"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Resolution(str, Enum):
|
|
41
|
+
"""Chart resolution/granularity."""
|
|
42
|
+
|
|
43
|
+
MINUTE = "MINUTE"
|
|
44
|
+
FIVE_MINUTES = "FIVE_MINUTES"
|
|
45
|
+
TEN_MINUTES = "TEN_MINUTES"
|
|
46
|
+
THIRTY_MINUTES = "THIRTY_MINUTES"
|
|
47
|
+
HOUR = "HOUR"
|
|
48
|
+
DAY = "DAY"
|
|
49
|
+
WEEK = "WEEK"
|
|
50
|
+
MONTH = "MONTH"
|