Coverage for src/finbot/market/dataset.py: 98%
83 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 17:12 +0000
1import hashlib
2import json
3from dataclasses import dataclass
4from datetime import datetime
6DATASET_VERSION = 1
9@dataclass(frozen=True)
10class DatasetMetadata:
11 source: str
12 symbols: tuple[str, ...]
13 timeframe: str
14 start: datetime
15 end: datetime
16 adjustment: str
17 source_options: tuple[tuple[str, str], ...] = ()
18 version: int = DATASET_VERSION
20 def __post_init__(self) -> None:
21 source = self.source.strip()
22 if not source:
23 raise ValueError("Dataset source must be a non-empty string.")
25 symbols = tuple(
26 sorted({symbol.strip().upper() for symbol in self.symbols if symbol.strip()}),
27 )
28 if not symbols:
29 raise ValueError("Dataset symbols must contain at least one symbol.")
31 timeframe = self.timeframe.strip()
32 if not timeframe:
33 raise ValueError("Dataset timeframe must be a non-empty string.")
35 adjustment = self.adjustment.strip()
36 if not adjustment:
37 raise ValueError("Dataset adjustment must be a non-empty string.")
39 if self.start.tzinfo is None or self.end.tzinfo is None:
40 raise ValueError("Dataset start and end must be timezone-aware.")
42 if self.end <= self.start:
43 raise ValueError("Dataset end must be after start.")
45 if self.version < 1:
46 raise ValueError("Dataset version must be at least 1.")
48 source_options = tuple(
49 sorted((key.strip(), value.strip()) for key, value in self.source_options),
50 )
51 if any(not key or not value for key, value in source_options):
52 raise ValueError("Dataset source options must contain non-empty keys and values.")
54 if len({key for key, _ in source_options}) != len(source_options):
55 raise ValueError("Dataset source option keys must be unique.")
57 object.__setattr__(self, "source", source)
58 object.__setattr__(self, "symbols", symbols)
59 object.__setattr__(self, "timeframe", timeframe)
60 object.__setattr__(self, "adjustment", adjustment)
61 object.__setattr__(self, "source_options", source_options)
63 @property
64 def dataset_id(self) -> str:
65 payload = json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":"))
66 return hashlib.sha256(payload.encode("utf-8")).hexdigest()
68 def to_dict(self) -> dict[str, object]:
69 return {
70 "source": self.source,
71 "symbols": list(self.symbols),
72 "timeframe": self.timeframe,
73 "start": self.start.isoformat(),
74 "end": self.end.isoformat(),
75 "adjustment": self.adjustment,
76 "source_options": dict(self.source_options),
77 "version": self.version,
78 }
80 @classmethod
81 def from_dict(cls, data: dict[str, object]) -> "DatasetMetadata":
82 try:
83 source = data["source"]
84 symbols = data["symbols"]
85 timeframe = data["timeframe"]
86 start = data["start"]
87 end = data["end"]
88 adjustment = data["adjustment"]
89 source_options = data["source_options"]
90 version = data["version"]
91 except KeyError as exc:
92 raise ValueError(f"Dataset metadata is missing required field {exc.args[0]!r}") from exc
94 if not isinstance(source, str):
95 raise ValueError("Dataset metadata field 'source' must be a string.")
97 if not isinstance(symbols, list) or not all(isinstance(symbol, str) for symbol in symbols):
98 raise ValueError("Dataset metadata field 'symbols' must be a list of strings.")
100 if not isinstance(timeframe, str):
101 raise ValueError("Dataset metadata field 'timeframe' must be a string.")
103 if not isinstance(start, str) or not isinstance(end, str):
104 raise ValueError(
105 "Dataset metadata fields 'start' and 'end' must be ISO datetime strings.",
106 )
108 if not isinstance(adjustment, str):
109 raise ValueError("Dataset metadata field 'adjustment' must be a string.")
111 if not isinstance(source_options, dict) or not all( 111 ↛ 115line 111 didn't jump to line 115 because the condition on line 111 was never true
112 ((isinstance(key, str) and isinstance(value, str))
113 for key, value in source_options.items()),
114 ):
115 raise ValueError(
116 "Dataset metadata field 'source_options' "
117 "must be an object containing string values.",
118 )
120 if not isinstance(version, int) or isinstance(version, bool):
121 raise ValueError("Dataset metadata field 'version' must be an integer.")
123 try:
124 parsed_start = datetime.fromisoformat(start)
125 parsed_end = datetime.fromisoformat(end)
126 except ValueError as exc:
127 raise ValueError("Dataset metadata contains an invalid ISO datetime.") from exc
129 return cls(
130 source=source,
131 symbols=tuple(symbols),
132 timeframe=timeframe,
133 start=parsed_start,
134 end=parsed_end,
135 adjustment=adjustment,
136 source_options=tuple(source_options.items()),
137 version=version,
138 )