dataquery-sdk 0.0.7__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.
dataquery/models.py ADDED
@@ -0,0 +1,1303 @@
1
+ """
2
+ Data models for the DATAQUERY SDK based on the OpenAPI specification.
3
+ """
4
+
5
+ from datetime import date, datetime, timedelta
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any, Dict, List, Optional, Union, cast
9
+
10
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
11
+
12
+
13
+ class DownloadStatus(str, Enum):
14
+ """Status of a download operation."""
15
+
16
+ PENDING = "pending"
17
+ DOWNLOADING = "downloading"
18
+ COMPLETED = "completed"
19
+ FAILED = "failed"
20
+ CANCELLED = "cancelled"
21
+
22
+
23
+ class TokenStatus(str, Enum):
24
+ """Status of an OAuth token."""
25
+
26
+ VALID = "valid"
27
+ EXPIRED = "expired"
28
+ REFRESHING = "refreshing"
29
+ INVALID = "invalid"
30
+
31
+
32
+ class ClientConfig(BaseModel):
33
+ """Configuration for the DATAQUERY client."""
34
+
35
+ # API configuration
36
+ base_url: str = Field(
37
+ default="https://api-developer.jpmorgan.com",
38
+ description="Base URL of the DATAQUERY API",
39
+ )
40
+ context_path: Optional[str] = Field(
41
+ default="/research/dataquery-authe/api/v2", description="API context path"
42
+ )
43
+ api_version: str = Field(default="2.0.0", description="API version")
44
+ # Optional separate host for file endpoints
45
+ files_base_url: Optional[str] = Field(
46
+ default="https://api-strm-gw01.jpmchase.com",
47
+ description="Separate base URL for file endpoints",
48
+ )
49
+ files_context_path: Optional[str] = Field(
50
+ default="/research/dataquery-authe/api/v2",
51
+ description="Context path for the files host",
52
+ )
53
+
54
+ # OAuth configuration
55
+ oauth_enabled: bool = Field(default=True, description="Enable OAuth authentication")
56
+ oauth_token_url: Optional[str] = Field(
57
+ default="https://authe.jpmorgan.com/as/token.oauth2",
58
+ description="OAuth token endpoint URL",
59
+ )
60
+ client_id: Optional[str] = Field(default=None, description="OAuth client ID")
61
+ client_secret: Optional[str] = Field(
62
+ default=None, description="OAuth client secret"
63
+ )
64
+ # scope removed
65
+ aud: Optional[str] = Field(
66
+ default="JPMC:URI:RS-06785-DataQueryExternalApi-PROD",
67
+ description="OAuth audience (aud)",
68
+ )
69
+ grant_type: str = Field(
70
+ default="client_credentials", description="OAuth grant type"
71
+ )
72
+
73
+ # Bearer token configuration
74
+ bearer_token: Optional[str] = Field(
75
+ default=None, description="Bearer token for API access"
76
+ )
77
+ token_refresh_threshold: int = Field(
78
+ default=300, description="Seconds before expiry to refresh token"
79
+ )
80
+
81
+ # HTTP configuration
82
+ timeout: float = Field(
83
+ default=600.0, description="Default request timeout in seconds"
84
+ )
85
+ max_retries: int = Field(default=3, description="Maximum retry attempts")
86
+ retry_delay: float = Field(
87
+ default=1.0, description="Delay between retries in seconds"
88
+ )
89
+
90
+ # Connection pooling
91
+ pool_connections: int = Field(default=10, description="Number of connection pools")
92
+ pool_maxsize: int = Field(default=20, description="Maximum connections per pool")
93
+
94
+ # Rate limiting
95
+ requests_per_minute: int = Field(
96
+ default=100, description="Requests per minute limit"
97
+ )
98
+ burst_capacity: int = Field(
99
+ default=20, description="Burst capacity for rate limiting"
100
+ )
101
+
102
+ # Proxy configuration
103
+ proxy_enabled: bool = Field(default=False, description="Enable proxy support")
104
+ proxy_url: Optional[str] = Field(
105
+ default="",
106
+ description="Proxy URL (e.g., http://proxy:8080, socks5://proxy:1080)",
107
+ )
108
+ proxy_username: Optional[str] = Field(
109
+ default="", description="Proxy username for authentication"
110
+ )
111
+ proxy_password: Optional[str] = Field(
112
+ default="", description="Proxy password for authentication"
113
+ )
114
+ proxy_verify_ssl: bool = Field(
115
+ default=True, description="Verify SSL certificates for proxy connections"
116
+ )
117
+
118
+ # Logging
119
+ log_level: str = Field(default="INFO", description="Logging level")
120
+ enable_debug_logging: bool = Field(
121
+ default=False, description="Enable debug logging"
122
+ )
123
+
124
+ # Download configuration
125
+ download_dir: str = Field(
126
+ default="./downloads", description="Base download directory"
127
+ )
128
+ create_directories: bool = Field(
129
+ default=True, description="Create parent directories if they don't exist"
130
+ )
131
+ overwrite_existing: bool = Field(
132
+ default=False, description="Overwrite existing files"
133
+ )
134
+ token_storage_dir: Optional[str] = Field(
135
+ default=None,
136
+ description="Optional directory to store OAuth tokens; defaults to '<download_dir>/.tokens'",
137
+ )
138
+
139
+ # Batch Download Configuration
140
+ max_concurrent_downloads: int = Field(
141
+ default=5, description="Maximum concurrent downloads"
142
+ )
143
+ batch_size: int = Field(default=10, description="Batch size for operations")
144
+ retry_failed: bool = Field(default=True, description="Retry failed downloads")
145
+ max_retry_attempts: int = Field(
146
+ default=2, description="Maximum retry attempts for failed downloads"
147
+ )
148
+ create_date_folders: bool = Field(
149
+ default=True, description="Create date-based folders"
150
+ )
151
+ preserve_path_structure: bool = Field(
152
+ default=True, description="Preserve original path structure"
153
+ )
154
+ flatten_structure: bool = Field(
155
+ default=False, description="Flatten directory structure"
156
+ )
157
+ show_batch_progress: bool = Field(default=True, description="Show batch progress")
158
+ show_individual_progress: bool = Field(
159
+ default=True, description="Show individual file progress"
160
+ )
161
+ continue_on_error: bool = Field(
162
+ default=True, description="Continue processing on errors"
163
+ )
164
+ log_errors: bool = Field(default=True, description="Log errors")
165
+ save_error_log: bool = Field(default=True, description="Save error log to file")
166
+ use_async_downloads: bool = Field(default=True, description="Use async downloads")
167
+ chunk_size: int = Field(default=8192, description="Download chunk size in bytes")
168
+
169
+ # Download Options
170
+ enable_range_requests: bool = Field(
171
+ default=True, description="Enable HTTP range requests"
172
+ )
173
+ show_progress: bool = Field(default=True, description="Show download progress")
174
+
175
+ # Workflow Configuration
176
+ workflow_dir: str = Field(
177
+ default="workflow", description="Workflow files subdirectory"
178
+ )
179
+ groups_dir: str = Field(default="groups", description="Groups files subdirectory")
180
+ availability_dir: str = Field(
181
+ default="availability", description="Availability files subdirectory"
182
+ )
183
+ default_dir: str = Field(default="files", description="Default files subdirectory")
184
+
185
+ # Security
186
+ mask_secrets: bool = Field(default=True, description="Mask secrets in logs")
187
+ token_storage_enabled: bool = Field(
188
+ default=False, description="Enable token storage"
189
+ )
190
+
191
+ model_config = ConfigDict(
192
+ extra="allow", populate_by_name=True
193
+ ) # Allow extra fields from API and populate by both alias and name
194
+
195
+ @field_validator("base_url")
196
+ @classmethod
197
+ def validate_base_url(cls, v):
198
+ # Allow invalid URLs for testing - validation happens in client
199
+ if v and v.startswith(("http://", "https://")):
200
+ return v.rstrip("/")
201
+ return v # Allow invalid URLs to be created
202
+
203
+ @field_validator("context_path")
204
+ @classmethod
205
+ def validate_context_path(cls, v):
206
+ if v is not None:
207
+ # Ensure context path starts with / and doesn't end with /
208
+ if not v.startswith("/"):
209
+ v = "/" + v
210
+ return v.rstrip("/")
211
+ return v
212
+
213
+ @field_validator("files_base_url")
214
+ @classmethod
215
+ def validate_files_base_url(cls, v):
216
+ if v:
217
+ if v.startswith(("http://", "https://")):
218
+ return v.rstrip("/")
219
+ return v
220
+
221
+ @field_validator("files_context_path")
222
+ @classmethod
223
+ def validate_files_context_path(cls, v):
224
+ if v is not None and v != "":
225
+ if not v.startswith("/"):
226
+ v = "/" + v
227
+ return v.rstrip("/")
228
+ return v
229
+
230
+ @field_validator("proxy_url")
231
+ @classmethod
232
+ def validate_proxy_url(cls, v):
233
+ # Allow None or empty string without validation
234
+ if v is None or v == "":
235
+ return v
236
+ if v is not None:
237
+ # Validate proxy URL format
238
+ if not v.startswith(("http://", "https://", "socks4://", "socks5://")):
239
+ raise ValueError(
240
+ "Proxy URL must start with http://, https://, socks4://, or socks5://"
241
+ )
242
+ return v
243
+
244
+ @field_validator("log_level")
245
+ @classmethod
246
+ def validate_log_level(cls, v):
247
+ valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
248
+ if v.upper() not in valid_levels:
249
+ raise ValueError(f"Log level must be one of: {valid_levels}")
250
+ return v.upper()
251
+
252
+ @field_validator("oauth_token_url")
253
+ @classmethod
254
+ def validate_oauth_token_url(cls, v, info):
255
+ if info.data.get("oauth_enabled", True) and not v:
256
+ # Auto-generate token URL from base URL
257
+ base_url = info.data.get("base_url", "")
258
+ if base_url:
259
+ return f"{base_url}/oauth/token"
260
+ return v
261
+
262
+ @property
263
+ def has_oauth_credentials(self) -> bool:
264
+ """Check if OAuth credentials are configured."""
265
+ return (
266
+ self.oauth_enabled
267
+ and self.client_id is not None
268
+ and self.client_secret is not None
269
+ )
270
+
271
+ @property
272
+ def has_bearer_token(self) -> bool:
273
+ """Check if bearer token is configured."""
274
+ return self.bearer_token is not None and self.bearer_token.strip() != ""
275
+
276
+ @property
277
+ def has_proxy_credentials(self) -> bool:
278
+ """Check if proxy credentials are configured."""
279
+ return self.proxy_username is not None and self.proxy_password is not None
280
+
281
+ @property
282
+ def api_base_url(self) -> str:
283
+ """Get the complete API base URL including context path."""
284
+ if self.context_path:
285
+ return f"{self.base_url}{self.context_path}"
286
+ return self.base_url
287
+
288
+ @property
289
+ def files_api_base_url(self) -> Optional[str]:
290
+ """Get the complete files API base URL if configured, including context path."""
291
+ if not self.files_base_url:
292
+ return None
293
+ if self.files_context_path:
294
+ return f"{self.files_base_url}{self.files_context_path}"
295
+ return self.files_base_url
296
+
297
+
298
+ class OAuthToken(BaseModel):
299
+ """OAuth token information."""
300
+
301
+ access_token: str = Field(..., description="Access token")
302
+ token_type: str = Field(default="Bearer", description="Token type")
303
+ expires_in: Optional[int] = Field(
304
+ default=None, description="Token expiry time in seconds"
305
+ )
306
+ # scope removed
307
+ refresh_token: Optional[str] = Field(default=None, description="Refresh token")
308
+
309
+ # Internal tracking
310
+ issued_at: Optional[datetime] = Field(default=None, description="Token issue time")
311
+
312
+ model_config = ConfigDict(
313
+ extra="allow", populate_by_name=True
314
+ ) # Allow extra fields from API and populate by both alias and name
315
+
316
+ @property
317
+ def expires_at(self) -> Optional[datetime]:
318
+ """Get token expiry time."""
319
+ if self.expires_in and self.issued_at:
320
+ return self.issued_at + timedelta(seconds=self.expires_in)
321
+ return None
322
+
323
+ @property
324
+ def is_expired(self) -> bool:
325
+ """Check if token is expired."""
326
+ if not self.expires_at:
327
+ return False
328
+ return datetime.now() >= self.expires_at
329
+
330
+ def is_expiring_soon(self, threshold: int = 300) -> bool:
331
+ """Check if token is expiring soon."""
332
+ if not self.expires_at:
333
+ return False
334
+ remaining_time = (self.expires_at - datetime.now()).total_seconds()
335
+ # If threshold is larger than the original token lifetime, not expiring soon
336
+ if self.expires_in and threshold > self.expires_in:
337
+ return False
338
+ return remaining_time < threshold
339
+
340
+ def to_authorization_header(self) -> str:
341
+ """Get authorization header value."""
342
+ return f"{self.token_type} {self.access_token}"
343
+
344
+ @property
345
+ def status(self) -> TokenStatus:
346
+ """Get the current status, checking if expired."""
347
+ if self.is_expired:
348
+ return TokenStatus.EXPIRED
349
+ return TokenStatus.VALID
350
+
351
+
352
+ class TokenRequest(BaseModel):
353
+ """OAuth token request parameters."""
354
+
355
+ grant_type: str = Field("client_credentials", description="OAuth grant type")
356
+ client_id: Optional[str] = Field(None, description="OAuth client ID")
357
+ client_secret: Optional[str] = Field(None, description="OAuth client secret")
358
+ # scope removed
359
+ aud: Optional[str] = Field(None, description="OAuth audience (aud)")
360
+
361
+ model_config = ConfigDict(
362
+ extra="allow", populate_by_name=True
363
+ ) # Allow extra fields from API and populate by both alias and name
364
+
365
+ def to_dict(self) -> Dict[str, str]:
366
+ """Convert to dictionary for request."""
367
+ data = {
368
+ "grant_type": self.grant_type,
369
+ }
370
+ if self.client_id:
371
+ data["client_id"] = cast(str, self.client_id)
372
+ if self.client_secret:
373
+ data["client_secret"] = cast(str, self.client_secret)
374
+ # scope removed
375
+ if getattr(self, "aud", None):
376
+ # Send audience as 'aud' per provider requirement
377
+ data["aud"] = cast(str, self.aud)
378
+ return data
379
+
380
+
381
+ class TokenResponse(BaseModel):
382
+ """OAuth token response."""
383
+
384
+ access_token: str = Field(..., description="Access token")
385
+ token_type: Optional[str] = Field(default=None, description="Token type")
386
+ expires_in: Optional[int] = Field(
387
+ default=None, description="Token expiry time in seconds"
388
+ )
389
+ # scope removed
390
+ refresh_token: Optional[str] = Field(default=None, description="Refresh token")
391
+
392
+ model_config = ConfigDict(
393
+ extra="allow", populate_by_name=True
394
+ ) # Allow extra fields from API and populate by both alias and name
395
+
396
+ def to_oauth_token(self) -> "OAuthToken":
397
+ """Convert to OAuthToken."""
398
+ return OAuthToken(
399
+ access_token=self.access_token,
400
+ token_type=(self.token_type or "Bearer"),
401
+ expires_in=self.expires_in,
402
+ # scope removed
403
+ refresh_token=self.refresh_token,
404
+ issued_at=datetime.now(), # Set issued_at when converting
405
+ # status is computed property
406
+ )
407
+
408
+
409
+ class FileMetadata(BaseModel):
410
+ """File metadata information."""
411
+
412
+ frequency: Optional[str] = Field(None, description="Data frequency")
413
+ regions: Optional[List[str]] = Field(
414
+ None, description="List of regions covered by the data"
415
+ )
416
+ history_start_date: Optional[str] = Field(
417
+ None, alias="history-start-date", description="History start date"
418
+ )
419
+ median_file_size_mb: Optional[str] = Field(
420
+ None, alias="median-file-size-mb", description="Median file size in MB"
421
+ )
422
+ publication_time: Optional[str] = Field(
423
+ None, alias="publication-time", description="Publication time"
424
+ )
425
+ data_lag: Optional[str] = Field(
426
+ None, alias="data-lag", description="Data lag information"
427
+ )
428
+
429
+ model_config = ConfigDict(
430
+ extra="allow", populate_by_name=True
431
+ ) # Allow extra fields from API and populate by both alias and name
432
+
433
+
434
+ class SchemaColumn(BaseModel):
435
+ """Schema column information."""
436
+
437
+ column_id: str = Field(..., alias="columnId", description="Column identifier")
438
+ column_name: str = Field(..., alias="columnName", description="Column name")
439
+ column_description: Optional[str] = Field(
440
+ None, alias="columnDescription", description="Column description"
441
+ )
442
+ data_type: str = Field(..., alias="dataType", description="Data type")
443
+
444
+ model_config = ConfigDict(
445
+ extra="allow", populate_by_name=True
446
+ ) # Allow extra fields from API and populate by both alias and name
447
+
448
+
449
+ class DateRange(BaseModel):
450
+ """Date range information."""
451
+
452
+ earliest: str = Field(..., description="Earliest date in YYYYMMDD format")
453
+ latest: str = Field(..., description="Latest date in YYYYMMDD format")
454
+
455
+ model_config = ConfigDict(
456
+ extra="allow", populate_by_name=True
457
+ ) # Allow extra fields from API and populate by both alias and name
458
+
459
+ def get_earliest_date(self) -> Optional[date]:
460
+ """Get earliest date as date object."""
461
+ try:
462
+ return datetime.strptime(self.earliest, "%Y%m%d").date()
463
+ except ValueError:
464
+ return None
465
+
466
+ def get_latest_date(self) -> Optional[date]:
467
+ """Get latest date as date object."""
468
+ try:
469
+ return datetime.strptime(self.latest, "%Y%m%d").date()
470
+ except ValueError:
471
+ return None
472
+
473
+
474
+ class Group(BaseModel):
475
+ """Model representing a data group based on the new OpenAPI spec."""
476
+
477
+ item: Optional[int] = Field(None, description="Item number")
478
+ group_id: Optional[str] = Field(
479
+ None, alias="group-id", description="Unique group identifier"
480
+ )
481
+ group_name: Optional[str] = Field(
482
+ None, alias="group-name", description="Display name of the group"
483
+ )
484
+
485
+ description: Optional[str] = Field(None, description="Group description")
486
+ provider: Optional[str] = Field(None, description="Data provider")
487
+ premium: Optional[bool] = Field(None, description="Whether this is a premium group")
488
+ population: Optional[Dict[str, Any]] = Field(
489
+ None, description="Population information"
490
+ )
491
+ attributes: Optional[List[Dict[str, Any]]] = Field(
492
+ None, description="Group attributes"
493
+ )
494
+ file_groups: Optional[int] = Field(
495
+ None, alias="file-groups", description="Number of file groups"
496
+ )
497
+
498
+ model_config = ConfigDict(
499
+ extra="allow", populate_by_name=True
500
+ ) # Allow extra fields from API and populate by both alias and name
501
+
502
+ # associated_file_count removed; use file_groups instead
503
+
504
+
505
+ class Link(BaseModel):
506
+ """Pagination link model."""
507
+
508
+ self: Optional[str] = Field(None, description="Current page URL")
509
+ next: Optional[str] = Field(None, description="Next page URL")
510
+
511
+ model_config = ConfigDict(
512
+ extra="allow", populate_by_name=True
513
+ ) # Allow extra fields from API and populate by both alias and name
514
+
515
+
516
+ class GroupList(BaseModel):
517
+ """Response model for listing groups with pagination support."""
518
+
519
+ groups: List[Group] = Field(..., description="List of groups")
520
+ links: Optional[List[Link]] = Field(None, description="Pagination links")
521
+
522
+ model_config = ConfigDict(
523
+ extra="allow", populate_by_name=True
524
+ ) # Allow extra fields from API and populate by both alias and name
525
+
526
+ def get_next_link(self) -> Optional[str]:
527
+ """Get the next page link URL."""
528
+ if not self.links:
529
+ return None
530
+ for link in self.links:
531
+ if link.next:
532
+ return link.next
533
+ return None
534
+
535
+ def has_next_page(self) -> bool:
536
+ """Check if there's a next page available."""
537
+ return self.get_next_link() is not None
538
+
539
+
540
+ class FileInfo(BaseModel):
541
+ """Model representing file information based on the DataQuery spec.
542
+
543
+ Only `file_group_id` is supported as the identifier.
544
+ """
545
+
546
+ # Primary identifier
547
+ file_group_id: Optional[str] = Field(
548
+ None, alias="file-group-id", description="Unique file group identifier"
549
+ )
550
+
551
+ # Additional info
552
+ description: Optional[str] = Field(None, description="File description")
553
+ file_type: Optional[List[str]] = Field(
554
+ None, alias="file-type", description="Type(s) of the file"
555
+ )
556
+ # legacy fields removed
557
+ metadata: Optional[FileMetadata] = Field(None, description="File metadata")
558
+ file_schema: Optional[List[SchemaColumn]] = Field(
559
+ None, description="File schema", alias="schema"
560
+ )
561
+
562
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
563
+
564
+ @field_validator("file_type", mode="before")
565
+ @classmethod
566
+ def normalize_file_type(cls, v):
567
+ """Accept string or list; normalize to list[str]."""
568
+ if v is None:
569
+ return None
570
+ if isinstance(v, str):
571
+ return [v]
572
+ if isinstance(v, list):
573
+ return [str(x) for x in v if x is not None]
574
+ # Fallback: coerce to string and wrap
575
+ return [str(v)]
576
+
577
+ # inputs must use 'file-group-id'
578
+
579
+ def get_file_extension(self) -> str:
580
+ """Get file extension based on file type."""
581
+ if not self.file_type:
582
+ return ".bin"
583
+ types_lower = [t.lower() for t in (self.file_type or [])]
584
+ if "parquet" in types_lower:
585
+ return ".parquet"
586
+ elif "csv" in types_lower:
587
+ return ".csv"
588
+ elif "json" in types_lower:
589
+ return ".json"
590
+ else:
591
+ return ".bin"
592
+
593
+ def is_parquet(self) -> bool:
594
+ """Check if file is Parquet format."""
595
+ return bool(self.file_type) and any(
596
+ (t or "").lower() == "parquet" for t in (self.file_type or [])
597
+ )
598
+
599
+ def is_csv(self) -> bool:
600
+ """Check if file is CSV format."""
601
+ return bool(self.file_type) and any(
602
+ (t or "").lower() == "csv" for t in (self.file_type or [])
603
+ )
604
+
605
+ def is_json(self) -> bool:
606
+ """Check if file is JSON format."""
607
+ return bool(self.file_type) and any(
608
+ (t or "").lower() == "json" for t in (self.file_type or [])
609
+ )
610
+
611
+
612
+ class FileList(BaseModel):
613
+ """Response model for listing files."""
614
+
615
+ group_id: str = Field(..., alias="group-id", description="Group identifier")
616
+ file_group_ids: List[FileInfo] = Field(
617
+ ..., alias="file-group-ids", description="List of file information"
618
+ )
619
+
620
+ model_config = ConfigDict(
621
+ extra="allow", populate_by_name=True
622
+ ) # Allow extra fields from API and populate by both alias and name
623
+
624
+ @property
625
+ def file_count(self) -> int:
626
+ """Get total number of files."""
627
+ return len(self.file_group_ids)
628
+
629
+ @property
630
+ def file_types(self) -> List[str]:
631
+ """Get list of file types."""
632
+ types: List[str] = []
633
+ for f in self.file_group_ids:
634
+ ft = getattr(f, "file_type", None)
635
+ if ft:
636
+ types.extend([t for t in ft if isinstance(t, str)])
637
+ return list(set(types))
638
+
639
+ def get_files_by_type(self, file_type: str) -> List[FileInfo]:
640
+ """Get files by type."""
641
+ target = (file_type or "").lower()
642
+ result: List[FileInfo] = []
643
+ for f in self.file_group_ids:
644
+ ft = getattr(f, "file_type", None)
645
+ if ft and any((t or "").lower() == target for t in ft):
646
+ result.append(f)
647
+ return result
648
+
649
+ def get_date_range(self) -> Optional[DateRange]:
650
+ """Get overall date range from metadata if available."""
651
+ # This would need to be implemented based on actual metadata structure
652
+ # For now, return None as the new spec doesn't show date range in file list
653
+ return None
654
+
655
+
656
+ class AvailabilityInfo(BaseModel):
657
+ """Model representing file availability information."""
658
+
659
+ group_id: Optional[str] = Field(
660
+ None, alias="group-id", description="Group identifier"
661
+ )
662
+ file_group_id: Optional[str] = Field(
663
+ None, alias="file-group-id", description="File group identifier"
664
+ )
665
+ file_date: str = Field(
666
+ ..., alias="file-datetime", description="File date in YYYYMMDD format"
667
+ )
668
+ is_available: bool = Field(
669
+ ..., alias="is-available", description="Whether the file is available"
670
+ )
671
+ file_name: Optional[str] = Field(
672
+ None, alias="file-name", description="Name of the file"
673
+ )
674
+ first_created_on: Optional[str] = Field(
675
+ None, alias="first-created-on", description="First creation timestamp"
676
+ )
677
+ last_modified: Optional[str] = Field(
678
+ None, alias="last-modified", description="Last modification timestamp"
679
+ )
680
+
681
+ model_config = ConfigDict(
682
+ extra="allow", populate_by_name=True
683
+ ) # Allow extra fields from API and populate by both alias and name
684
+
685
+ def get_file_date(self) -> Optional[date]:
686
+ """Get file date as date object."""
687
+ try:
688
+ return datetime.strptime(self.file_date, "%Y%m%d").date()
689
+ except ValueError:
690
+ return None
691
+
692
+
693
+ class AvailabilityResponse(BaseModel):
694
+ """Deprecated: Use AvailabilityInfo directly."""
695
+
696
+ group_id: Optional[str] = Field(None, alias="group-id")
697
+ file_group_id: Optional[str] = Field(None, alias="file-group-id")
698
+ date_range: Optional[DateRange] = Field(None, alias="date-range")
699
+ availability: List[AvailabilityInfo] = Field(default_factory=list)
700
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
701
+
702
+ @property
703
+ def available_files(self) -> List[AvailabilityInfo]:
704
+ return [f for f in self.availability if f.is_available]
705
+
706
+ @property
707
+ def unavailable_files(self) -> List[AvailabilityInfo]:
708
+ return [f for f in self.availability if not f.is_available]
709
+
710
+ @property
711
+ def availability_rate(self) -> float:
712
+ if not self.availability:
713
+ return 0.0
714
+ return (len(self.available_files) / len(self.availability)) * 100
715
+
716
+
717
+ class AvailableFilesResponse(BaseModel):
718
+ """Response model for listing available files by date range."""
719
+
720
+ group_id: str = Field(..., alias="group-id", description="Group identifier")
721
+ file_group_id: Optional[str] = Field(
722
+ None, alias="file-group-id", description="File identifier"
723
+ )
724
+ start_date: Optional[str] = Field(
725
+ None, alias="start-date", description="Start date"
726
+ )
727
+ end_date: Optional[str] = Field(None, alias="end-date", description="End date")
728
+ available_files: List[Dict[str, Any]] = Field(
729
+ ..., alias="available-files", description="List of available files"
730
+ )
731
+ summary: Optional[Dict[str, Any]] = Field(
732
+ None, alias="summary", description="Summary of available files"
733
+ )
734
+
735
+ model_config = ConfigDict(
736
+ extra="allow", populate_by_name=True
737
+ ) # Allow extra fields from API and populate by both alias and name
738
+
739
+
740
+ class DownloadProgress(BaseModel):
741
+ """Model representing download progress."""
742
+
743
+ file_group_id: str = Field(..., description="File identifier")
744
+ bytes_downloaded: int = Field(default=0, description="Number of bytes downloaded")
745
+ total_bytes: int = Field(default=0, description="Total number of bytes to download")
746
+ percentage: float = Field(default=0.0, description="Download percentage (0-100)")
747
+ speed_bps: float = Field(
748
+ default=0.0, description="Download speed in bytes per second"
749
+ )
750
+ eta_seconds: Optional[float] = Field(
751
+ default=None, description="Estimated time to completion in seconds"
752
+ )
753
+ start_time: datetime = Field(
754
+ default_factory=datetime.now, description="Download start time"
755
+ )
756
+ last_update: datetime = Field(
757
+ default_factory=datetime.now, description="Last progress update time"
758
+ )
759
+ status: DownloadStatus = Field(
760
+ default=DownloadStatus.PENDING, description="Download status"
761
+ )
762
+
763
+ model_config = ConfigDict(extra="allow") # Allow extra fields from API
764
+
765
+ @property
766
+ def is_complete(self) -> bool:
767
+ """Check if download is complete."""
768
+ return self.percentage >= 100.0
769
+
770
+ @property
771
+ def remaining_bytes(self) -> int:
772
+ """Get remaining bytes to download."""
773
+ return max(0, self.total_bytes - self.bytes_downloaded)
774
+
775
+ def update_progress(self, bytes_downloaded: int, speed_bps: Optional[float] = None):
776
+ """Update download progress."""
777
+ self.bytes_downloaded = bytes_downloaded
778
+ self.percentage = (
779
+ (bytes_downloaded / self.total_bytes) * 100 if self.total_bytes > 0 else 0
780
+ )
781
+
782
+ if speed_bps:
783
+ self.speed_bps = speed_bps
784
+
785
+ self.last_update = datetime.now()
786
+
787
+
788
+ class DownloadOptions(BaseModel):
789
+ """Options for file downloads."""
790
+
791
+ # File options
792
+ destination_path: Optional[Path] = Field(
793
+ default=None, description="Local path to save the file"
794
+ )
795
+ create_directories: bool = Field(
796
+ default=True, description="Create parent directories if they don't exist"
797
+ )
798
+ overwrite_existing: bool = Field(
799
+ default=False, description="Overwrite existing files"
800
+ )
801
+
802
+ # Download options
803
+ chunk_size_setting: int = Field(
804
+ default=8192,
805
+ description="Chunk size for streaming downloads",
806
+ alias="chunk_size",
807
+ )
808
+ max_retries: int = Field(default=3, description="Maximum number of retry attempts")
809
+ retry_delay: float = Field(
810
+ default=1.0, description="Delay between retries in seconds"
811
+ )
812
+ timeout: float = Field(default=600.0, description="Request timeout in seconds")
813
+
814
+ # Range requests
815
+ enable_range_requests: bool = Field(
816
+ default=True, description="Enable HTTP range requests for resumable downloads"
817
+ )
818
+ range_start: Optional[int] = Field(
819
+ default=None, description="Start byte position for range download (0-based)"
820
+ )
821
+ range_end: Optional[int] = Field(
822
+ default=None, description="End byte position for range download (inclusive)"
823
+ )
824
+ range_header: Optional[str] = Field(
825
+ default=None, description="Custom range header (e.g., 'bytes=0-1023')"
826
+ )
827
+
828
+ # Progress tracking
829
+ show_progress: bool = Field(default=True, description="Show download progress")
830
+ progress_callback: Optional[Any] = Field(
831
+ default=None, description="Custom progress callback function"
832
+ )
833
+
834
+ model_config = ConfigDict(extra="allow") # Allow extra fields from API
835
+
836
+ @property
837
+ def overwrite(self) -> bool:
838
+ """Backward compatibility property."""
839
+ # Check if overwrite was passed in extra fields
840
+ extra_overwrite = getattr(self, "__pydantic_extra__", {}).get("overwrite")
841
+ if extra_overwrite is not None:
842
+ return extra_overwrite
843
+ return self.overwrite_existing
844
+
845
+ @property
846
+ def verify_checksum(self) -> bool:
847
+ """Backward compatibility property."""
848
+ # Check if verify_checksum was passed in extra fields
849
+ extra_verify_checksum = getattr(self, "__pydantic_extra__", {}).get(
850
+ "verify_checksum"
851
+ )
852
+ if extra_verify_checksum is not None:
853
+ return extra_verify_checksum
854
+ return False # Default to False for backward compatibility
855
+
856
+ @property
857
+ def chunk_size(self) -> int:
858
+ """Public chunk size value always returning a positive integer."""
859
+ extra_chunk_size = getattr(self, "__pydantic_extra__", {}).get("chunk_size")
860
+ value = (
861
+ extra_chunk_size
862
+ if extra_chunk_size is not None
863
+ else self.chunk_size_setting
864
+ )
865
+ # Safety clamp
866
+ try:
867
+ value_int = int(value)
868
+ except Exception:
869
+ value_int = 8192
870
+ if value_int <= 0:
871
+ value_int = 8192
872
+ if value_int > 1024 * 1024:
873
+ value_int = 1024 * 1024
874
+ return value_int
875
+
876
+ @field_validator("chunk_size_setting")
877
+ @classmethod
878
+ def validate_chunk_size(cls, v):
879
+ if v <= 0:
880
+ raise ValueError("Chunk size must be positive")
881
+ if v > 1024 * 1024: # 1MB
882
+ raise ValueError("Chunk size cannot exceed 1MB")
883
+ return v
884
+
885
+ @field_validator("range_start", "range_end")
886
+ @classmethod
887
+ def validate_range_values(cls, v):
888
+ if v is not None and v < 0:
889
+ raise ValueError("Range values must be non-negative")
890
+ return v
891
+
892
+ @field_validator("range_end")
893
+ @classmethod
894
+ def validate_range_end(cls, v, info):
895
+ if v is not None and "range_start" in info.data:
896
+ range_start = info.data["range_start"]
897
+ if range_start is not None and v <= range_start:
898
+ raise ValueError("Range end must be greater than range start")
899
+ return v
900
+
901
+
902
+ class DownloadResult(BaseModel):
903
+ """Result of a download operation."""
904
+
905
+ file_group_id: str = Field(..., description="File identifier")
906
+ group_id: Optional[str] = Field(None, description="Group identifier")
907
+ local_path: Optional[Path] = Field(
908
+ None, description="Local path where file was saved"
909
+ )
910
+ file_size: Optional[int] = Field(None, description="Size of the downloaded file")
911
+ download_time: Optional[float] = Field(
912
+ None, description="Time taken for download in seconds"
913
+ )
914
+ bytes_downloaded: Optional[int] = Field(None, description="Total bytes downloaded")
915
+ status: DownloadStatus = Field(DownloadStatus.FAILED, description="Download status")
916
+ error_message: Optional[str] = Field(
917
+ None, description="Error message if download failed"
918
+ )
919
+
920
+ # legacy download result fields removed
921
+
922
+ model_config = {"extra": "allow"} # Allow extra fields from API
923
+
924
+ @property
925
+ def speed_mbps(self) -> float:
926
+ """Calculate download speed in MB/s."""
927
+ if not self.download_time or self.download_time <= 0 or not self.file_size:
928
+ return 0.0
929
+ return (self.file_size / (1024 * 1024)) / self.download_time
930
+
931
+
932
+ class Instrument(BaseModel):
933
+ """Model representing an instrument from the DataQuery catalog."""
934
+
935
+ item: int = Field(..., description="Item number")
936
+ instrument_id: str = Field(
937
+ ..., alias="instrument-id", description="Unique instrument identifier"
938
+ )
939
+ instrument_name: str = Field(
940
+ ..., alias="instrument-name", description="Instrument short name"
941
+ )
942
+ country: Optional[str] = Field(None, description="ISO-3166-1 alpha-3 country code")
943
+ currency: Optional[str] = Field(None, description="ISO-4217 currency code")
944
+ instrument_cusip: Optional[str] = Field(
945
+ None, alias="instrument-cusip", description="Instrument CUSIP"
946
+ )
947
+ instrument_isin: Optional[str] = Field(
948
+ None, alias="instrument-isin", description="Instrument ISIN"
949
+ )
950
+
951
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
952
+
953
+
954
+ class Attribute(BaseModel):
955
+ """Model representing an attribute measure for an instrument."""
956
+
957
+ attribute_id: str = Field(
958
+ ..., alias="attribute-id", description="Unique attribute measure identifier"
959
+ )
960
+ attribute_name: str = Field(
961
+ ..., alias="attribute-name", description="Attribute short name"
962
+ )
963
+ expression: str = Field(
964
+ ..., description="Traditional DataQuery time-series identifier"
965
+ )
966
+ label: str = Field(..., description="Name of a time-series data set")
967
+ last_published: Optional[str] = Field(
968
+ None, alias="last-published", description="Date/Time data was last published"
969
+ )
970
+ message: Optional[str] = Field(None, description="Attribute level user message")
971
+ time_series: Optional[List[List[Union[str, float]]]] = Field(
972
+ None, alias="time-series", description="Time series data"
973
+ )
974
+
975
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
976
+
977
+
978
+ class Filter(BaseModel):
979
+ """Model representing a filter dimension."""
980
+
981
+ filter_name: str = Field(
982
+ ..., alias="filter-name", description="Name of a filter dimension"
983
+ )
984
+ description: Optional[str] = Field(
985
+ None, alias="filter-description", description="Description of a filter"
986
+ )
987
+ values: Optional[List[str]] = Field(
988
+ None, description="Valid filter value enumerator"
989
+ )
990
+
991
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
992
+
993
+
994
+ class TimeSeriesDataPoint(BaseModel):
995
+ """Model representing a single time series data point."""
996
+
997
+ date: str = Field(..., description="Date in YYYYMMDD format")
998
+ value: float = Field(..., description="Measurable value")
999
+
1000
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1001
+
1002
+
1003
+ class InstrumentWithAttributes(BaseModel):
1004
+ """Model representing an instrument with its attributes."""
1005
+
1006
+ item: int = Field(..., description="Item number")
1007
+ instrument_id: str = Field(
1008
+ ..., alias="instrument-id", description="Unique instrument identifier"
1009
+ )
1010
+ instrument_name: str = Field(
1011
+ ..., alias="instrument-name", description="Instrument short name"
1012
+ )
1013
+ instrument_cusip: Optional[str] = Field(
1014
+ None, alias="instrument-cusip", description="Instrument CUSIP"
1015
+ )
1016
+ instrument_isin: Optional[str] = Field(
1017
+ None, alias="instrument-isin", description="Instrument ISIN"
1018
+ )
1019
+ group: Optional[Dict[str, str]] = Field(None, description="Group information")
1020
+ attributes: List[Attribute] = Field(..., description="List of attributes")
1021
+
1022
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1023
+
1024
+
1025
+ class InstrumentResponse(BaseModel):
1026
+ """Response model for a single instrument."""
1027
+
1028
+ instrument: Instrument = Field(..., description="Single instrument")
1029
+
1030
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1031
+
1032
+
1033
+ class InstrumentsResponse(BaseModel):
1034
+ """Response model for listing instruments."""
1035
+
1036
+ links: Optional[List[Link]] = Field(None, description="Pagination links")
1037
+ items: int = Field(..., description="Total number of items")
1038
+ page_size: int = Field(
1039
+ ..., alias="page-size", description="Number of items per page"
1040
+ )
1041
+ instruments: List[Instrument] = Field(..., description="List of instruments")
1042
+
1043
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1044
+
1045
+
1046
+ class AttributesResponse(BaseModel):
1047
+ """Response model for listing attributes."""
1048
+
1049
+ links: Optional[List[Link]] = Field(None, description="Pagination links")
1050
+ items: int = Field(..., description="Total number of items")
1051
+ page_size: int = Field(
1052
+ ..., alias="page-size", description="Number of items per page"
1053
+ )
1054
+ instruments: List[InstrumentWithAttributes] = Field(
1055
+ ..., description="List of instruments with attributes"
1056
+ )
1057
+
1058
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1059
+
1060
+
1061
+ class FiltersResponse(BaseModel):
1062
+ """Response model for listing filters."""
1063
+
1064
+ links: Optional[List[Link]] = Field(None, description="Pagination links")
1065
+ items: int = Field(..., description="Total number of items")
1066
+ page_size: int = Field(
1067
+ ..., alias="page-size", description="Number of items per page"
1068
+ )
1069
+ filters: List[Filter] = Field(..., description="List of filters")
1070
+
1071
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1072
+
1073
+
1074
+ class TimeSeriesResponse(BaseModel):
1075
+ """Response model for time series data."""
1076
+
1077
+ links: Optional[List[Link]] = Field(None, description="Pagination links")
1078
+ items: int = Field(..., description="Total number of items")
1079
+ page_size: int = Field(
1080
+ ..., alias="page-size", description="Number of items per page"
1081
+ )
1082
+ instruments: List[InstrumentWithAttributes] = Field(
1083
+ ..., description="List of instruments with time series data"
1084
+ )
1085
+
1086
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1087
+
1088
+
1089
+ class GridDataSeries(BaseModel):
1090
+ """Model representing a grid data series."""
1091
+
1092
+ expr: str = Field(..., description="The expression for the grid data")
1093
+ error_code: Optional[str] = Field(
1094
+ None, alias="errorCode", description="Error code for this specific expression"
1095
+ )
1096
+ error_message: Optional[str] = Field(
1097
+ None,
1098
+ alias="errorMessage",
1099
+ description="Error message for this specific expression",
1100
+ )
1101
+ records: Optional[List[Dict[str, Any]]] = Field(
1102
+ None, description="The grid data records"
1103
+ )
1104
+
1105
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1106
+
1107
+
1108
+ class GridDataResponse(BaseModel):
1109
+ """Response model for grid data."""
1110
+
1111
+ error_code: Optional[str] = Field(
1112
+ None, alias="errorCode", description="Error code for the entire API request"
1113
+ )
1114
+ error_message: Optional[str] = Field(
1115
+ None,
1116
+ alias="errorMessage",
1117
+ description="Error message for the entire API request",
1118
+ )
1119
+ series: List[GridDataSeries] = Field(..., description="List of grid data series")
1120
+
1121
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1122
+
1123
+
1124
+ class ServiceStatus(BaseModel):
1125
+ """Model representing service status."""
1126
+
1127
+ code: int = Field(..., description="Status code")
1128
+ description: str = Field(..., description="Status description")
1129
+
1130
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1131
+
1132
+
1133
+ class ErrorResponse(BaseModel):
1134
+ """Error response from the API matching DataQuery specification."""
1135
+
1136
+ code: Union[int, str] = Field(..., description="DataQuery error code")
1137
+ description: str = Field(
1138
+ ..., description="Description of the error that has occurred"
1139
+ )
1140
+ interaction_id: Optional[str] = Field(
1141
+ None,
1142
+ alias="x-dataquery-interaction-id",
1143
+ description="Interaction ID for traceability",
1144
+ )
1145
+
1146
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1147
+
1148
+
1149
+ class AuthenticationErrorResponse(ErrorResponse):
1150
+ """Authentication or Authorization information missing or invalid."""
1151
+
1152
+
1153
+ class BadRequestResponse(ErrorResponse):
1154
+ """The request received was malformed or invalid."""
1155
+
1156
+
1157
+ class NotFoundResponse(ErrorResponse):
1158
+ """The requested resource was not found."""
1159
+
1160
+
1161
+ class ForbiddenPremiumResponse(ErrorResponse):
1162
+ """Premium data access required."""
1163
+
1164
+
1165
+ class InternalServerErrorResponse(ErrorResponse):
1166
+ """Internal Server Error."""
1167
+
1168
+
1169
+ class Information(BaseModel):
1170
+ """A DataQuery information message."""
1171
+
1172
+ code: Union[int, str] = Field(
1173
+ ..., description="DataQuery code for information message"
1174
+ )
1175
+ description: str = Field(..., description="Description of information provided")
1176
+
1177
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
1178
+
1179
+
1180
+ class NoContentResponse(Information):
1181
+ """Request successfully processed but no content available."""
1182
+
1183
+
1184
+ class Available(Information):
1185
+ """DataQuery Services are functioning as expected."""
1186
+
1187
+
1188
+ class Unavailable(ErrorResponse):
1189
+ """DataQuery Services are currently unavailable."""
1190
+
1191
+
1192
+ # Time series parameters from OpenAPI specification
1193
+
1194
+
1195
+ class DataType(str, Enum):
1196
+ """Data type for time series requests."""
1197
+
1198
+ REFERENCE_DATA = "REFERENCE_DATA"
1199
+ NO_REFERENCE_DATA = "NO_REFERENCE_DATA"
1200
+ ALL = "ALL"
1201
+
1202
+
1203
+ class Calendar(str, Enum):
1204
+ """Calendar convention for time-series."""
1205
+
1206
+ CAL_USBANK = "CAL_USBANK" # Default
1207
+ CAL_ALLDAYS = "CAL_ALLDAYS"
1208
+ CAL_WEEKDAYS = "CAL_WEEKDAYS"
1209
+ CAL_AUSTRALIA = "CAL_AUSTRALIA"
1210
+ CAL_BELGIUM = "CAL_BELGIUM"
1211
+ CAL_CANADA = "CAL_CANADA"
1212
+ CAL_DENMARK = "CAL_DENMARK"
1213
+ CAL_EURO = "CAL_EURO"
1214
+ CAL_FINLAND = "CAL_FINLAND"
1215
+ CAL_FRANCE = "CAL_FRANCE"
1216
+ CAL_GERMANY = "CAL_GERMANY"
1217
+ CAL_HONGKONG = "CAL_HONGKONG"
1218
+ CAL_IRELAND = "CAL_IRELAND"
1219
+ CAL_ITALY = "CAL_ITALY"
1220
+ CAL_JAPAN = "CAL_JAPAN"
1221
+ CAL_MALAYSIA = "CAL_MALAYSIA"
1222
+ CAL_NETHERLANDS = "CAL_NETHERLANDS"
1223
+ CAL_NEWZEALAND = "CAL_NEWZEALAND"
1224
+ CAL_NYSE = "CAL_NYSE"
1225
+ CAL_PORTUGAL = "CAL_PORTUGAL"
1226
+ CAL_SAFRICA = "CAL_SAFRICA"
1227
+ CAL_SINGAPORE = "CAL_SINGAPORE"
1228
+ CAL_SPAIN = "CAL_SPAIN"
1229
+ CAL_SWEDEN = "CAL_SWEDEN"
1230
+ CAL_SWITZERLAND = "CAL_SWITZERLAND"
1231
+ CAL_USEXCH = "CAL_USEXCH"
1232
+ CAL_UK = "CAL_UK"
1233
+ CALSOF = "CALSOF"
1234
+ CALTGT = "CALTGT"
1235
+ CALLIF = "CALLIF"
1236
+ CAL_EUR_UKFIN = "CAL_EUR_UKFIN"
1237
+ CAL_UK_TGT = "CAL_UK_TGT"
1238
+
1239
+
1240
+ class Frequency(str, Enum):
1241
+ """Frequency convention for time-series."""
1242
+
1243
+ FREQ_INTRA = "FREQ_INTRA"
1244
+ FREQ_DAY = "FREQ_DAY" # Default
1245
+ FREQ_WEEK = "FREQ_WEEK"
1246
+ FREQ_MONTH = "FREQ_MONTH"
1247
+ FREQ_QUARTER = "FREQ_QUARTER"
1248
+ FREQ_ANN = "FREQ_ANN"
1249
+
1250
+
1251
+ class Conversion(str, Enum):
1252
+ """Conversion convention for time-series."""
1253
+
1254
+ CONV_LASTBUS_ABS = "CONV_LASTBUS_ABS" # Default
1255
+ CONV_FIRSTBUS_ABS = "CONV_FIRSTBUS_ABS"
1256
+ CONV_LASTBUS_REL = "CONV_LASTBUS_REL"
1257
+ CONV_FIRSTBUS_REL = "CONV_FIRSTBUS_REL"
1258
+ CONV_SUM_ABS_SDT = "CONV_SUM_ABS_SDT"
1259
+ CONV_SUM_ABS_EDT = "CONV_SUM_ABS_EDT"
1260
+ CONV_SUM_REL_SDT = "CONV_SUM_REL_SDT"
1261
+ CONV_SUM_REL_EDT = "CONV_SUM_REL_EDT"
1262
+ CONV_AVG_ABS_SDT = "CONV_AVG_ABS_SDT"
1263
+ CONV_AVG_ABS_EDT = "CONV_AVG_ABS_EDT"
1264
+ CONV_AVG_REL_SDT = "CONV_AVG_REL_SDT"
1265
+ CONV_AVG_REL_EDT = "CONV_AVG_REL_EDT"
1266
+
1267
+
1268
+ class NanTreatment(str, Enum):
1269
+ """Missing data point treatment for time-series."""
1270
+
1271
+ NA_NOTHING = "NA_NOTHING" # Default
1272
+ NA_LAST = "NA_LAST"
1273
+ NA_NEXT = "NA_NEXT"
1274
+ NA_INTERP = "NA_INTERP"
1275
+
1276
+
1277
+ class Format(str, Enum):
1278
+ """Response format."""
1279
+
1280
+ JSON = "JSON"
1281
+
1282
+
1283
+ class TimeSeriesParameters(BaseModel):
1284
+ """Parameters for time series requests."""
1285
+
1286
+ data: DataType = Field(DataType.REFERENCE_DATA, description="Data type to retrieve")
1287
+ format: Format = Field(Format.JSON, description="Response format")
1288
+ start_date: Optional[str] = Field(
1289
+ "TODAY-1D", alias="start-date", description="Start date"
1290
+ )
1291
+ end_date: Optional[str] = Field("TODAY", alias="end-date", description="End date")
1292
+ calendar: Calendar = Field(Calendar.CAL_USBANK, description="Calendar convention")
1293
+ frequency: Frequency = Field(Frequency.FREQ_DAY, description="Frequency convention")
1294
+ conversion: Conversion = Field(
1295
+ Conversion.CONV_LASTBUS_ABS, description="Conversion convention"
1296
+ )
1297
+ nan_treatment: NanTreatment = Field(
1298
+ NanTreatment.NA_NOTHING,
1299
+ alias="nan-treatment",
1300
+ description="Missing data treatment",
1301
+ )
1302
+
1303
+ model_config = ConfigDict(extra="allow", populate_by_name=True)