fin3 0.4.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.
Files changed (48) hide show
  1. fin3/__init__.py +55 -0
  2. fin3/calendar/__init__.py +1 -0
  3. fin3/calendar/exchange.py +142 -0
  4. fin3/cli.py +366 -0
  5. fin3/config/__init__.py +1 -0
  6. fin3/config/settings.py +300 -0
  7. fin3/core.py +611 -0
  8. fin3/exceptions.py +126 -0
  9. fin3/inspect.py +239 -0
  10. fin3/manifest.py +631 -0
  11. fin3/metadata/__init__.py +1 -0
  12. fin3/metadata/asset_profile.py +126 -0
  13. fin3/monitoring/__init__.py +15 -0
  14. fin3/monitoring/collector.py +172 -0
  15. fin3/monitoring/display.py +114 -0
  16. fin3/monitoring/render.py +128 -0
  17. fin3/monitoring/tmux.py +96 -0
  18. fin3/monitoring/tracker.py +453 -0
  19. fin3/options.py +653 -0
  20. fin3/options_schemas.py +481 -0
  21. fin3/providers/__init__.py +144 -0
  22. fin3/providers/base.py +79 -0
  23. fin3/providers/binance.py +358 -0
  24. fin3/providers/cboe.py +306 -0
  25. fin3/providers/databento.py +253 -0
  26. fin3/providers/eodhd_options.py +1257 -0
  27. fin3/providers/massive.py +404 -0
  28. fin3/providers/yfinance.py +303 -0
  29. fin3/schemas.py +164 -0
  30. fin3/storage/__init__.py +1 -0
  31. fin3/storage/arctic.py +601 -0
  32. fin3/storage/defrag.py +225 -0
  33. fin3/storage/locking.py +314 -0
  34. fin3/sync.py +1078 -0
  35. fin3/utils/__init__.py +1 -0
  36. fin3/utils/date_utils.py +119 -0
  37. fin3/utils/integrity.py +127 -0
  38. fin3/utils/logging.py +48 -0
  39. fin3/utils/options_validation.py +245 -0
  40. fin3/utils/rules.py +573 -0
  41. fin3/utils/symbols.py +34 -0
  42. fin3/utils/validation.py +104 -0
  43. fin3-0.4.0.dist-info/METADATA +188 -0
  44. fin3-0.4.0.dist-info/RECORD +48 -0
  45. fin3-0.4.0.dist-info/WHEEL +5 -0
  46. fin3-0.4.0.dist-info/entry_points.txt +2 -0
  47. fin3-0.4.0.dist-info/licenses/LICENSE +202 -0
  48. fin3-0.4.0.dist-info/top_level.txt +1 -0
