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 +74 -0
- onefinance/audit/__init__.py +23 -0
- onefinance/audit/log.py +325 -0
- onefinance/audit/models.py +116 -0
- onefinance/cache/__init__.py +11 -0
- onefinance/cache/keys.py +62 -0
- onefinance/cache/manager.py +363 -0
- onefinance/cli/__init__.py +1 -0
- onefinance/cli/__main__.py +4 -0
- onefinance/cli/app.py +1131 -0
- onefinance/cli/format.py +87 -0
- onefinance/core/__init__.py +1 -0
- onefinance/core/client.py +753 -0
- onefinance/core/config.py +223 -0
- onefinance/core/errors.py +174 -0
- onefinance/core/models.py +378 -0
- onefinance/core/router.py +409 -0
- onefinance/indicators/__init__.py +16 -0
- onefinance/indicators/core.py +284 -0
- onefinance/providers/__init__.py +1 -0
- onefinance/providers/_utils.py +23 -0
- onefinance/providers/base.py +196 -0
- onefinance/providers/finnhub.py +712 -0
- onefinance/providers/fmp.py +885 -0
- onefinance/providers/twelve_data.py +235 -0
- onefinance/providers/yfinance_provider.py +513 -0
- onefinance-0.1.0.dist-info/METADATA +175 -0
- onefinance-0.1.0.dist-info/RECORD +31 -0
- onefinance-0.1.0.dist-info/WHEEL +4 -0
- onefinance-0.1.0.dist-info/entry_points.txt +2 -0
- onefinance-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
onefinance/audit/log.py
ADDED
|
@@ -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
|
+
]
|
onefinance/cache/keys.py
ADDED
|
@@ -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)
|