quantlib-pro 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.
- quantlib_api/__init__.py +50 -0
- quantlib_api/_http.py +243 -0
- quantlib_api/auth.py +164 -0
- quantlib_api/client.py +349 -0
- quantlib_api/exceptions.py +69 -0
- quantlib_api/resources/__init__.py +43 -0
- quantlib_api/resources/analytics.py +84 -0
- quantlib_api/resources/backtesting.py +76 -0
- quantlib_api/resources/base.py +15 -0
- quantlib_api/resources/compliance.py +95 -0
- quantlib_api/resources/data.py +66 -0
- quantlib_api/resources/execution.py +113 -0
- quantlib_api/resources/health.py +34 -0
- quantlib_api/resources/liquidity.py +83 -0
- quantlib_api/resources/macro.py +63 -0
- quantlib_api/resources/market_analysis.py +83 -0
- quantlib_api/resources/options.py +123 -0
- quantlib_api/resources/portfolio.py +131 -0
- quantlib_api/resources/regime.py +68 -0
- quantlib_api/resources/risk.py +105 -0
- quantlib_api/resources/signals.py +77 -0
- quantlib_api/resources/systemic_risk.py +86 -0
- quantlib_api/resources/uat.py +92 -0
- quantlib_api/resources/volatility.py +84 -0
- quantlib_cli/__init__.py +20 -0
- quantlib_cli/_endpoints.py +101 -0
- quantlib_cli/auth_store.py +105 -0
- quantlib_cli/cli.py +617 -0
- quantlib_cli/formatters.py +120 -0
- quantlib_pro/__init__.py +208 -0
- quantlib_pro/analytics/__init__.py +30 -0
- quantlib_pro/analytics/correlation_analysis.py +459 -0
- quantlib_pro/analytics/manager.py +54 -0
- quantlib_pro/api/__init__.py +154 -0
- quantlib_pro/api/auth.py +221 -0
- quantlib_pro/api/dependencies.py +612 -0
- quantlib_pro/api/health.py +242 -0
- quantlib_pro/api/models.py +452 -0
- quantlib_pro/api/optimizations.py +381 -0
- quantlib_pro/api/routers.py +1200 -0
- quantlib_pro/api/routers_analytics.py +451 -0
- quantlib_pro/api/routers_backtesting.py +402 -0
- quantlib_pro/api/routers_compliance.py +464 -0
- quantlib_pro/api/routers_data.py +465 -0
- quantlib_pro/api/routers_execution.py +471 -0
- quantlib_pro/api/routers_liquidity.py +410 -0
- quantlib_pro/api/routers_macro.py +323 -0
- quantlib_pro/api/routers_market_analysis.py +462 -0
- quantlib_pro/api/routers_realdata.py +396 -0
- quantlib_pro/api/routers_signals.py +416 -0
- quantlib_pro/api/routers_systemic_risk.py +421 -0
- quantlib_pro/api/routers_uat.py +403 -0
- quantlib_pro/audit/__init__.py +15 -0
- quantlib_pro/audit/calculation_log.py +269 -0
- quantlib_pro/cli.py +313 -0
- quantlib_pro/compliance/__init__.py +54 -0
- quantlib_pro/compliance/audit_trail.py +520 -0
- quantlib_pro/compliance/gdpr.py +478 -0
- quantlib_pro/compliance/reporting.py +404 -0
- quantlib_pro/data/__init__.py +113 -0
- quantlib_pro/data/alpha_vantage_client.py +349 -0
- quantlib_pro/data/cache.py +159 -0
- quantlib_pro/data/cache_manager.py +378 -0
- quantlib_pro/data/data_router.py +306 -0
- quantlib_pro/data/database.py +123 -0
- quantlib_pro/data/fetcher.py +199 -0
- quantlib_pro/data/fred_client.py +324 -0
- quantlib_pro/data/fred_provider.py +156 -0
- quantlib_pro/data/manager.py +250 -0
- quantlib_pro/data/market_data.py +237 -0
- quantlib_pro/data/models/__init__.py +22 -0
- quantlib_pro/data/models/audit.py +27 -0
- quantlib_pro/data/models/backtest.py +42 -0
- quantlib_pro/data/models/base.py +16 -0
- quantlib_pro/data/models/celery_task.py +36 -0
- quantlib_pro/data/models/portfolio.py +51 -0
- quantlib_pro/data/models/timeseries.py +54 -0
- quantlib_pro/data/models/user.py +26 -0
- quantlib_pro/data/providers/__init__.py +24 -0
- quantlib_pro/data/providers/alpha_vantage.py +338 -0
- quantlib_pro/data/providers/capital_iq.py +359 -0
- quantlib_pro/data/providers/factset.py +482 -0
- quantlib_pro/data/providers/multi_provider.py +210 -0
- quantlib_pro/data/providers_legacy.py +509 -0
- quantlib_pro/data/quality.py +205 -0
- quantlib_pro/data/yahoo_client.py +321 -0
- quantlib_pro/execution/__init__.py +84 -0
- quantlib_pro/execution/backtesting.py +551 -0
- quantlib_pro/execution/manager.py +55 -0
- quantlib_pro/execution/market_impact.py +325 -0
- quantlib_pro/execution/order_book.py +309 -0
- quantlib_pro/execution/strategies.py +351 -0
- quantlib_pro/governance/__init__.py +30 -0
- quantlib_pro/governance/policies.py +544 -0
- quantlib_pro/macro/__init__.py +97 -0
- quantlib_pro/macro/correlation.py +374 -0
- quantlib_pro/macro/economic.py +438 -0
- quantlib_pro/macro/macro_regime.py +167 -0
- quantlib_pro/macro/manager.py +44 -0
- quantlib_pro/macro/sentiment.py +443 -0
- quantlib_pro/market_microstructure/__init__.py +13 -0
- quantlib_pro/market_microstructure/calibrated_orderbook.py +289 -0
- quantlib_pro/market_regime/__init__.py +54 -0
- quantlib_pro/market_regime/hmm_detector.py +259 -0
- quantlib_pro/market_regime/manager.py +45 -0
- quantlib_pro/market_regime/trend_regime.py +296 -0
- quantlib_pro/market_regime/volatility_regime.py +284 -0
- quantlib_pro/monitoring/health_check.py +361 -0
- quantlib_pro/observability/__init__.py +144 -0
- quantlib_pro/observability/health.py +350 -0
- quantlib_pro/observability/metrics.py +342 -0
- quantlib_pro/observability/monitoring.py +475 -0
- quantlib_pro/observability/performance.py +369 -0
- quantlib_pro/observability/profiler.py +444 -0
- quantlib_pro/options/__init__.py +89 -0
- quantlib_pro/options/bachelier.py +625 -0
- quantlib_pro/options/black_scholes.py +551 -0
- quantlib_pro/options/greeks.py +367 -0
- quantlib_pro/options/manager.py +313 -0
- quantlib_pro/options/monte_carlo.py +377 -0
- quantlib_pro/portfolio/__init__.py +52 -0
- quantlib_pro/portfolio/black_litterman.py +246 -0
- quantlib_pro/portfolio/manager.py +186 -0
- quantlib_pro/portfolio/optimization.py +337 -0
- quantlib_pro/portfolio/optimizer.py +165 -0
- quantlib_pro/portfolio/risk_parity.py +236 -0
- quantlib_pro/resilience/__init__.py +17 -0
- quantlib_pro/resilience/circuit_breaker.py +204 -0
- quantlib_pro/risk/__init__.py +80 -0
- quantlib_pro/risk/advanced_analytics.py +504 -0
- quantlib_pro/risk/limits.py +206 -0
- quantlib_pro/risk/manager.py +220 -0
- quantlib_pro/risk/stress.py +21 -0
- quantlib_pro/risk/stress_testing.py +356 -0
- quantlib_pro/risk/var.py +358 -0
- quantlib_pro/sdk.py +294 -0
- quantlib_pro/security/__init__.py +40 -0
- quantlib_pro/security/authentication.py +203 -0
- quantlib_pro/security/encryption.py +118 -0
- quantlib_pro/security/rate_limiting.py +226 -0
- quantlib_pro/testing/__init__.py +64 -0
- quantlib_pro/testing/chaos.py +553 -0
- quantlib_pro/testing/load_testing.py +482 -0
- quantlib_pro/testing/model_validation.py +485 -0
- quantlib_pro/testing/reporting.py +554 -0
- quantlib_pro/uat/__init__.py +69 -0
- quantlib_pro/uat/bug_tracker.py +397 -0
- quantlib_pro/uat/feedback.py +514 -0
- quantlib_pro/uat/performance_validation.py +456 -0
- quantlib_pro/uat/scenarios.py +632 -0
- quantlib_pro/ui/__init__.py +9 -0
- quantlib_pro/ui/caching.py +419 -0
- quantlib_pro/ui/components.py +436 -0
- quantlib_pro/utils/__init__.py +41 -0
- quantlib_pro/utils/logging.py +72 -0
- quantlib_pro/utils/types.py +108 -0
- quantlib_pro/utils/usability.py +422 -0
- quantlib_pro/utils/validation.py +89 -0
- quantlib_pro/validation/__init__.py +17 -0
- quantlib_pro/validation/model_validation.py +294 -0
- quantlib_pro/volatility/__init__.py +48 -0
- quantlib_pro/volatility/manager.py +39 -0
- quantlib_pro/volatility/smile_models.py +348 -0
- quantlib_pro/volatility/surface.py +337 -0
- quantlib_pro-1.0.0.dist-info/METADATA +659 -0
- quantlib_pro-1.0.0.dist-info/RECORD +170 -0
- quantlib_pro-1.0.0.dist-info/WHEEL +5 -0
- quantlib_pro-1.0.0.dist-info/entry_points.txt +2 -0
- quantlib_pro-1.0.0.dist-info/licenses/LICENSE +1 -0
- quantlib_pro-1.0.0.dist-info/top_level.txt +3 -0
quantlib_api/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QuantLib Pro Python SDK
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
|
|
5
|
+
A Python client library for the QuantLib Pro quantitative finance API.
|
|
6
|
+
|
|
7
|
+
Usage::
|
|
8
|
+
|
|
9
|
+
from quantlib_api import QuantLibClient
|
|
10
|
+
|
|
11
|
+
client = QuantLibClient(
|
|
12
|
+
base_url="http://localhost:8000",
|
|
13
|
+
username="demo",
|
|
14
|
+
password="demo123",
|
|
15
|
+
auto_login=True
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
result = client.portfolio.optimize(
|
|
19
|
+
tickers=["AAPL", "GOOGL", "MSFT"],
|
|
20
|
+
budget=100_000,
|
|
21
|
+
optimization_target="sharpe"
|
|
22
|
+
)
|
|
23
|
+
print(result)
|
|
24
|
+
|
|
25
|
+
:license: MIT
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from quantlib_api.client import QuantLibClient
|
|
29
|
+
from quantlib_api.exceptions import (
|
|
30
|
+
QuantLibError,
|
|
31
|
+
QuantLibAPIError,
|
|
32
|
+
QuantLibAuthError,
|
|
33
|
+
QuantLibNotFoundError,
|
|
34
|
+
QuantLibRateLimitError,
|
|
35
|
+
QuantLibNetworkError,
|
|
36
|
+
QuantLibValidationError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__version__ = "1.0.0"
|
|
40
|
+
__author__ = "tubakhxn"
|
|
41
|
+
__all__ = [
|
|
42
|
+
"QuantLibClient",
|
|
43
|
+
"QuantLibError",
|
|
44
|
+
"QuantLibAPIError",
|
|
45
|
+
"QuantLibAuthError",
|
|
46
|
+
"QuantLibNotFoundError",
|
|
47
|
+
"QuantLibRateLimitError",
|
|
48
|
+
"QuantLibNetworkError",
|
|
49
|
+
"QuantLibValidationError",
|
|
50
|
+
]
|
quantlib_api/_http.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QuantLib Pro SDK — HTTP Session Wrapper
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- httpx session with connection pooling
|
|
6
|
+
- Automatic retry with exponential backoff (3 attempts)
|
|
7
|
+
- JWT Bearer token injection
|
|
8
|
+
- Timeout configuration (connect: 5s, read: 30s)
|
|
9
|
+
- Verbose mode for debugging (masks auth headers)
|
|
10
|
+
- Async support via httpx.AsyncClient
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from quantlib_api.exceptions import (
|
|
20
|
+
QuantLibAPIError,
|
|
21
|
+
QuantLibAuthError,
|
|
22
|
+
QuantLibNetworkError,
|
|
23
|
+
QuantLibNotFoundError,
|
|
24
|
+
QuantLibRateLimitError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Default timeouts
|
|
30
|
+
CONNECT_TIMEOUT = 5.0 # seconds
|
|
31
|
+
READ_TIMEOUT = 30.0 # seconds
|
|
32
|
+
MAX_RETRIES = 3
|
|
33
|
+
BACKOFF_BASE = 0.5 # seconds
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class HTTPSession:
|
|
37
|
+
"""
|
|
38
|
+
Synchronous HTTP session wrapping httpx.Client with retry logic.
|
|
39
|
+
|
|
40
|
+
Automatically injects Authorization header and handles
|
|
41
|
+
4xx/5xx responses by raising typed exceptions.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
base_url: str,
|
|
47
|
+
*,
|
|
48
|
+
timeout: float = READ_TIMEOUT,
|
|
49
|
+
max_retries: int = MAX_RETRIES,
|
|
50
|
+
verbose: bool = False,
|
|
51
|
+
):
|
|
52
|
+
self.base_url = base_url.rstrip("/")
|
|
53
|
+
self.timeout = timeout
|
|
54
|
+
self.max_retries = max_retries
|
|
55
|
+
self.verbose = verbose
|
|
56
|
+
self._token: str | None = None
|
|
57
|
+
self._api_key: str | None = None
|
|
58
|
+
self._client = httpx.Client(
|
|
59
|
+
base_url=self.base_url,
|
|
60
|
+
timeout=httpx.Timeout(connect=CONNECT_TIMEOUT, read=self.timeout, write=10.0, pool=5.0),
|
|
61
|
+
follow_redirects=True,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def set_token(self, token: str):
|
|
65
|
+
"""Update the Bearer token used for all requests."""
|
|
66
|
+
self._token = token
|
|
67
|
+
|
|
68
|
+
def set_api_key(self, api_key: str):
|
|
69
|
+
"""Update the API key used for all requests."""
|
|
70
|
+
self._api_key = api_key
|
|
71
|
+
|
|
72
|
+
def _headers(self) -> dict:
|
|
73
|
+
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
74
|
+
if self._token:
|
|
75
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
76
|
+
elif self._api_key:
|
|
77
|
+
headers["X-API-Key"] = self._api_key
|
|
78
|
+
return headers
|
|
79
|
+
|
|
80
|
+
def _log_request(self, method: str, url: str, body: Any = None):
|
|
81
|
+
if self.verbose:
|
|
82
|
+
safe_headers = self._headers()
|
|
83
|
+
if "Authorization" in safe_headers:
|
|
84
|
+
safe_headers["Authorization"] = "Bearer ***"
|
|
85
|
+
logger.debug(f"→ {method} {url} headers={safe_headers} body={body}")
|
|
86
|
+
|
|
87
|
+
def _handle_response(self, response: httpx.Response) -> dict:
|
|
88
|
+
status = response.status_code
|
|
89
|
+
if self.verbose:
|
|
90
|
+
logger.debug(f"← {status} ({len(response.content)} bytes)")
|
|
91
|
+
|
|
92
|
+
if status == 200 or status == 201:
|
|
93
|
+
try:
|
|
94
|
+
return response.json()
|
|
95
|
+
except Exception:
|
|
96
|
+
return {"raw": response.text}
|
|
97
|
+
|
|
98
|
+
# Error handling
|
|
99
|
+
try:
|
|
100
|
+
detail = response.json().get("detail", response.text)
|
|
101
|
+
except Exception:
|
|
102
|
+
detail = response.text
|
|
103
|
+
|
|
104
|
+
if status in (401, 403):
|
|
105
|
+
raise QuantLibAuthError(f"HTTP {status}: {detail}")
|
|
106
|
+
if status == 404:
|
|
107
|
+
raise QuantLibNotFoundError(detail)
|
|
108
|
+
if status == 429:
|
|
109
|
+
retry_after = int(response.headers.get("Retry-After", 60))
|
|
110
|
+
raise QuantLibRateLimitError(retry_after)
|
|
111
|
+
raise QuantLibAPIError(f"HTTP {status}: {detail}", status_code=status, response={"detail": detail})
|
|
112
|
+
|
|
113
|
+
def _request_with_retry(self, method: str, path: str, **kwargs) -> dict:
|
|
114
|
+
url = path if path.startswith("http") else self.base_url + path
|
|
115
|
+
self._log_request(method, url, kwargs.get("json"))
|
|
116
|
+
|
|
117
|
+
last_exc = None
|
|
118
|
+
for attempt in range(self.max_retries):
|
|
119
|
+
try:
|
|
120
|
+
response = self._client.request(method, url, headers=self._headers(), **kwargs)
|
|
121
|
+
return self._handle_response(response)
|
|
122
|
+
except (QuantLibAuthError, QuantLibNotFoundError):
|
|
123
|
+
raise # Don't retry auth/404 errors
|
|
124
|
+
except QuantLibRateLimitError as e:
|
|
125
|
+
wait = e.retry_after
|
|
126
|
+
logger.warning(f"Rate limited. Waiting {wait}s before retry {attempt + 1}/{self.max_retries}")
|
|
127
|
+
time.sleep(min(wait, 60))
|
|
128
|
+
last_exc = e
|
|
129
|
+
except QuantLibAPIError as e:
|
|
130
|
+
if e.status_code < 500:
|
|
131
|
+
raise # Don't retry 4xx
|
|
132
|
+
wait = BACKOFF_BASE * (2 ** attempt)
|
|
133
|
+
logger.warning(f"Server error {e.status_code}. Retrying in {wait:.1f}s (attempt {attempt + 1}/{self.max_retries})")
|
|
134
|
+
time.sleep(wait)
|
|
135
|
+
last_exc = e
|
|
136
|
+
except httpx.TimeoutException as e:
|
|
137
|
+
wait = BACKOFF_BASE * (2 ** attempt)
|
|
138
|
+
logger.warning(f"Timeout. Retrying in {wait:.1f}s (attempt {attempt + 1}/{self.max_retries})")
|
|
139
|
+
time.sleep(wait)
|
|
140
|
+
last_exc = QuantLibNetworkError(f"Request timed out after {self.timeout}s")
|
|
141
|
+
except httpx.ConnectError as e:
|
|
142
|
+
raise QuantLibNetworkError(f"Cannot connect to {self.base_url}. Is the server running?") from e
|
|
143
|
+
except httpx.RequestError as e:
|
|
144
|
+
raise QuantLibNetworkError(str(e)) from e
|
|
145
|
+
|
|
146
|
+
raise last_exc or QuantLibNetworkError("Max retries exceeded")
|
|
147
|
+
|
|
148
|
+
def get(self, path: str, params: dict = None) -> dict:
|
|
149
|
+
return self._request_with_retry("GET", path, params=params)
|
|
150
|
+
|
|
151
|
+
def post(self, path: str, json: dict = None) -> dict:
|
|
152
|
+
return self._request_with_retry("POST", path, json=json or {})
|
|
153
|
+
|
|
154
|
+
def put(self, path: str, json: dict = None) -> dict:
|
|
155
|
+
return self._request_with_retry("PUT", path, json=json or {})
|
|
156
|
+
|
|
157
|
+
def delete(self, path: str) -> dict:
|
|
158
|
+
return self._request_with_retry("DELETE", path)
|
|
159
|
+
|
|
160
|
+
def close(self):
|
|
161
|
+
self._client.close()
|
|
162
|
+
|
|
163
|
+
def __enter__(self):
|
|
164
|
+
return self
|
|
165
|
+
|
|
166
|
+
def __exit__(self, *args):
|
|
167
|
+
self.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class AsyncHTTPSession:
|
|
171
|
+
"""
|
|
172
|
+
Async HTTP session (httpx.AsyncClient) for use with asyncio.
|
|
173
|
+
|
|
174
|
+
Usage::
|
|
175
|
+
|
|
176
|
+
async with AsyncHTTPSession("http://localhost:8000") as session:
|
|
177
|
+
session.set_token(token)
|
|
178
|
+
result = await session.get("/api/v1/portfolio/performance")
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self, base_url: str, *, timeout: float = READ_TIMEOUT, verbose: bool = False):
|
|
182
|
+
self.base_url = base_url.rstrip("/")
|
|
183
|
+
self.timeout = timeout
|
|
184
|
+
self.verbose = verbose
|
|
185
|
+
self._token: str | None = None
|
|
186
|
+
self._client = httpx.AsyncClient(
|
|
187
|
+
base_url=self.base_url,
|
|
188
|
+
timeout=httpx.Timeout(connect=CONNECT_TIMEOUT, read=timeout, write=10.0, pool=5.0),
|
|
189
|
+
follow_redirects=True,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def set_token(self, token: str):
|
|
193
|
+
self._token = token
|
|
194
|
+
|
|
195
|
+
def _headers(self) -> dict:
|
|
196
|
+
h = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
197
|
+
if self._token:
|
|
198
|
+
h["Authorization"] = f"Bearer {self._token}"
|
|
199
|
+
return h
|
|
200
|
+
|
|
201
|
+
async def _handle_response_async(self, response: httpx.Response) -> dict:
|
|
202
|
+
status = response.status_code
|
|
203
|
+
if status in (200, 201):
|
|
204
|
+
try:
|
|
205
|
+
return response.json()
|
|
206
|
+
except Exception:
|
|
207
|
+
return {"raw": response.text}
|
|
208
|
+
try:
|
|
209
|
+
detail = response.json().get("detail", response.text)
|
|
210
|
+
except Exception:
|
|
211
|
+
detail = response.text
|
|
212
|
+
if status in (401, 403):
|
|
213
|
+
raise QuantLibAuthError(f"HTTP {status}: {detail}")
|
|
214
|
+
if status == 404:
|
|
215
|
+
raise QuantLibNotFoundError(detail)
|
|
216
|
+
if status == 429:
|
|
217
|
+
raise QuantLibRateLimitError(int(response.headers.get("Retry-After", 60)))
|
|
218
|
+
raise QuantLibAPIError(f"HTTP {status}: {detail}", status_code=status)
|
|
219
|
+
|
|
220
|
+
async def get(self, path: str, params: dict = None) -> dict:
|
|
221
|
+
url = self.base_url + path
|
|
222
|
+
try:
|
|
223
|
+
r = await self._client.get(url, headers=self._headers(), params=params)
|
|
224
|
+
return await self._handle_response_async(r)
|
|
225
|
+
except httpx.ConnectError as e:
|
|
226
|
+
raise QuantLibNetworkError(str(e)) from e
|
|
227
|
+
|
|
228
|
+
async def post(self, path: str, json: dict = None) -> dict:
|
|
229
|
+
url = self.base_url + path
|
|
230
|
+
try:
|
|
231
|
+
r = await self._client.post(url, headers=self._headers(), json=json or {})
|
|
232
|
+
return await self._handle_response_async(r)
|
|
233
|
+
except httpx.ConnectError as e:
|
|
234
|
+
raise QuantLibNetworkError(str(e)) from e
|
|
235
|
+
|
|
236
|
+
async def close(self):
|
|
237
|
+
await self._client.aclose()
|
|
238
|
+
|
|
239
|
+
async def __aenter__(self):
|
|
240
|
+
return self
|
|
241
|
+
|
|
242
|
+
async def __aexit__(self, *args):
|
|
243
|
+
await self.close()
|
quantlib_api/auth.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""
|
|
2
|
+
QuantLib Pro SDK — JWT Authentication Manager
|
|
3
|
+
|
|
4
|
+
Handles:
|
|
5
|
+
- Login via POST /auth/login
|
|
6
|
+
- Token storage in memory (never to disk)
|
|
7
|
+
- Auto-refresh on 401 responses
|
|
8
|
+
- Token expiry detection via JWT decode
|
|
9
|
+
- API key auth as alternative
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import httpx
|
|
20
|
+
|
|
21
|
+
from quantlib_api.exceptions import QuantLibAuthError, QuantLibNetworkError
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
API_BASE_URL_ENV = "QUANTLIB_URL"
|
|
26
|
+
API_KEY_ENV = "QUANTLIB_API_KEY"
|
|
27
|
+
DEFAULT_BASE_URL = "http://localhost:8000"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _decode_jwt_payload(token: str) -> dict:
|
|
31
|
+
"""Decode JWT payload without verifying signature (for expiry check only)."""
|
|
32
|
+
try:
|
|
33
|
+
parts = token.split(".")
|
|
34
|
+
if len(parts) != 3:
|
|
35
|
+
return {}
|
|
36
|
+
payload = parts[1]
|
|
37
|
+
# Add padding
|
|
38
|
+
payload += "=" * (4 - len(payload) % 4)
|
|
39
|
+
decoded = base64.urlsafe_b64decode(payload)
|
|
40
|
+
return json.loads(decoded)
|
|
41
|
+
except Exception:
|
|
42
|
+
return {}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _token_expired(token: str, buffer_seconds: int = 60) -> bool:
|
|
46
|
+
"""Return True if the JWT token expires within buffer_seconds."""
|
|
47
|
+
payload = _decode_jwt_payload(token)
|
|
48
|
+
exp = payload.get("exp")
|
|
49
|
+
if not exp:
|
|
50
|
+
return False # No expiry claim → assume valid
|
|
51
|
+
return time.time() >= (exp - buffer_seconds)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class AuthManager:
|
|
55
|
+
"""
|
|
56
|
+
Manages JWT authentication lifecycle for the QuantLib Pro API.
|
|
57
|
+
|
|
58
|
+
Supports:
|
|
59
|
+
- username/password login (POST /auth/login)
|
|
60
|
+
- API key via Authorization header
|
|
61
|
+
- Auto-refresh on expiry
|
|
62
|
+
- Environment variable configuration (QUANTLIB_URL, QUANTLIB_API_KEY)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
base_url: str,
|
|
68
|
+
username: Optional[str] = None,
|
|
69
|
+
password: Optional[str] = None,
|
|
70
|
+
api_key: Optional[str] = None,
|
|
71
|
+
):
|
|
72
|
+
self.base_url = base_url.rstrip("/")
|
|
73
|
+
self._username = username
|
|
74
|
+
self._password = password
|
|
75
|
+
self._api_key = api_key or os.environ.get(API_KEY_ENV)
|
|
76
|
+
self._token: Optional[str] = None
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_env(cls) -> "AuthManager":
|
|
80
|
+
"""Create AuthManager from environment variables."""
|
|
81
|
+
base_url = os.environ.get(API_BASE_URL_ENV, DEFAULT_BASE_URL)
|
|
82
|
+
api_key = os.environ.get(API_KEY_ENV)
|
|
83
|
+
return cls(base_url=base_url, api_key=api_key)
|
|
84
|
+
|
|
85
|
+
def login(self, username: Optional[str] = None, password: Optional[str] = None) -> str:
|
|
86
|
+
"""
|
|
87
|
+
Authenticate with username/password and store the JWT token.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
str: The JWT access token
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
QuantLibAuthError: If credentials are invalid
|
|
94
|
+
QuantLibNetworkError: If the server is unreachable
|
|
95
|
+
"""
|
|
96
|
+
user = username or self._username
|
|
97
|
+
pwd = password or self._password
|
|
98
|
+
|
|
99
|
+
if not user or not pwd:
|
|
100
|
+
raise QuantLibAuthError("Username and password are required for login.")
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
response = httpx.post(
|
|
104
|
+
f"{self.base_url}/auth/login",
|
|
105
|
+
json={"username": user, "password": pwd},
|
|
106
|
+
timeout=10.0,
|
|
107
|
+
)
|
|
108
|
+
except httpx.ConnectError as e:
|
|
109
|
+
raise QuantLibNetworkError(f"Cannot connect to {self.base_url}") from e
|
|
110
|
+
except httpx.TimeoutException as e:
|
|
111
|
+
raise QuantLibNetworkError("Login request timed out") from e
|
|
112
|
+
|
|
113
|
+
if response.status_code == 200:
|
|
114
|
+
data = response.json()
|
|
115
|
+
token = data.get("access_token") or data.get("token")
|
|
116
|
+
if not token:
|
|
117
|
+
raise QuantLibAuthError("Login succeeded but no token in response.")
|
|
118
|
+
self._token = token
|
|
119
|
+
logger.info("✅ Successfully authenticated with QuantLib Pro API")
|
|
120
|
+
return token
|
|
121
|
+
elif response.status_code in (401, 403):
|
|
122
|
+
raise QuantLibAuthError(f"Invalid credentials: {response.json().get('detail', '')}")
|
|
123
|
+
else:
|
|
124
|
+
raise QuantLibAuthError(f"Login failed with status {response.status_code}")
|
|
125
|
+
|
|
126
|
+
def get_token(self, auto_refresh: bool = True) -> Optional[str]:
|
|
127
|
+
"""
|
|
128
|
+
Get the current valid token. Auto-refreshes if expired and credentials available.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
str | None: JWT token, or None if not authenticated
|
|
132
|
+
"""
|
|
133
|
+
if self._api_key:
|
|
134
|
+
return self._api_key # API key doesn't expire
|
|
135
|
+
|
|
136
|
+
if not self._token:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
if auto_refresh and _token_expired(self._token) and self._username and self._password:
|
|
140
|
+
logger.info("Token expired. Auto-refreshing...")
|
|
141
|
+
try:
|
|
142
|
+
self.login()
|
|
143
|
+
except Exception as e:
|
|
144
|
+
logger.warning(f"Token refresh failed: {e}")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
return self._token
|
|
148
|
+
|
|
149
|
+
def get_headers(self) -> dict:
|
|
150
|
+
"""Return Authorization headers for API requests."""
|
|
151
|
+
token = self.get_token()
|
|
152
|
+
if token:
|
|
153
|
+
return {"Authorization": f"Bearer {token}"}
|
|
154
|
+
return {}
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def is_authenticated(self) -> bool:
|
|
158
|
+
"""True if a valid token is available."""
|
|
159
|
+
return bool(self.get_token(auto_refresh=False))
|
|
160
|
+
|
|
161
|
+
def logout(self):
|
|
162
|
+
"""Clear stored credentials."""
|
|
163
|
+
self._token = None
|
|
164
|
+
logger.info("Logged out from QuantLib Pro API")
|