fin3/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """fin3 — Declarative financial time-series data library."""
2
+
3
+ from fin3.config.settings import ClientConfig
4
+ from fin3.core import MarketDataFetcher
5
+ from fin3.exceptions import (
6
+ BoundaryMismatchError,
7
+ ConfigurationError,
8
+ DataValidationError,
9
+ Fin3Error,
10
+ ProviderError,
11
+ ProviderRateLimitError,
12
+ ProviderTimeoutError,
13
+ RequestLimitExceededError,
14
+ SchemaValidationError,
15
+ StorageError,
16
+ )
17
+ from fin3.inspect import LibraryOverview, SymbolProfile, inspect_library
18
+ from fin3.monitoring import ResourceReport, ResourceTracker
19
+ from fin3.options import ChainResult, OptionsDataFetcher
20
+ from fin3.options_schemas import ChainCoverageManifest
21
+ from fin3.schemas import AssetType, Resolution
22
+ from fin3.storage.defrag import DefragReport, SymbolDefragResult, defragment_library, get_fragmentation_info
23
+ from fin3.utils.integrity import IntegrityIssue, IntegrityReport, check_integrity
24
+
25
+ __all__ = [
26
+ "AssetType",
27
+ "BoundaryMismatchError",
28
+ "ChainCoverageManifest",
29
+ "ChainResult",
30
+ "ClientConfig",
31
+ "ConfigurationError",
32
+ "DataValidationError",
33
+ "DefragReport",
34
+ "Fin3Error",
35
+ "IntegrityIssue",
36
+ "IntegrityReport",
37
+ "LibraryOverview",
38
+ "MarketDataFetcher",
39
+ "OptionsDataFetcher",
40
+ "ProviderError",
41
+ "RequestLimitExceededError",
42
+ "ResourceReport",
43
+ "ResourceTracker",
44
+ "ProviderRateLimitError",
45
+ "ProviderTimeoutError",
46
+ "Resolution",
47
+ "SchemaValidationError",
48
+ "StorageError",
49
+ "SymbolDefragResult",
50
+ "SymbolProfile",
51
+ "check_integrity",
52
+ "defragment_library",
53
+ "get_fragmentation_info",
54
+ "inspect_library",
55
+ ]
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,142 @@
1
+ """Calendar strategy protocol and implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Protocol
6
+
7
+ import exchange_calendars
8
+ import pandas as pd
9
+
10
+ from fin3.schemas import Resolution
11
+
12
+
13
+ class CalendarStrategy(Protocol):
14
+ """Protocol for trading calendar grid generation.
15
+
16
+ Implementations generate a DatetimeIndex of valid bar timestamps within
17
+ the trading session for a given date range and resolution. Used to
18
+ reindex OHLCV data so every expected bar has a row.
19
+ """
20
+
21
+ def generate_grid(
22
+ self, start: pd.Timestamp, end: pd.Timestamp, resolution: Resolution
23
+ ) -> pd.DatetimeIndex:
24
+ """Generate a grid of valid bar timestamps.
25
+
26
+ Parameters
27
+ ----------
28
+ start : pd.Timestamp
29
+ Start of the range (UTC).
30
+ end : pd.Timestamp
31
+ End of the range (UTC).
32
+ resolution : Resolution
33
+ Bar resolution (e.g. ``1m``, ``1h``).
34
+
35
+ Returns
36
+ -------
37
+ pd.DatetimeIndex
38
+ Sorted UTC timestamps for every valid bar in the range.
39
+ """
40
+ ...
41
+
42
+
43
+ class ExchangeCalendarStrategy:
44
+ """Generates master grid using exchange_calendars for a given MIC.
45
+
46
+ Produces aligned intraday timestamps for exchanges with defined trading
47
+ sessions, holidays, and early closes. Used for equities (NYSE) and
48
+ futures (CME).
49
+ """
50
+
51
+ def __init__(self, mic: str) -> None:
52
+ """Initialise with a Market Identifier Code.
53
+
54
+ Parameters
55
+ ----------
56
+ mic : str
57
+ ISO 10383 Market Identifier Code (e.g. ``XNYS``, ``XNAS``, ``CME``).
58
+ """
59
+ self._calendar = exchange_calendars.get_calendar(mic)
60
+
61
+ def generate_grid(
62
+ self, start: pd.Timestamp, end: pd.Timestamp, resolution: Resolution
63
+ ) -> pd.DatetimeIndex:
64
+ """Generate an exchange-aligned bar grid.
65
+
66
+ Builds per-session bar sequences using the exchange's open/close
67
+ times, excluding holidays and early closes. Intraday sub-day grids
68
+ are trimmed to the requested range when start/end carry intraday time.
69
+
70
+ Parameters
71
+ ----------
72
+ start : pd.Timestamp
73
+ Start of the range (UTC).
74
+ end : pd.Timestamp
75
+ End of the range (UTC).
76
+ resolution : Resolution
77
+ Bar resolution (e.g. ``1m`` for minute bars).
78
+
79
+ Returns
80
+ -------
81
+ pd.DatetimeIndex
82
+ Sorted UTC timestamps for every valid bar in the range.
83
+ """
84
+ naive_start = start.tz_localize(None) if start.tz is not None else start
85
+ naive_end = end.tz_localize(None) if end.tz is not None else end
86
+ # sessions_in_range requires date-only inputs (midnight)
87
+ date_start = naive_start.normalize()
88
+ date_end = naive_end.normalize()
89
+ sessions = self._calendar.sessions_in_range(date_start, date_end)
90
+ freq = resolution.timedelta_alias
91
+
92
+ all_bars: list[pd.DatetimeIndex] = []
93
+ for session in sessions:
94
+ session_ts = pd.Timestamp(session)
95
+ open_time, close_time = self._calendar.session_open_close(session_ts)
96
+ bars = pd.date_range(open_time, close_time, freq=freq)
97
+ if len(bars) > 0 and bars[-1] > close_time:
98
+ bars = bars[:-1]
99
+ all_bars.append(bars)
100
+
101
+ if not all_bars:
102
+ return pd.DatetimeIndex([], tz="UTC")
103
+
104
+ combined = pd.DatetimeIndex(pd.concat([pd.Series(idx) for idx in all_bars]))
105
+ if combined.tz is None:
106
+ combined = combined.tz_localize("UTC")
107
+ # Trim to the requested range when start/end carry intraday time
108
+ utc_start = start if start.tz is not None else start.tz_localize("UTC")
109
+ utc_end = end if end.tz is not None else end.tz_localize("UTC")
110
+ if utc_start != utc_start.normalize() or utc_end != utc_end.normalize():
111
+ combined = combined[(combined >= utc_start) & (combined <= utc_end)]
112
+ return combined
113
+
114
+
115
+ class ContinuousCalendarStrategy:
116
+ """Generates master grid as a continuous date_range (no holidays). Used for crypto.
117
+
118
+ Produces uninterrupted bar timestamps across the full date range at the
119
+ specified resolution. No gaps for weekends, holidays, or exchange closures.
120
+ """
121
+
122
+ def generate_grid(
123
+ self, start: pd.Timestamp, end: pd.Timestamp, resolution: Resolution
124
+ ) -> pd.DatetimeIndex:
125
+ """Generate a continuous bar grid with no calendar gaps.
126
+
127
+ Parameters
128
+ ----------
129
+ start : pd.Timestamp
130
+ Start of the range (UTC).
131
+ end : pd.Timestamp
132
+ End of the range (UTC).
133
+ resolution : Resolution
134
+ Bar resolution (e.g. ``1h`` for hourly bars).
135
+
136
+ Returns
137
+ -------
138
+ pd.DatetimeIndex
139
+ Sorted UTC timestamps at the requested resolution across the range.
140
+ """
141
+ freq = resolution.timedelta_alias
142
+ return pd.date_range(start, end, freq=freq, tz="UTC")
fin3/cli.py ADDED
@@ -0,0 +1,366 @@
1
+ """``fin3`` command line: ensure / sync / status for declarative manifests.
2
+
3
+ Construction order is fixed: parse arguments → load/validate manifest →
4
+ ``_load_config()`` → ``configure_logging`` → ``_build_runner()`` → perform
5
+ operation. Manifest validation deliberately precedes environment
6
+ configuration so an invalid manifest is diagnosed even outside a configured
7
+ checkout. ``--json`` emits exactly one JSON value on stdout; all diagnostics
8
+ routed through ``logging`` go to stderr.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import json
15
+ import sys
16
+ from collections.abc import Sequence
17
+ from datetime import datetime, timezone
18
+ from typing import Any
19
+
20
+ from pydantic import SecretStr, ValidationError
21
+
22
+ from fin3.config.settings import ClientConfig
23
+ from fin3.exceptions import ConfigurationError, CostLimitExceededError, Fin3Error
24
+ from fin3.manifest import load_manifest
25
+ from fin3.storage.locking import LockAcquisitionError
26
+ from fin3.sync import ManifestReport, ManifestRunner, SymbolResult
27
+ from fin3.utils.logging import configure_logging
28
+
29
+
30
+ def _load_config() -> ClientConfig:
31
+ """Production configuration seam (tests monkeypatch this).
32
+
33
+ ``ClientConfig`` validates ``FIN3_*`` environment variables at
34
+ construction; a missing ``FIN3_MINIO__*`` set raises
35
+ ``pydantic.ValidationError``, which ``main`` renders as a
36
+ configuration error.
37
+ """
38
+ return ClientConfig() # type: ignore[call-arg] # required fields come from env
39
+
40
+
41
+ def _build_runner(config: ClientConfig) -> ManifestRunner:
42
+ """Production runner seam (tests monkeypatch this)."""
43
+ return ManifestRunner(config)
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Secret redaction
48
+ # ---------------------------------------------------------------------------
49
+
50
+
51
+ def _collect_secrets(config: ClientConfig | None) -> list[str]:
52
+ """Return configured secret values (API keys, MinIO credentials).
53
+
54
+ Recognizes plain ``str`` fields and ``SecretStr`` fields (via
55
+ ``get_secret_value()``) — including the hidden top-level
56
+ ``ClientConfig.eodhd_api_key``. This is a redaction boundary: the
57
+ collected values must never appear in dumps, errors, reports, or URLs.
58
+ """
59
+ if config is None:
60
+ return []
61
+ secrets: list[str] = []
62
+ models: list[object] = [config.minio, *config.providers.values()]
63
+ for model in models:
64
+ for name in getattr(type(model), "model_fields", {}):
65
+ if "key" not in name and "secret" not in name:
66
+ continue
67
+ value = getattr(model, name, None)
68
+ if isinstance(value, SecretStr):
69
+ raw = value.get_secret_value()
70
+ if raw:
71
+ secrets.append(raw)
72
+ elif isinstance(value, str) and value:
73
+ secrets.append(value)
74
+ # Top-level hidden raw key (a bare SecretStr field, not a model).
75
+ raw_key = config.eodhd_api_key
76
+ if isinstance(raw_key, SecretStr) and raw_key.get_secret_value():
77
+ secrets.append(raw_key.get_secret_value())
78
+ return sorted(set(secrets), key=len, reverse=True)
79
+
80
+
81
+ def _redact(message: str, config: ClientConfig | None) -> str:
82
+ for secret in _collect_secrets(config):
83
+ if secret in message:
84
+ message = message.replace(secret, "***")
85
+ return message
86
+
87
+
88
+ def _redact_report(
89
+ report: ManifestReport, config: ClientConfig | None
90
+ ) -> ManifestReport:
91
+ """Return a copy of *report* with secret-redacted error strings."""
92
+ results: list[SymbolResult] = []
93
+ for r in report.results:
94
+ if r.error is None:
95
+ results.append(r)
96
+ continue
97
+ results.append(
98
+ SymbolResult(
99
+ dataset_id=r.dataset_id,
100
+ library=r.library,
101
+ symbol=r.symbol,
102
+ policy=r.policy,
103
+ action=r.action,
104
+ status=r.status,
105
+ reason=r.reason,
106
+ fetch_ranges=list(r.fetch_ranges),
107
+ write_ranges=list(r.write_ranges),
108
+ rows_written=r.rows_written,
109
+ write_state_unknown=r.write_state_unknown,
110
+ error=_redact(r.error, config),
111
+ )
112
+ )
113
+ return ManifestReport(
114
+ manifest_id=report.manifest_id,
115
+ declaration_hash=report.declaration_hash,
116
+ last_successful_hash=report.last_successful_hash,
117
+ definition_state=report.definition_state,
118
+ operation=report.operation,
119
+ dry_run=report.dry_run,
120
+ resolved_at=report.resolved_at,
121
+ planned_libraries=report.planned_libraries,
122
+ created_libraries=report.created_libraries,
123
+ estimated_cost=report.estimated_cost,
124
+ results=results,
125
+ errors=[_redact(e, config) for e in report.errors],
126
+ state_write_unknown=report.state_write_unknown,
127
+ )
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Rendering
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def _iso(dt: datetime) -> str:
136
+ return dt.astimezone(timezone.utc).isoformat()
137
+
138
+
139
+ def _result_to_dict(result: SymbolResult) -> dict[str, Any]:
140
+ return {
141
+ "dataset_id": result.dataset_id,
142
+ "library": result.library,
143
+ "symbol": result.symbol,
144
+ "policy": result.policy,
145
+ "action": result.action,
146
+ "status": result.status,
147
+ "reason": result.reason,
148
+ "fetch_ranges": [[_iso(a), _iso(b)] for a, b in result.fetch_ranges],
149
+ "write_ranges": [[_iso(a), _iso(b)] for a, b in result.write_ranges],
150
+ "rows_written": result.rows_written,
151
+ "write_state_unknown": result.write_state_unknown,
152
+ "error": result.error,
153
+ }
154
+
155
+
156
+ def _report_to_dict(report: ManifestReport) -> dict[str, Any]:
157
+ return {
158
+ "manifest_id": report.manifest_id,
159
+ "declaration_hash": report.declaration_hash,
160
+ "last_successful_hash": report.last_successful_hash,
161
+ "definition_state": report.definition_state,
162
+ "operation": report.operation,
163
+ "dry_run": report.dry_run,
164
+ "resolved_at": _iso(report.resolved_at),
165
+ "planned_libraries": report.planned_libraries,
166
+ "created_libraries": report.created_libraries,
167
+ "estimated_cost": report.estimated_cost,
168
+ "totals": {
169
+ "created": report.created_count,
170
+ "updated": report.updated_count,
171
+ "skipped": report.skipped_count,
172
+ "failed": report.failed_count,
173
+ },
174
+ "results": [_result_to_dict(r) for r in report.results],
175
+ "errors": list(report.errors),
176
+ "state_write_unknown": report.state_write_unknown,
177
+ }
178
+
179
+
180
+ def _format_report(report: ManifestReport) -> str:
181
+ lines = [
182
+ f"Manifest {report.manifest_id}: {report.operation}"
183
+ + (" (dry-run)" if report.dry_run else ""),
184
+ f" definition: {report.definition_state}"
185
+ + (
186
+ f" (last successful hash: {report.last_successful_hash})"
187
+ if report.last_successful_hash
188
+ else ""
189
+ ),
190
+ f" resolved_at: {_iso(report.resolved_at)}",
191
+ ]
192
+ if report.planned_libraries:
193
+ lines.append(
194
+ f" planned library creations: {', '.join(report.planned_libraries)}"
195
+ )
196
+ if report.created_libraries:
197
+ lines.append(f" created libraries: {', '.join(report.created_libraries)}")
198
+ if report.estimated_cost is not None:
199
+ lines.append(f" estimated cost: ${report.estimated_cost:.2f}")
200
+ lines.append(
201
+ f" totals: {report.created_count} created, {report.updated_count} updated, "
202
+ f"{report.skipped_count} skipped, {report.failed_count} failed"
203
+ )
204
+
205
+ if report.results:
206
+ lines.append(" targets:")
207
+ for r in report.results:
208
+ line = (
209
+ f" {r.dataset_id}/{r.symbol} [{r.library}] "
210
+ f"action={r.action} status={r.status}"
211
+ )
212
+ if r.reason:
213
+ line += f" reason={r.reason}"
214
+ if r.rows_written:
215
+ line += f" rows={r.rows_written}"
216
+ if r.fetch_ranges:
217
+ ranges = ", ".join(
218
+ f"[{a.isoformat()}, {b.isoformat()}]" for a, b in r.fetch_ranges
219
+ )
220
+ line += f" fetch={ranges}"
221
+ if r.write_ranges:
222
+ ranges = ", ".join(
223
+ f"[{a.isoformat()}, {b.isoformat()}]" for a, b in r.write_ranges
224
+ )
225
+ line += f" write={ranges}"
226
+ lines.append(line)
227
+ if r.error is not None:
228
+ lines.append(f" error: {r.error}")
229
+ for err in report.errors:
230
+ lines.append(f" error: {err}")
231
+ if report.state_write_unknown:
232
+ lines.append(" warning: manifest state write outcome unknown")
233
+ return "\n".join(lines)
234
+
235
+
236
+ # ---------------------------------------------------------------------------
237
+ # Argument parsing
238
+ # ---------------------------------------------------------------------------
239
+
240
+
241
+ def _build_parser() -> argparse.ArgumentParser:
242
+ parser = argparse.ArgumentParser(
243
+ prog="fin3",
244
+ description="Declarative dataset manifests: ensure, sync, and status",
245
+ )
246
+ sub = parser.add_subparsers(dest="command", required=True)
247
+
248
+ for name, help_text in (
249
+ ("ensure", "create missing libraries and symbols declared by the manifest"),
250
+ ("sync", "reconcile stored data with the manifest per update policy"),
251
+ ):
252
+ p = sub.add_parser(name, help=help_text)
253
+ p.add_argument("manifest", help="Path to a manifest .yaml/.yml/.json file")
254
+ p.add_argument(
255
+ "--dry-run",
256
+ action="store_true",
257
+ help="Report planned actions without writing anything",
258
+ )
259
+ p.add_argument(
260
+ "--json",
261
+ action="store_true",
262
+ help="Emit the report as a single JSON value on stdout",
263
+ )
264
+ p.add_argument(
265
+ "--max-cost",
266
+ type=float,
267
+ default=None,
268
+ metavar="USD",
269
+ help="Abort before any write when the estimated download cost exceeds this",
270
+ )
271
+
272
+ status = sub.add_parser(
273
+ "status", help="read-only preview of the actions sync would take"
274
+ )
275
+ status.add_argument("manifest", help="Path to a manifest .yaml/.yml/.json file")
276
+ status.add_argument(
277
+ "--json",
278
+ action="store_true",
279
+ help="Emit the report as a single JSON value on stdout",
280
+ )
281
+ return parser
282
+
283
+
284
+ # ---------------------------------------------------------------------------
285
+ # Entry point
286
+ # ---------------------------------------------------------------------------
287
+
288
+
289
+ def main(argv: Sequence[str] | None = None) -> int:
290
+ """CLI entry point. Returns a process exit code."""
291
+ parser = _build_parser()
292
+ args = parser.parse_args(argv)
293
+
294
+ config: ClientConfig | None = None
295
+ try:
296
+ manifest = load_manifest(args.manifest)
297
+ config = _load_config()
298
+ configure_logging(level=config.log_level, format_=config.log_format)
299
+ runner = _build_runner(config)
300
+ if args.command == "status":
301
+ report = runner.status(manifest)
302
+ elif args.command == "ensure":
303
+ report = runner.ensure(
304
+ manifest, dry_run=args.dry_run, max_cost=args.max_cost
305
+ )
306
+ else:
307
+ report = runner.sync(manifest, dry_run=args.dry_run, max_cost=args.max_cost)
308
+ except ConfigurationError as exc:
309
+ payload = {
310
+ "error": {
311
+ "kind": "configuration",
312
+ "message": _redact(str(exc), config),
313
+ }
314
+ }
315
+ if getattr(args, "json", False):
316
+ print(json.dumps(payload))
317
+ else:
318
+ print(
319
+ f"configuration error: {payload['error']['message']}",
320
+ file=sys.stderr,
321
+ )
322
+ return 2
323
+ except ValidationError as exc:
324
+ payload = {
325
+ "error": {
326
+ "kind": "configuration",
327
+ "message": _redact(f"invalid configuration: {exc}", None),
328
+ }
329
+ }
330
+ if getattr(args, "json", False):
331
+ print(json.dumps(payload))
332
+ else:
333
+ print(
334
+ f"configuration error: {payload['error']['message']}",
335
+ file=sys.stderr,
336
+ )
337
+ return 2
338
+ except (CostLimitExceededError, Fin3Error, LockAcquisitionError) as exc:
339
+ payload = {
340
+ "error": {
341
+ "kind": "operational",
342
+ "message": _redact(str(exc), config),
343
+ }
344
+ }
345
+ if getattr(args, "json", False):
346
+ print(json.dumps(payload))
347
+ else:
348
+ print(
349
+ f"operational error: {payload['error']['message']}",
350
+ file=sys.stderr,
351
+ )
352
+ return 1
353
+
354
+ report = _redact_report(report, config)
355
+ if getattr(args, "json", False):
356
+ print(json.dumps(_report_to_dict(report)))
357
+ else:
358
+ print(_format_report(report))
359
+
360
+ if report.failed_count > 0 or report.state_write_unknown or report.errors:
361
+ return 1
362
+ return 0
363
+
364
+
365
+ if __name__ == "__main__": # pragma: no cover
366
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+