onefinance 0.1.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.
onefinance/__init__.py ADDED
@@ -0,0 +1,74 @@
1
+ """OneFinance — unified financial data API across multiple providers."""
2
+
3
+ from onefinance.core.client import OneFinanceClient
4
+ from onefinance.core.errors import (
5
+ AllProvidersFailedError,
6
+ FinanceError,
7
+ NotSupportedError,
8
+ ProviderError,
9
+ RateLimitError,
10
+ )
11
+ from onefinance.indicators import TechnicalIndicators, compute_indicators
12
+ from onefinance.core.models import (
13
+ AnalystData,
14
+ BalanceSheet,
15
+ CashFlow,
16
+ CompanyInfo,
17
+ CorporateAction,
18
+ Currency,
19
+ DCFValuation,
20
+ EarningsRecord,
21
+ FinancialRatios,
22
+ FinanceModel,
23
+ ForwardEstimates,
24
+ IncomeStatement,
25
+ InsiderTrade,
26
+ InstitutionalHolder,
27
+ NewsArticle,
28
+ OptionChain,
29
+ OptionContract,
30
+ PriceBar,
31
+ Quote,
32
+ ScreenerResult,
33
+ SectorInfo,
34
+ Symbol,
35
+ )
36
+
37
+ __all__ = [
38
+ # Client
39
+ "OneFinanceClient",
40
+ # Models
41
+ "AnalystData",
42
+ "FinanceModel",
43
+ "PriceBar",
44
+ "Quote",
45
+ "IncomeStatement",
46
+ "BalanceSheet",
47
+ "CashFlow",
48
+ "CompanyInfo",
49
+ "CorporateAction",
50
+ "DCFValuation",
51
+ "EarningsRecord",
52
+ "FinancialRatios",
53
+ "ForwardEstimates",
54
+ "InsiderTrade",
55
+ "InstitutionalHolder",
56
+ "NewsArticle",
57
+ "OptionChain",
58
+ "OptionContract",
59
+ "ScreenerResult",
60
+ "SectorInfo",
61
+ "Symbol",
62
+ "Currency",
63
+ # Errors
64
+ "FinanceError",
65
+ "ProviderError",
66
+ "NotSupportedError",
67
+ "RateLimitError",
68
+ "AllProvidersFailedError",
69
+ # Indicators
70
+ "TechnicalIndicators",
71
+ "compute_indicators",
72
+ ]
73
+
74
+ __version__ = "0.1.0"
@@ -0,0 +1,23 @@
1
+ """Audit log — structured tracing for every API invocation.
2
+
3
+ Records provider calls, cache hits, tier-walking decisions, and
4
+ errors so you can answer "which provider served this?" and
5
+ "how many FMP calls did I burn today?"
6
+
7
+ Usage::
8
+
9
+ from onefinance.audit import AuditLog, AuditEntry, AuditStats
10
+
11
+ log = AuditLog()
12
+ entries = log.query(provider="fmp", limit=10)
13
+ stats = log.stats()
14
+ """
15
+
16
+ from onefinance.audit.models import AuditEntry, AuditStats
17
+ from onefinance.audit.log import AuditLog
18
+
19
+ __all__ = [
20
+ "AuditEntry",
21
+ "AuditLog",
22
+ "AuditStats",
23
+ ]
@@ -0,0 +1,325 @@
1
+ """JSONL file-backed audit log for provider API calls.
2
+
3
+ Stores one JSON object per line (append-only). Designed for low
4
+ overhead (single line write per API call), human-readability
5
+ (``cat audit.jsonl | jq``), and programmatic querying via the
6
+ ``query()`` and ``stats()`` helpers.
7
+
8
+ Default location: ``~/.one_finance_data/audit/audit.jsonl``.
9
+ Default retention: 30 days (pruned on startup).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ from collections import defaultdict
18
+ from datetime import datetime, timedelta, timezone
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from onefinance.audit.models import AuditEntry, AuditStats
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _DEFAULT_LOG_DIR = "~/.one_finance_data/audit"
27
+ _DEFAULT_RETENTION_DAYS = 30
28
+
29
+
30
+ class AuditLog:
31
+ """JSONL file-backed audit log for provider API calls.
32
+
33
+ Parameters
34
+ ----------
35
+ log_path:
36
+ Path to the JSONL log file. If a directory is given,
37
+ ``audit.jsonl`` is appended. Defaults to
38
+ ``~/.one_finance_data/audit/audit.jsonl``.
39
+ retention_days:
40
+ Entries older than this are pruned on startup.
41
+ Set to ``0`` to disable pruning.
42
+ enabled:
43
+ If ``False``, ``record()`` is a no-op. Useful for tests
44
+ or when audit overhead is unwanted.
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ log_path: str | Path | None = None,
50
+ retention_days: int = _DEFAULT_RETENTION_DAYS,
51
+ enabled: bool = True,
52
+ ) -> None:
53
+ self._enabled = enabled
54
+ self._retention_days = retention_days
55
+ self._path: Path | None = None
56
+
57
+ if not enabled:
58
+ return
59
+
60
+ resolved = Path(log_path or _DEFAULT_LOG_DIR).expanduser()
61
+ if resolved.is_dir() or not resolved.suffix:
62
+ resolved = resolved / "audit.jsonl"
63
+ resolved.parent.mkdir(parents=True, exist_ok=True)
64
+ self._path = resolved
65
+
66
+ # Auto-prune old entries
67
+ if retention_days > 0 and self._path.exists():
68
+ pruned = self._prune()
69
+ if pruned > 0:
70
+ logger.debug(
71
+ "Pruned %d audit entries older than %d days",
72
+ pruned, retention_days,
73
+ )
74
+
75
+ # -------------------------------------------------------------------
76
+ # Write
77
+ # -------------------------------------------------------------------
78
+
79
+ def record(self, entry: AuditEntry) -> None:
80
+ """Append an audit entry as a single JSON line.
81
+
82
+ No-op if the log is disabled.
83
+ """
84
+ if not self._enabled or self._path is None:
85
+ return
86
+
87
+ try:
88
+ line = json.dumps(entry.to_dict(), separators=(",", ":"))
89
+ with open(self._path, "a") as f:
90
+ f.write(line + "\n")
91
+ except Exception:
92
+ logger.debug("Failed to write audit entry", exc_info=True)
93
+
94
+ # -------------------------------------------------------------------
95
+ # Query
96
+ # -------------------------------------------------------------------
97
+
98
+ def query(
99
+ self,
100
+ *,
101
+ provider: str | None = None,
102
+ endpoint: str | None = None,
103
+ status: str | None = None,
104
+ symbol: str | None = None,
105
+ request_id: str | None = None,
106
+ since: datetime | None = None,
107
+ limit: int = 100,
108
+ ) -> list[AuditEntry]:
109
+ """Query audit entries with optional filters.
110
+
111
+ Returns newest-first, up to ``limit`` entries.
112
+ """
113
+ if not self._enabled or self._path is None or not self._path.exists():
114
+ return []
115
+
116
+ entries: list[AuditEntry] = []
117
+ for raw in self._read_lines():
118
+ try:
119
+ obj = json.loads(raw)
120
+ except json.JSONDecodeError:
121
+ continue
122
+
123
+ # Apply filters
124
+ if provider is not None and obj.get("provider") != provider:
125
+ continue
126
+ if endpoint is not None and obj.get("endpoint") != endpoint:
127
+ continue
128
+ if status is not None and obj.get("status") != status:
129
+ continue
130
+ if symbol is not None and (obj.get("symbol") or "").upper() != symbol.upper():
131
+ continue
132
+ if request_id is not None and obj.get("request_id") != request_id:
133
+ continue
134
+ if since is not None:
135
+ ts = _parse_ts(obj.get("timestamp", ""))
136
+ if ts is not None and ts < since:
137
+ continue
138
+
139
+ entries.append(_dict_to_entry(obj))
140
+
141
+ # Newest-first, limited
142
+ entries.reverse()
143
+ return entries[:limit]
144
+
145
+ # -------------------------------------------------------------------
146
+ # Aggregate stats
147
+ # -------------------------------------------------------------------
148
+
149
+ def stats(
150
+ self,
151
+ *,
152
+ since: datetime | None = None,
153
+ ) -> AuditStats:
154
+ """Compute aggregate stats over a time range.
155
+
156
+ Parameters
157
+ ----------
158
+ since:
159
+ Start of the stats period. Defaults to 24 hours ago.
160
+ """
161
+ if not self._enabled or self._path is None or not self._path.exists():
162
+ return AuditStats()
163
+
164
+ if since is None:
165
+ since = datetime.now(timezone.utc) - timedelta(days=1)
166
+
167
+ now = datetime.now(timezone.utc)
168
+
169
+ total_calls = 0
170
+ cache_hits = 0
171
+ calls_by: dict[str, int] = defaultdict(int)
172
+ errors_by: dict[str, int] = defaultdict(int)
173
+ rate_limits_by: dict[str, int] = defaultdict(int)
174
+ latencies_by: dict[str, list[float]] = defaultdict(list)
175
+
176
+ for raw in self._read_lines():
177
+ try:
178
+ obj = json.loads(raw)
179
+ except json.JSONDecodeError:
180
+ continue
181
+
182
+ ts = _parse_ts(obj.get("timestamp", ""))
183
+ if ts is not None and ts < since:
184
+ continue
185
+
186
+ prov = obj.get("provider", "unknown")
187
+ status = obj.get("status", "")
188
+
189
+ if status == "cache_hit":
190
+ cache_hits += 1
191
+ continue
192
+
193
+ # Skip not_supported and skipped — they aren't real calls
194
+ if status in ("not_supported", "skipped"):
195
+ continue
196
+
197
+ total_calls += 1
198
+ calls_by[prov] += 1
199
+ latencies_by[prov].append(obj.get("latency_ms", 0))
200
+
201
+ if status == "error":
202
+ errors_by[prov] += 1
203
+ elif status == "rate_limited":
204
+ rate_limits_by[prov] += 1
205
+ errors_by[prov] += 1
206
+
207
+ total_requests = total_calls + cache_hits
208
+ cache_hit_rate = cache_hits / total_requests if total_requests > 0 else 0.0
209
+
210
+ avg_latency = {
211
+ prov: round(sum(lats) / len(lats), 1)
212
+ for prov, lats in latencies_by.items()
213
+ if lats
214
+ }
215
+
216
+ return AuditStats(
217
+ total_calls=total_calls,
218
+ cache_hits=cache_hits,
219
+ cache_hit_rate=round(cache_hit_rate, 3),
220
+ calls_by_provider=dict(calls_by),
221
+ errors_by_provider=dict(errors_by),
222
+ avg_latency_ms_by_provider=avg_latency,
223
+ rate_limits_by_provider=dict(rate_limits_by),
224
+ period_start=since,
225
+ period_end=now,
226
+ )
227
+
228
+ # -------------------------------------------------------------------
229
+ # Maintenance
230
+ # -------------------------------------------------------------------
231
+
232
+ def _prune(self) -> int:
233
+ """Remove entries older than ``retention_days``.
234
+
235
+ Rewrites the file in-place, keeping only recent entries.
236
+ Returns the number of entries removed.
237
+ """
238
+ if self._path is None or not self._path.exists():
239
+ return 0
240
+
241
+ cutoff = datetime.now(timezone.utc) - timedelta(days=self._retention_days)
242
+ kept: list[str] = []
243
+ pruned = 0
244
+
245
+ for raw in self._read_lines():
246
+ try:
247
+ obj = json.loads(raw)
248
+ ts = _parse_ts(obj.get("timestamp", ""))
249
+ if ts is not None and ts < cutoff:
250
+ pruned += 1
251
+ continue
252
+ except json.JSONDecodeError:
253
+ pruned += 1
254
+ continue
255
+ kept.append(raw)
256
+
257
+ if pruned > 0:
258
+ with open(self._path, "w") as f:
259
+ for line in kept:
260
+ f.write(line if line.endswith("\n") else line + "\n")
261
+
262
+ return pruned
263
+
264
+ def clear(self) -> None:
265
+ """Remove all entries from the audit log."""
266
+ if self._path is not None and self._path.exists():
267
+ self._path.write_text("")
268
+
269
+ def close(self) -> None:
270
+ """No-op for file-based log (no persistent connections)."""
271
+ pass
272
+
273
+ @property
274
+ def enabled(self) -> bool:
275
+ """Whether this audit log is active."""
276
+ return self._enabled
277
+
278
+ @property
279
+ def path(self) -> Path | None:
280
+ """Path to the log file, or None if disabled."""
281
+ return self._path
282
+
283
+ # -------------------------------------------------------------------
284
+ # Internal
285
+ # -------------------------------------------------------------------
286
+
287
+ def _read_lines(self) -> list[str]:
288
+ """Read all lines from the log file."""
289
+ if self._path is None or not self._path.exists():
290
+ return []
291
+ with open(self._path) as f:
292
+ return [line.strip() for line in f if line.strip()]
293
+
294
+
295
+ # ---------------------------------------------------------------------------
296
+ # Helpers
297
+ # ---------------------------------------------------------------------------
298
+
299
+ def _parse_ts(value: str) -> datetime | None:
300
+ """Parse an ISO timestamp string to datetime."""
301
+ if not value:
302
+ return None
303
+ try:
304
+ return datetime.fromisoformat(value)
305
+ except (ValueError, TypeError):
306
+ return None
307
+
308
+
309
+ def _dict_to_entry(obj: dict[str, Any]) -> AuditEntry:
310
+ """Convert a JSON dict to an AuditEntry."""
311
+ return AuditEntry(
312
+ timestamp=_parse_ts(obj.get("timestamp", "")) or datetime.now(timezone.utc),
313
+ request_id=obj.get("request_id", ""),
314
+ endpoint=obj.get("endpoint", ""),
315
+ provider=obj.get("provider", ""),
316
+ symbol=obj.get("symbol"),
317
+ status=obj.get("status", ""),
318
+ latency_ms=obj.get("latency_ms", 0),
319
+ error_code=obj.get("error_code"),
320
+ error_message=obj.get("error_message"),
321
+ tier_position=obj.get("tier_position", 0),
322
+ tier_total=obj.get("tier_total", 1),
323
+ http_status=obj.get("http_status"),
324
+ cache_key=obj.get("cache_key"),
325
+ )
@@ -0,0 +1,116 @@
1
+ """Data models for the audit log.
2
+
3
+ ``AuditEntry`` is a single log row; ``AuditStats`` is aggregated
4
+ statistics over a time range.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from datetime import datetime
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class AuditEntry:
15
+ """Single audit log entry for one provider call attempt.
16
+
17
+ Attributes
18
+ ----------
19
+ timestamp:
20
+ UTC time when the call started.
21
+ request_id:
22
+ Short hex ID grouping all tier-walk attempts for one
23
+ ``dispatch()`` call.
24
+ endpoint:
25
+ Logical endpoint name (``"price_history"``, ``"quote"``, etc.).
26
+ provider:
27
+ Provider that handled this attempt (``"fmp"``, ``"cache"``, etc.).
28
+ symbol:
29
+ Ticker symbol, if extractable from the call context.
30
+ status:
31
+ Outcome — ``"success"``, ``"error"``, ``"rate_limited"``,
32
+ ``"skipped"``, ``"cache_hit"``, ``"not_supported"``.
33
+ latency_ms:
34
+ Wall-clock time for this attempt in milliseconds.
35
+ error_code:
36
+ Stable error code (e.g. ``"NETWORK_ERROR"``), or ``None``.
37
+ error_message:
38
+ Human-readable error detail, or ``None``.
39
+ tier_position:
40
+ 0-indexed position of this provider in the tier list.
41
+ tier_total:
42
+ Total number of providers in the tier list.
43
+ http_status:
44
+ Raw HTTP status code, if available.
45
+ cache_key:
46
+ Cache key for ``cache_hit`` entries, ``None`` otherwise.
47
+ """
48
+
49
+ timestamp: datetime
50
+ request_id: str
51
+ endpoint: str
52
+ provider: str
53
+ symbol: str | None = None
54
+ status: str = "success"
55
+ latency_ms: float = 0.0
56
+ error_code: str | None = None
57
+ error_message: str | None = None
58
+ tier_position: int = 0
59
+ tier_total: int = 1
60
+ http_status: int | None = None
61
+ cache_key: str | None = None
62
+
63
+ def to_dict(self) -> dict:
64
+ """Serialise to a plain dictionary."""
65
+ return {
66
+ "timestamp": self.timestamp.isoformat(),
67
+ "request_id": self.request_id,
68
+ "endpoint": self.endpoint,
69
+ "provider": self.provider,
70
+ "symbol": self.symbol,
71
+ "status": self.status,
72
+ "latency_ms": round(self.latency_ms, 1),
73
+ "error_code": self.error_code,
74
+ "error_message": self.error_message,
75
+ "tier_position": self.tier_position,
76
+ "tier_total": self.tier_total,
77
+ "http_status": self.http_status,
78
+ "cache_key": self.cache_key,
79
+ }
80
+
81
+
82
+ @dataclass
83
+ class AuditStats:
84
+ """Aggregated statistics from audit entries.
85
+
86
+ Attributes
87
+ ----------
88
+ total_calls:
89
+ Total API call attempts (excludes cache hits).
90
+ cache_hits:
91
+ Number of requests served from cache.
92
+ cache_hit_rate:
93
+ Fraction of total requests served from cache (0.0–1.0).
94
+ calls_by_provider:
95
+ Number of API calls per provider.
96
+ errors_by_provider:
97
+ Number of errors per provider.
98
+ avg_latency_ms_by_provider:
99
+ Mean latency in ms per provider.
100
+ rate_limits_by_provider:
101
+ Number of rate-limit hits per provider.
102
+ period_start:
103
+ Start of the stats period.
104
+ period_end:
105
+ End of the stats period.
106
+ """
107
+
108
+ total_calls: int = 0
109
+ cache_hits: int = 0
110
+ cache_hit_rate: float = 0.0
111
+ calls_by_provider: dict[str, int] = field(default_factory=dict)
112
+ errors_by_provider: dict[str, int] = field(default_factory=dict)
113
+ avg_latency_ms_by_provider: dict[str, float] = field(default_factory=dict)
114
+ rate_limits_by_provider: dict[str, int] = field(default_factory=dict)
115
+ period_start: datetime | None = None
116
+ period_end: datetime | None = None
@@ -0,0 +1,11 @@
1
+ """Cache subpackage — diskcache-backed caching layer."""
2
+
3
+ from onefinance.cache.keys import make_key
4
+ from onefinance.cache.manager import CacheManager, default_ttl, ttl_for_price_history
5
+
6
+ __all__ = [
7
+ "CacheManager",
8
+ "make_key",
9
+ "default_ttl",
10
+ "ttl_for_price_history",
11
+ ]
@@ -0,0 +1,62 @@
1
+ """Cache key generation and parameter hashing.
2
+
3
+ Keys are provider-agnostic: the same ``(symbol, date range)`` from
4
+ FMP and yfinance share one cache key. The ``source`` field on the
5
+ returned model tells the caller which provider answered.
6
+
7
+ Key format::
8
+
9
+ "{data_type}:{sha256(sorted_params)[:16]}"
10
+ # example: "price_history:a3f9c2e8b1d4f6a0"
11
+
12
+ See design doc §10 for the full specification.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import json
19
+ from typing import Any
20
+
21
+
22
+ def make_key(data_type: str, **params: Any) -> str:
23
+ """Build a cache key from a data type and arbitrary parameters.
24
+
25
+ Parameters are JSON-serialised with ``sort_keys=True`` before hashing
26
+ so argument order never causes misses.
27
+
28
+ Parameters
29
+ ----------
30
+ data_type:
31
+ Endpoint identifier, e.g. ``"price_history"``, ``"quote"``.
32
+ **params:
33
+ The call parameters (symbol, start, end, period, etc.).
34
+ Values are coerced to strings via ``_normalise_value`` so that
35
+ ``date(2024,1,1)`` and ``"2024-01-01"`` produce the same key.
36
+
37
+ Returns
38
+ -------
39
+ str
40
+ A deterministic cache key like ``"price_history:a3f9c2e8b1d4f6a0"``.
41
+ """
42
+ normalised = {k: _normalise_value(v) for k, v in params.items() if v is not None}
43
+ param_json = json.dumps(normalised, sort_keys=True, separators=(",", ":"))
44
+ param_hash = hashlib.sha256(param_json.encode()).hexdigest()[:16]
45
+ return f"{data_type}:{param_hash}"
46
+
47
+
48
+ def _normalise_value(value: Any) -> str | int | float | bool | list | None:
49
+ """Coerce a parameter value to a JSON-friendly primitive.
50
+
51
+ Ensures that ``date(2024,1,1)`` and ``"2024-01-01"`` hash identically.
52
+ """
53
+ if value is None:
54
+ return None
55
+ if isinstance(value, bool):
56
+ return value
57
+ if isinstance(value, (int, float)):
58
+ return value
59
+ # date / datetime → ISO string
60
+ if hasattr(value, "isoformat"):
61
+ return value.isoformat()
62
+ return str(value)