volt-client 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.
@@ -0,0 +1,28 @@
1
+ """
2
+ Volt Client - Python client for Volt Power Analytics API.
3
+
4
+ Usage:
5
+ from volt_client import VoltClient
6
+
7
+ client = VoltClient(api_key="volt_your_api_key")
8
+ df = client.get_actual("price spot no1 nordpool eur mwh min60 actual", "2024-01-01", "2024-12-31")
9
+ """
10
+
11
+ from volt_client.client import (
12
+ DataFrame,
13
+ VoltAccessError,
14
+ VoltClient,
15
+ VoltClientError,
16
+ VoltCurveNotFoundError,
17
+ VoltEmptyDataError,
18
+ )
19
+
20
+ __version__ = "1.0.0"
21
+ __all__ = [
22
+ "VoltClient",
23
+ "VoltClientError",
24
+ "VoltAccessError",
25
+ "VoltCurveNotFoundError",
26
+ "VoltEmptyDataError",
27
+ "DataFrame",
28
+ ]
volt_client/client.py ADDED
@@ -0,0 +1,826 @@
1
+ """
2
+ Volt Client for accessing Volt Power Analytics time series data API.
3
+
4
+ Usage:
5
+ from volt_client import VoltClient
6
+
7
+ # Recommended: Use API key (generated from dashboard)
8
+ client = VoltClient(api_key="volt_abc123...")
9
+ df = client.get_actual("price power no1 nordpool eur min60 actual", "2024-01-01", "2024-12-31")
10
+
11
+ # For better performance with large datasets, use polars:
12
+ client = VoltClient(api_key="volt_abc123...", use_polars=True)
13
+ df = client.get_actual(...) # Returns polars DataFrame
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import TYPE_CHECKING
20
+
21
+ import orjson
22
+ import polars as pl
23
+ import requests
24
+ from requests.adapters import HTTPAdapter
25
+ from urllib3.util.retry import Retry
26
+
27
+ if TYPE_CHECKING:
28
+ import pandas as pd
29
+
30
+ # Type alias for DataFrame return type
31
+ DataFrame = pl.DataFrame
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Default API URL
36
+ DEFAULT_API_URL = "https://volt-api-gateway.azure-api.net/volt"
37
+
38
+
39
+ class VoltClientError(Exception):
40
+ """Base exception for VoltClient errors."""
41
+
42
+ pass
43
+
44
+
45
+ class VoltAccessError(VoltClientError):
46
+ """Raised when access is denied."""
47
+
48
+ pass
49
+
50
+
51
+ class VoltCurveNotFoundError(VoltClientError):
52
+ """Raised when a curve is not found."""
53
+
54
+ pass
55
+
56
+
57
+ class VoltEmptyDataError(VoltClientError):
58
+ """Raised when no data is returned."""
59
+
60
+ pass
61
+
62
+
63
+ class VoltClient:
64
+ """
65
+ Client for accessing Volt Power Analytics time series data API.
66
+
67
+ Internally uses polars for all data processing (10x faster than pandas).
68
+ By default returns pandas DataFrames for backwards compatibility.
69
+
70
+ Parameters:
71
+ -----------
72
+ api_key : str
73
+ API key for authentication (required, generated from dashboard).
74
+ Example: "volt_abc123..."
75
+ base_url : str, optional
76
+ Base URL for the API (default: https://volt-api-gateway.azure-api.net/volt)
77
+ use_polars : bool, optional
78
+ If True, return polars DataFrames instead of pandas.
79
+ Default: False (returns pandas DataFrames for backwards compatibility)
80
+
81
+ Example:
82
+ --------
83
+ >>> from volt_client import VoltClient
84
+ >>> client = VoltClient(api_key="volt_abc123...")
85
+ >>> df = client.get_actual("price power no1 nordpool eur min60 actual", "2024-01-01", "2024-12-31")
86
+
87
+ >>> # For better performance with large datasets:
88
+ >>> client = VoltClient(api_key="volt_abc123...", use_polars=True)
89
+ >>> df = client.get_actual(...) # Returns polars DataFrame
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ api_key: str,
95
+ base_url: str = DEFAULT_API_URL,
96
+ use_polars: bool = False,
97
+ ):
98
+ if not api_key:
99
+ raise ValueError("API key is required. Get one from the Volt Analytics dashboard.")
100
+
101
+ self.api_key = api_key
102
+ self.base_url = base_url.rstrip("/")
103
+ self.use_polars = use_polars
104
+ self._session = None
105
+ self._curves_cache: pl.DataFrame | None = None
106
+
107
+ def _get_session(self) -> requests.Session:
108
+ """Get or create a requests session with retry logic."""
109
+ if self._session is None:
110
+ self._session = requests.Session()
111
+ retry = Retry(
112
+ total=3,
113
+ backoff_factor=0.3,
114
+ status_forcelist=(500, 502, 503, 504),
115
+ )
116
+ adapter = HTTPAdapter(max_retries=retry)
117
+ self._session.mount("http://", adapter)
118
+ self._session.mount("https://", adapter)
119
+ return self._session
120
+
121
+ def _get_headers(self) -> dict:
122
+ """Get request headers with authentication credentials."""
123
+ return {
124
+ "Content-Type": "application/json",
125
+ "X-API-Key": self.api_key,
126
+ }
127
+
128
+ def _request(self, method: str, endpoint: str, params: dict = None) -> dict:
129
+ """Make an API request."""
130
+ session = self._get_session()
131
+ url = f"{self.base_url}{endpoint}"
132
+
133
+ response = session.request(
134
+ method=method,
135
+ url=url,
136
+ headers=self._get_headers(),
137
+ params=params,
138
+ )
139
+
140
+ if response.status_code == 401:
141
+ raise VoltAccessError("Invalid API key or access denied")
142
+ elif response.status_code == 403:
143
+ raise VoltAccessError(f"Access denied to {endpoint}")
144
+ elif response.status_code == 404:
145
+ # Include params in error for debugging
146
+ param_info = f" (params: {params})" if params else ""
147
+ raise VoltCurveNotFoundError(f"Not found: {endpoint}{param_info}")
148
+ elif response.status_code != 200:
149
+ raise VoltClientError(f"API error: {response.status_code} - {response.text}")
150
+
151
+ # Use orjson for faster JSON parsing (3-10x faster than stdlib json)
152
+ return orjson.loads(response.content)
153
+
154
+ def _to_output(self, df: pl.DataFrame) -> DataFrame | pd.DataFrame:
155
+ """Convert polars DataFrame to output format based on use_polars setting."""
156
+ if self.use_polars:
157
+ return df
158
+
159
+ pdf = df.to_pandas()
160
+ if "ts" in pdf.columns:
161
+ pdf = pdf.set_index("ts")
162
+ return pdf
163
+
164
+ def search(self, area: str = None, groups: str = None) -> DataFrame:
165
+ """
166
+ Search for available curves.
167
+
168
+ Parameters:
169
+ -----------
170
+ area : str, optional
171
+ Filter by area (e.g., "NO1", "SE1")
172
+ groups : str, optional
173
+ Filter by group (e.g., "historical", "mid-term")
174
+
175
+ Returns:
176
+ --------
177
+ DataFrame with curve metadata (curve_id, curve_name, area, groups, etc.)
178
+ Returns polars DataFrame if use_polars=True, otherwise pandas DataFrame.
179
+ """
180
+ params = {}
181
+ if area:
182
+ params["area"] = area
183
+ if groups:
184
+ params["groups"] = groups
185
+
186
+ data = self._request("GET", "/curves", params=params)
187
+ return pl.DataFrame(data) if self.use_polars else pl.DataFrame(data).to_pandas()
188
+
189
+ def get_curve_id(self, curve_name: str) -> int:
190
+ """
191
+ Get the curve ID for a given curve name.
192
+
193
+ Parameters:
194
+ -----------
195
+ curve_name : str
196
+ The unique curve name
197
+
198
+ Returns:
199
+ --------
200
+ int: The curve ID
201
+ """
202
+ # Always use polars internally for cache
203
+ if self._curves_cache is None:
204
+ data = self._request("GET", "/curves", params={})
205
+ self._curves_cache = pl.DataFrame(data)
206
+
207
+ curve_name_lower = curve_name.lower()
208
+ match = self._curves_cache.filter(
209
+ pl.col("curve_name").str.to_lowercase() == curve_name_lower
210
+ )
211
+
212
+ if match.is_empty():
213
+ raise VoltCurveNotFoundError(
214
+ f"Curve '{curve_name}' not found or you do not have access to it"
215
+ )
216
+ return int(match["curve_id"][0])
217
+
218
+ def name_search(self, search_query: str) -> DataFrame:
219
+ """
220
+ Search for curves by keywords in the name.
221
+
222
+ Parameters:
223
+ -----------
224
+ search_query : str
225
+ Space-separated keywords to search for (e.g., "price no1 actual")
226
+
227
+ Returns:
228
+ --------
229
+ DataFrame with matching curves.
230
+ Returns polars DataFrame if use_polars=True, otherwise pandas DataFrame.
231
+ """
232
+ # Always use polars internally for cache
233
+ if self._curves_cache is None:
234
+ data = self._request("GET", "/curves", params={})
235
+ self._curves_cache = pl.DataFrame(data)
236
+
237
+ search_terms = search_query.lower().split()
238
+
239
+ # Build polars filter expression
240
+ filter_expr = pl.lit(True)
241
+ for term in search_terms:
242
+ filter_expr = filter_expr & pl.col("curve_name").str.to_lowercase().str.contains(term)
243
+ matches = self._curves_cache.filter(filter_expr)
244
+
245
+ if matches.is_empty():
246
+ logger.warning(f"No curves found matching '{search_query}'")
247
+
248
+ return matches if self.use_polars else matches.to_pandas()
249
+
250
+ def get_actual(
251
+ self,
252
+ curve_name,
253
+ start_date: str,
254
+ end_date: str,
255
+ tz: str = None,
256
+ agg: str = None,
257
+ agg_func: str = None,
258
+ ) -> DataFrame:
259
+ """
260
+ Get actual (historical) time series data for a curve or multiple curves.
261
+
262
+ Supports both single curve and bulk queries. When a list is provided,
263
+ automatically uses a single optimized bulk request (much faster than
264
+ calling get_actual() in a loop).
265
+
266
+ Parameters:
267
+ -----------
268
+ curve_name : str or list of str
269
+ Single curve name, or list of curve names for bulk query.
270
+ Curve names must end with 'actual' or 'synthetic'.
271
+ start_date : str
272
+ Start date in format 'YYYY-MM-DD'
273
+ end_date : str
274
+ End date in format 'YYYY-MM-DD'
275
+ tz : str, optional
276
+ Output timezone (e.g., 'Europe/Oslo', 'CET'). Default: UTC.
277
+ agg : str, optional
278
+ Aggregation period: 'hourly', 'daily', 'weekly', 'monthly', 'quarterly', 'yearly'.
279
+ If specified, data is aggregated server-side.
280
+ agg_func : str, optional
281
+ Aggregation function: 'avg', 'sum', 'min', 'max'. Default: 'avg'.
282
+ Only used when agg is specified.
283
+
284
+ Returns:
285
+ --------
286
+ DataFrame with DatetimeIndex and value column(s) named after the curve(s).
287
+ Returns polars DataFrame if use_polars=True, otherwise pandas DataFrame.
288
+
289
+ Examples:
290
+ ---------
291
+ # Single curve
292
+ df = client.get_actual(
293
+ "price power no1 nordpool eur min60 actual",
294
+ "2024-01-01", "2024-12-31"
295
+ )
296
+
297
+ # Multiple curves (bulk query - much faster)
298
+ df = client.get_actual(
299
+ ["price power no1 nordpool eur min60 actual",
300
+ "price power no2 nordpool eur min60 actual",
301
+ "price power se1 nordpool eur min60 actual"],
302
+ "2024-01-01", "2024-12-31",
303
+ tz="Europe/Oslo",
304
+ agg="daily"
305
+ )
306
+ # Returns DataFrame with one column per curve
307
+ """
308
+ # If list provided, use bulk query for efficiency
309
+ if isinstance(curve_name, list | tuple):
310
+ return self.get_bulk(
311
+ curve_names=list(curve_name),
312
+ start_date=start_date,
313
+ end_date=end_date,
314
+ tz=tz,
315
+ agg=agg,
316
+ agg_func=agg_func,
317
+ )
318
+
319
+ curve_name_lower = curve_name.lower()
320
+ curve_type = curve_name_lower.split()[-1]
321
+
322
+ if curve_type not in ["actual", "synthetic"]:
323
+ raise VoltClientError(
324
+ f"Curve '{curve_name}' is not of type 'actual' or 'synthetic' but '{curve_type}'. "
325
+ "Use get_actual only for actual/synthetic curves."
326
+ )
327
+
328
+ curve_id = self.get_curve_id(curve_name_lower)
329
+
330
+ params = {
331
+ "curve_id": curve_id,
332
+ "start_date": start_date,
333
+ "end_date": end_date,
334
+ }
335
+ if tz:
336
+ params["tz"] = tz
337
+ if agg:
338
+ params["agg"] = agg
339
+ if agg_func:
340
+ params["agg_func"] = agg_func
341
+
342
+ data = self._request("GET", "/data/actual", params=params)
343
+
344
+ if not data.get("data"):
345
+ raise VoltEmptyDataError(
346
+ f"No data for '{curve_name}' between {start_date} and {end_date}"
347
+ )
348
+
349
+ # Always use polars internally for processing
350
+ df = pl.DataFrame(data["data"])
351
+ target_tz = tz or "UTC"
352
+ df = df.with_columns(
353
+ pl.col("ts")
354
+ .str.to_datetime(time_unit="us", time_zone="UTC")
355
+ .dt.convert_time_zone(target_tz)
356
+ )
357
+ df = df.sort("ts")
358
+ df = df.unique(subset=["ts"], keep="last")
359
+ df = df.rename({"value": curve_name_lower})
360
+ df = df.select(["ts", curve_name_lower])
361
+
362
+ logger.info(f"Extracted data for: {curve_name} from {df['ts'][0]} to {df['ts'][-1]}")
363
+
364
+ return self._to_output(df)
365
+
366
+ def get_fcast(
367
+ self,
368
+ curve_name,
369
+ start_date: str,
370
+ end_date: str,
371
+ as_of: str = None,
372
+ tz: str = None,
373
+ freq: str = None,
374
+ tags: str = None,
375
+ ) -> DataFrame:
376
+ """
377
+ Get forecast time series data for a curve or multiple curves.
378
+
379
+ Supports both single curve and bulk queries. When a list is provided,
380
+ automatically uses the bulk endpoint (much faster than calling get_fcast()
381
+ in a loop).
382
+
383
+ Parameters:
384
+ -----------
385
+ curve_name : str or list of str
386
+ Single curve name, or list of curve names for bulk query.
387
+ Curve names must end with 'fcast'.
388
+ start_date : str
389
+ Start date in format 'YYYY-MM-DD'
390
+ end_date : str
391
+ End date in format 'YYYY-MM-DD'
392
+ as_of : str, optional
393
+ As-of date for forecast (YYYY-MM-DD). If not specified, uses latest.
394
+ Note: For bulk queries, as_of is not supported.
395
+ tz : str, optional
396
+ Output timezone (e.g., 'Europe/Oslo', 'CET'). Default: UTC.
397
+ Note: For mid-term percentile data (when freq is specified), only
398
+ CET-compatible timezones are allowed.
399
+ freq : str, optional
400
+ Frequency for mid-term percentile/average data (e.g., 'weekly', 'monthly').
401
+ Only applicable for mid-term curves. Not supported in bulk queries.
402
+ tags : str, optional
403
+ Comma-separated weather year tags for mid-term hourly data.
404
+ Not supported in bulk queries.
405
+
406
+ Returns:
407
+ --------
408
+ DataFrame with timestamp and value column(s).
409
+ Returns polars DataFrame if use_polars=True, otherwise pandas DataFrame.
410
+
411
+ Examples:
412
+ ---------
413
+ # Single curve
414
+ df = client.get_fcast("prod wind no1 mid-term fcast", "2024-01-01", "2024-12-31")
415
+
416
+ # Multiple curves (bulk query - much faster)
417
+ df = client.get_fcast([
418
+ "prod wind no1 mid-term fcast",
419
+ "prod wind no2 mid-term fcast",
420
+ "prod solar de mid-term fcast"
421
+ ], "2024-01-01", "2024-12-31")
422
+ """
423
+ # If list provided, use bulk query for efficiency
424
+ if isinstance(curve_name, list | tuple):
425
+ if as_of or freq or tags:
426
+ logger.warning(
427
+ "Bulk forecast queries do not support as_of, freq, or tags parameters. "
428
+ "These will be ignored."
429
+ )
430
+ return self.get_bulk(
431
+ curve_names=list(curve_name),
432
+ start_date=start_date,
433
+ end_date=end_date,
434
+ tz=tz,
435
+ )
436
+
437
+ curve_name_lower = curve_name.lower()
438
+ curve_type = curve_name_lower.split()[-1]
439
+
440
+ if curve_type != "fcast":
441
+ raise VoltClientError(
442
+ f"Curve '{curve_name}' is not of type 'fcast' but '{curve_type}'. "
443
+ "Use get_fcast only for forecast curves."
444
+ )
445
+
446
+ curve_id = self.get_curve_id(curve_name_lower)
447
+
448
+ params = {
449
+ "curve_id": curve_id,
450
+ "start_date": start_date,
451
+ "end_date": end_date,
452
+ }
453
+ if as_of:
454
+ params["as_of"] = as_of
455
+ if tz:
456
+ params["tz"] = tz
457
+ if freq:
458
+ params["freq"] = freq
459
+ if tags:
460
+ params["tags"] = tags
461
+
462
+ data = self._request("GET", "/data/fcast", params=params)
463
+
464
+ if not data.get("data"):
465
+ raise VoltEmptyDataError(
466
+ f"No data for '{curve_name}' between {start_date} and {end_date}"
467
+ )
468
+
469
+ # Always use polars internally
470
+ df = pl.DataFrame(data["data"])
471
+ target_tz = tz or "UTC"
472
+ df = df.with_columns(
473
+ pl.col("ts")
474
+ .str.to_datetime(time_unit="us", time_zone="UTC")
475
+ .dt.convert_time_zone(target_tz)
476
+ )
477
+ df = df.sort("ts")
478
+ df = df.unique(subset=["ts"], keep="last")
479
+ if "value" in df.columns:
480
+ df = df.rename({"value": curve_name_lower})
481
+
482
+ logger.info(
483
+ f"Extracted forecast data for: {curve_name} from {df['ts'][0]} to {df['ts'][-1]}"
484
+ )
485
+
486
+ return self._to_output(df)
487
+
488
+ def get_closing(
489
+ self,
490
+ curve_name,
491
+ as_of: str = None,
492
+ tz: str = None,
493
+ ) -> DataFrame:
494
+ """
495
+ Get latest closing/settlement prices for a forward curve or multiple curves.
496
+
497
+ Supports both single curve and bulk queries. When a list is provided,
498
+ automatically uses a single optimized bulk request (much faster than
499
+ calling get_closing() in a loop).
500
+
501
+ Parameters:
502
+ -----------
503
+ curve_name : str or list of str
504
+ Single curve name, or list of curve names for bulk query.
505
+ Curve names must end with 'close' or 'settlement'.
506
+ as_of : str, optional
507
+ As-of date (YYYY-MM-DD). If not specified, uses latest for each curve.
508
+ tz : str, optional
509
+ Output timezone (e.g., 'Europe/Oslo', 'CET'). Default: UTC.
510
+
511
+ Returns:
512
+ --------
513
+ For single curve: DataFrame with forward contract data (delivery periods and prices).
514
+ For bulk query: DataFrame with curve_name, ts, value, and as_of columns.
515
+ Returns polars DataFrame if use_polars=True, otherwise pandas DataFrame.
516
+
517
+ Examples:
518
+ ---------
519
+ # Single curve
520
+ df = client.get_closing("price future no1 nasdaq close")
521
+
522
+ # Multiple curves (bulk query - much faster)
523
+ df = client.get_closing([
524
+ "price future no1 nasdaq close",
525
+ "price future no2 nasdaq close",
526
+ "price future se1 nasdaq close"
527
+ ])
528
+ """
529
+ # If list provided, use bulk query for efficiency
530
+ if isinstance(curve_name, list | tuple):
531
+ return self.get_bulk_closing(
532
+ curve_names=list(curve_name),
533
+ as_of=as_of,
534
+ tz=tz,
535
+ )
536
+
537
+ curve_name_lower = curve_name.lower()
538
+ curve_type = curve_name_lower.split()[-1]
539
+
540
+ if curve_type not in ["close", "settlement"]:
541
+ raise VoltClientError(
542
+ f"Curve '{curve_name}' is not of type 'close' or 'settlement' but '{curve_type}'. "
543
+ "Use get_closing only for closing/settlement curves."
544
+ )
545
+
546
+ curve_id = self.get_curve_id(curve_name_lower)
547
+
548
+ params = {"curve_id": curve_id}
549
+ if as_of:
550
+ params["as_of"] = as_of
551
+ if tz:
552
+ params["tz"] = tz
553
+
554
+ data = self._request("GET", "/data/closing", params=params)
555
+
556
+ if not data.get("data"):
557
+ raise VoltEmptyDataError(f"No closing data for '{curve_name}'")
558
+
559
+ # Always use polars internally
560
+ df = pl.DataFrame(data["data"])
561
+ target_tz = tz or "UTC"
562
+ for col in ["ts", "delivery_end"]:
563
+ if col in df.columns:
564
+ df = df.with_columns(
565
+ pl.col(col)
566
+ .str.to_datetime(time_unit="us", time_zone="UTC")
567
+ .dt.convert_time_zone(target_tz)
568
+ )
569
+
570
+ logger.info(
571
+ f"Extracted closing data for: {curve_name}, as_of: {data.get('as_of')}, count: {len(df)}"
572
+ )
573
+
574
+ return df if self.use_polars else df.to_pandas()
575
+
576
+ def get_bulk_closing(
577
+ self,
578
+ curve_names: list[str],
579
+ as_of: str = None,
580
+ as_of_start: str = None,
581
+ as_of_end: str = None,
582
+ tz: str = None,
583
+ ) -> DataFrame:
584
+ """
585
+ Get closing/settlement prices for multiple forward curves in a single request.
586
+
587
+ This is much more efficient than calling get_closing() multiple times
588
+ as it makes a single HTTP request and ClickHouse query.
589
+
590
+ Parameters:
591
+ -----------
592
+ curve_names : list of str
593
+ List of curve names to fetch (must end with 'close' or 'settlement')
594
+ as_of : str, optional
595
+ Single as-of date (YYYY-MM-DD). If not specified, uses latest for each curve.
596
+ as_of_start : str, optional
597
+ Start of as-of range (YYYY-MM-DD). Use with as_of_end for development over time.
598
+ as_of_end : str, optional
599
+ End of as-of range (YYYY-MM-DD). Use with as_of_start for development over time.
600
+ tz : str, optional
601
+ Output timezone (e.g., 'Europe/Oslo', 'CET'). Default: UTC.
602
+
603
+ Returns:
604
+ --------
605
+ DataFrame with columns: curve_name, ts, value, as_of.
606
+ Returns polars DataFrame if use_polars=True, otherwise pandas DataFrame.
607
+
608
+ Examples:
609
+ ---------
610
+ # Snapshot mode - latest for each curve
611
+ df = client.get_bulk_closing(["curve1 close", "curve2 close"])
612
+
613
+ # Snapshot mode - specific date
614
+ df = client.get_bulk_closing(["curve1 close", "curve2 close"], as_of="2025-01-15")
615
+
616
+ # Range mode - development over time
617
+ df = client.get_bulk_closing(
618
+ ["curve1 close", "curve2 close"],
619
+ as_of_start="2025-01-01",
620
+ as_of_end="2025-01-15"
621
+ )
622
+ """
623
+ if not curve_names:
624
+ raise VoltClientError("curve_names cannot be empty")
625
+
626
+ if as_of and (as_of_start or as_of_end):
627
+ raise VoltClientError(
628
+ "Cannot use 'as_of' together with 'as_of_start'/'as_of_end'. "
629
+ "Use either snapshot mode (as_of) or range mode (as_of_start + as_of_end)."
630
+ )
631
+ if (as_of_start and not as_of_end) or (as_of_end and not as_of_start):
632
+ raise VoltClientError(
633
+ "Both 'as_of_start' and 'as_of_end' must be specified for range mode."
634
+ )
635
+
636
+ # Get curve IDs for all names
637
+ curve_ids = []
638
+ id_to_name = {}
639
+ for name in curve_names:
640
+ name_lower = name.lower()
641
+ curve_id = self.get_curve_id(name_lower)
642
+ curve_ids.append(curve_id)
643
+ id_to_name[curve_id] = name_lower
644
+
645
+ params = {
646
+ "curve_ids": ",".join(str(cid) for cid in curve_ids),
647
+ }
648
+ if as_of:
649
+ params["as_of"] = as_of
650
+ if as_of_start:
651
+ params["as_of_start"] = as_of_start
652
+ if as_of_end:
653
+ params["as_of_end"] = as_of_end
654
+ if tz:
655
+ params["tz"] = tz
656
+
657
+ data = self._request("GET", "/data/closing/bulk", params=params)
658
+
659
+ curves_data = data.get("curves", [])
660
+ if not curves_data:
661
+ raise VoltEmptyDataError(
662
+ f"No closing data found for any of the {len(curve_names)} requested curves. "
663
+ "Check that curves have data in curves_forwards table."
664
+ )
665
+
666
+ # Log which curves had data vs which didn't
667
+ curves_with_data = {c["curve_id"] for c in curves_data}
668
+ curves_without_data = [id_to_name[cid] for cid in curve_ids if cid not in curves_with_data]
669
+ if curves_without_data:
670
+ logger.warning(
671
+ f"No closing data for {len(curves_without_data)} curves: {curves_without_data[:5]}..."
672
+ )
673
+
674
+ # Build combined DataFrame using pl.concat (avoids Python row-by-row loop)
675
+ dfs = []
676
+ for curve in curves_data:
677
+ curve_id = curve["curve_id"]
678
+ curve_name = id_to_name.get(curve_id, curve.get("curve_name", f"curve_{curve_id}"))
679
+ curve_as_of = curve.get("as_of")
680
+
681
+ if not curve.get("data"):
682
+ continue
683
+
684
+ cdf = pl.DataFrame(curve["data"])
685
+ cdf = cdf.with_columns(pl.lit(curve_name).alias("curve_name"))
686
+ # In range mode, as_of is on each point; in snapshot mode, add from curve
687
+ if "as_of" not in cdf.columns:
688
+ cdf = cdf.with_columns(pl.lit(curve_as_of).alias("as_of"))
689
+ dfs.append(cdf)
690
+
691
+ if not dfs:
692
+ raise VoltEmptyDataError("No closing data for curves")
693
+
694
+ df = pl.concat(dfs)
695
+ target_tz = tz or "UTC"
696
+ df = df.with_columns(
697
+ pl.col("ts")
698
+ .str.to_datetime(time_unit="us", time_zone="UTC")
699
+ .dt.convert_time_zone(target_tz)
700
+ )
701
+ df = df.sort(["curve_name", "as_of", "ts"])
702
+
703
+ logger.info(
704
+ f"Extracted bulk closing data for {len(curve_names)} curves: {len(df)} rows total"
705
+ )
706
+
707
+ return df if self.use_polars else df.to_pandas()
708
+
709
+ def get_bulk(
710
+ self,
711
+ curve_names: list[str],
712
+ start_date: str,
713
+ end_date: str,
714
+ tz: str = None,
715
+ agg: str = None,
716
+ agg_func: str = None,
717
+ ) -> DataFrame:
718
+ """
719
+ Get time series data for multiple curves in a single request.
720
+
721
+ This is much more efficient than calling get_actual() multiple times
722
+ as it makes a single HTTP request and ClickHouse query.
723
+
724
+ Parameters:
725
+ -----------
726
+ curve_names : list of str
727
+ List of curve names to fetch
728
+ start_date : str
729
+ Start date in format 'YYYY-MM-DD'
730
+ end_date : str
731
+ End date in format 'YYYY-MM-DD'
732
+ tz : str, optional
733
+ Output timezone (e.g., 'Europe/Oslo', 'CET'). Default: UTC.
734
+ agg : str, optional
735
+ Aggregation period: 'hourly', 'daily', 'weekly', 'monthly', 'quarterly', 'yearly'.
736
+ agg_func : str, optional
737
+ Aggregation function: 'avg', 'sum', 'min', 'max'. Default: 'avg'.
738
+
739
+ Returns:
740
+ --------
741
+ DataFrame with timestamp column/index and one column per curve.
742
+ Returns polars DataFrame if use_polars=True, otherwise pandas DataFrame.
743
+ """
744
+ if not curve_names:
745
+ raise VoltClientError("curve_names cannot be empty")
746
+
747
+ # Get curve IDs for all names
748
+ curve_ids = []
749
+ name_to_id = {}
750
+ for name in curve_names:
751
+ name_lower = name.lower()
752
+ curve_id = self.get_curve_id(name_lower)
753
+ curve_ids.append(curve_id)
754
+ name_to_id[curve_id] = name_lower
755
+
756
+ params = {
757
+ "curve_ids": ",".join(str(cid) for cid in curve_ids),
758
+ "start_date": start_date,
759
+ "end_date": end_date,
760
+ }
761
+ if tz:
762
+ params["tz"] = tz
763
+ if agg:
764
+ params["agg"] = agg
765
+ if agg_func:
766
+ params["agg_func"] = agg_func
767
+
768
+ data = self._request("GET", "/data", params=params)
769
+
770
+ curves_data = data.get("curves", [])
771
+ if not curves_data:
772
+ raise VoltEmptyDataError(f"No data for curves between {start_date} and {end_date}")
773
+
774
+ # Always use polars internally for processing
775
+ dfs = []
776
+ for curve in curves_data:
777
+ curve_id = curve["curve_id"]
778
+ curve_name = name_to_id.get(curve_id, f"curve_{curve_id}")
779
+
780
+ if curve.get("data"):
781
+ df = pl.DataFrame(curve["data"])
782
+ target_tz = tz or "UTC"
783
+ df = df.with_columns(
784
+ pl.col("ts")
785
+ .str.to_datetime(time_unit="us", time_zone="UTC")
786
+ .dt.convert_time_zone(target_tz)
787
+ )
788
+ df = df.rename({"value": curve_name})
789
+ df = df.select(["ts", curve_name])
790
+ dfs.append(df)
791
+
792
+ if not dfs:
793
+ raise VoltEmptyDataError(f"No data for curves between {start_date} and {end_date}")
794
+
795
+ # Join all DataFrames on timestamp using polars (much faster than pandas)
796
+ result = dfs[0]
797
+ for df in dfs[1:]:
798
+ result = result.join(df, on="ts", how="full", coalesce=True)
799
+
800
+ result = result.sort("ts")
801
+ result = result.unique(subset=["ts"], keep="last")
802
+
803
+ logger.info(
804
+ f"Extracted bulk data for {len(curve_names)} curves: "
805
+ f"{result['ts'][0]} to {result['ts'][-1]}, {len(result)} rows"
806
+ )
807
+
808
+ return self._to_output(result)
809
+
810
+ def get_areas(self) -> list[str]:
811
+ """Get list of areas the user has access to."""
812
+ data = self._request("GET", "/areas")
813
+ return data.get("areas", [])
814
+
815
+ def get_groups(self) -> list[str]:
816
+ """Get list of groups the user has access to."""
817
+ data = self._request("GET", "/groups")
818
+ return data.get("groups", [])
819
+
820
+ def me(self) -> dict:
821
+ """Get current user information."""
822
+ return self._request("GET", "/me")
823
+
824
+ def my_rules(self) -> dict:
825
+ """Get current user's permission rules."""
826
+ return self._request("GET", "/me/rules")
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: volt-client
3
+ Version: 1.0.0
4
+ Summary: Python client for Volt Power Analytics API - Access energy market time series data
5
+ Author-email: Volt Power Analytics <support@voltanalytics.no>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://voltpoweranalytics.com
8
+ Project-URL: Documentation, https://github.com/Volt-Power-Analytics/volt-api/tree/main/docs
9
+ Project-URL: Repository, https://github.com/Volt-Power-Analytics/volt-api
10
+ Keywords: energy,api,time-series,power,market,data
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Scientific/Engineering
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: polars>=0.20.0
21
+ Requires-Dist: requests>=2.28.0
22
+ Requires-Dist: orjson>=3.9.0
23
+ Provides-Extra: pandas
24
+ Requires-Dist: pandas>=2.0.0; extra == "pandas"
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7.4.0; extra == "dev"
27
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
28
+
29
+ # Volt Client
30
+
31
+ Python client for the Volt Power Analytics API - Access energy market time series data.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install volt-client
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ```python
42
+ import os
43
+ from volt_client import VoltClient
44
+
45
+ # Initialize with your API key
46
+ client = VoltClient(api_key=os.environ["VOLT_API_KEY"])
47
+
48
+ # Get spot prices
49
+ df = client.get_actual(
50
+ "price spot no1 nordpool eur mwh min60 actual",
51
+ start_date="2024-01-01",
52
+ end_date="2024-12-31"
53
+ )
54
+ print(df.head())
55
+ ```
56
+
57
+ ## Features
58
+
59
+ - Simple Python interface for Volt Power Analytics API
60
+ - Support for historical data, forecasts, and forward curves
61
+ - Bulk queries for efficient data retrieval
62
+ - Timezone conversion and server-side aggregation
63
+ - Returns pandas DataFrames (or polars for better performance)
64
+
65
+ ## Usage Examples
66
+
67
+ ### Historical Data
68
+
69
+ ```python
70
+ # Single curve
71
+ df = client.get_actual(
72
+ "price spot no1 nordpool eur mwh min60 actual",
73
+ start_date="2024-01-01",
74
+ end_date="2024-12-31",
75
+ tz="Europe/Oslo",
76
+ agg="daily",
77
+ agg_func="avg"
78
+ )
79
+
80
+ # Multiple curves (bulk query - much faster)
81
+ df = client.get_actual(
82
+ ["price spot no1...", "price spot no2...", "price spot no3..."],
83
+ start_date="2024-01-01",
84
+ end_date="2024-12-31"
85
+ )
86
+ ```
87
+
88
+ ### Forecast Data
89
+
90
+ ```python
91
+ df = client.get_fcast(
92
+ "price no1 volt emps mid-term fcast",
93
+ start_date="2025-01-01",
94
+ end_date="2025-12-31",
95
+ freq="weekly" # Returns percentiles: avg, p10, p25, p50, p75, p90
96
+ )
97
+ ```
98
+
99
+ ### Forward Curves
100
+
101
+ ```python
102
+ df = client.get_closing("price future no1 nasdaq eur mwh close")
103
+ ```
104
+
105
+ ### Performance Tips
106
+
107
+ For large datasets, use polars mode (10x faster):
108
+
109
+ ```python
110
+ client = VoltClient(api_key="...", use_polars=True)
111
+ df = client.get_actual(...) # Returns polars DataFrame
112
+ ```
113
+
114
+ ## API Reference
115
+
116
+ | Method | Description |
117
+ |--------|-------------|
118
+ | `get_actual(curve, start, end)` | Get historical/actual data |
119
+ | `get_fcast(curve, start, end)` | Get forecast data |
120
+ | `get_closing(curve)` | Get forward curve prices |
121
+ | `search(area=None)` | Search available curves |
122
+ | `get_areas()` | List accessible areas |
123
+ | `get_groups()` | List accessible groups |
124
+
125
+ ## Documentation
126
+
127
+ Full documentation: https://github.com/Volt-Power-Analytics/volt-api/tree/main/docs
128
+
129
+ ## Support
130
+
131
+ Contact: support@voltanalytics.no
@@ -0,0 +1,6 @@
1
+ volt_client/__init__.py,sha256=Q8uuwfiSqVyDsYOELc0nts1A_StcmdZVcp1tv7KsfSY,605
2
+ volt_client/client.py,sha256=4he1uSL1NsCqZjWGJqV1Tme0YVCKRafn9hSJ2f33REs,28408
3
+ volt_client-1.0.0.dist-info/METADATA,sha256=rMwKnKyyYmx1ShO1lc3AxiHis8ZJJPdJB8bKiepXqXM,3470
4
+ volt_client-1.0.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
5
+ volt_client-1.0.0.dist-info/top_level.txt,sha256=BWXHOWfVwSZW7GgLwFjWUEgeFmPWCs3XK8B1DjNNtYs,12
6
+ volt_client-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ volt_client