tt-data-store 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.
- tt_data_store/__init__.py +14 -0
- tt_data_store/atomic.py +22 -0
- tt_data_store/calendar.py +98 -0
- tt_data_store/candles.py +523 -0
- tt_data_store/db.py +40 -0
- tt_data_store/files.py +104 -0
- tt_data_store/gaps.py +60 -0
- tt_data_store/instruments.py +161 -0
- tt_data_store/parquet.py +44 -0
- tt_data_store/paths.py +129 -0
- tt_data_store/remote.py +467 -0
- tt_data_store/schema.py +30 -0
- tt_data_store/schema.sql +217 -0
- tt_data_store/store.py +191 -0
- tt_data_store/validation.py +292 -0
- tt_data_store-0.1.0.dist-info/METADATA +244 -0
- tt_data_store-0.1.0.dist-info/RECORD +20 -0
- tt_data_store-0.1.0.dist-info/WHEEL +5 -0
- tt_data_store-0.1.0.dist-info/licenses/LICENSE +185 -0
- tt_data_store-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Canonical market data archive library."""
|
|
2
|
+
|
|
3
|
+
from tt_data_store.store import MarketStore
|
|
4
|
+
from tt_data_store.remote import RemoteConfig, SyncConflict, TransferResult
|
|
5
|
+
from tt_data_store.validation import ValidationError, ValidationResult
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"MarketStore",
|
|
9
|
+
"RemoteConfig",
|
|
10
|
+
"SyncConflict",
|
|
11
|
+
"TransferResult",
|
|
12
|
+
"ValidationError",
|
|
13
|
+
"ValidationResult",
|
|
14
|
+
]
|
tt_data_store/atomic.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Atomic Parquet replacement (doc 06)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def atomic_replace(temp_path: Path, target_path: Path) -> None:
|
|
10
|
+
"""Replace target with temp via sync + os.replace.
|
|
11
|
+
|
|
12
|
+
Temporary files live beside the target as ``.{name}.tmp`` (doc 06).
|
|
13
|
+
"""
|
|
14
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
with open(temp_path, "rb") as f:
|
|
16
|
+
f.flush()
|
|
17
|
+
os.fsync(f.fileno())
|
|
18
|
+
os.replace(temp_path, target_path)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def temp_path_for(target_path: Path) -> Path:
|
|
22
|
+
return target_path.parent / f".{target_path.name}.tmp"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Market calendar sessions → expected timestamps (doc 08)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import date, datetime, timedelta
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from zoneinfo import ZoneInfo
|
|
8
|
+
|
|
9
|
+
import pyarrow as pa
|
|
10
|
+
import pyarrow.parquet as pq
|
|
11
|
+
|
|
12
|
+
from tt_data_store.schema import TZ
|
|
13
|
+
|
|
14
|
+
IST = ZoneInfo(TZ)
|
|
15
|
+
|
|
16
|
+
CALENDAR_SCHEMA = pa.schema(
|
|
17
|
+
[
|
|
18
|
+
pa.field("date", pa.date32(), nullable=False),
|
|
19
|
+
pa.field("market", pa.string(), nullable=False),
|
|
20
|
+
pa.field("session_start", pa.timestamp("us", tz=TZ), nullable=True),
|
|
21
|
+
pa.field("session_end", pa.timestamp("us", tz=TZ), nullable=True),
|
|
22
|
+
pa.field("event", pa.string(), nullable=True),
|
|
23
|
+
]
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def calendar_path(root: Path, market: str = "nse") -> Path:
|
|
28
|
+
return root / "reference" / "calendar" / f"{market.lower()}.parquet"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load_sessions(
|
|
32
|
+
root: Path,
|
|
33
|
+
*,
|
|
34
|
+
market: str = "NSE",
|
|
35
|
+
start: date | None = None,
|
|
36
|
+
end: date | None = None,
|
|
37
|
+
) -> list[tuple[datetime, datetime]]:
|
|
38
|
+
path = calendar_path(root, market="nse" if market.upper() == "NSE" else market.lower())
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return []
|
|
41
|
+
table = pq.read_table(path)
|
|
42
|
+
sessions: list[tuple[datetime, datetime]] = []
|
|
43
|
+
for i in range(table.num_rows):
|
|
44
|
+
mkt = table.column("market")[i].as_py()
|
|
45
|
+
if str(mkt).upper() != market.upper():
|
|
46
|
+
continue
|
|
47
|
+
d = table.column("date")[i].as_py()
|
|
48
|
+
if start is not None and d < start:
|
|
49
|
+
continue
|
|
50
|
+
if end is not None and d > end:
|
|
51
|
+
continue
|
|
52
|
+
s_start = table.column("session_start")[i].as_py()
|
|
53
|
+
s_end = table.column("session_end")[i].as_py()
|
|
54
|
+
if s_start is None or s_end is None:
|
|
55
|
+
# Holiday / non-trading row: no expected timestamps
|
|
56
|
+
continue
|
|
57
|
+
if s_start.tzinfo is None:
|
|
58
|
+
s_start = s_start.replace(tzinfo=IST)
|
|
59
|
+
else:
|
|
60
|
+
s_start = s_start.astimezone(IST)
|
|
61
|
+
if s_end.tzinfo is None:
|
|
62
|
+
s_end = s_end.replace(tzinfo=IST)
|
|
63
|
+
else:
|
|
64
|
+
s_end = s_end.astimezone(IST)
|
|
65
|
+
sessions.append((s_start, s_end))
|
|
66
|
+
return sessions
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def expected_timestamps(
|
|
70
|
+
sessions: list[tuple[datetime, datetime]],
|
|
71
|
+
) -> list[datetime]:
|
|
72
|
+
"""Generate 1-minute timestamps for half-open session [start, end).
|
|
73
|
+
|
|
74
|
+
Candles use timestamp = start of the minute (doc 02). If the market closes
|
|
75
|
+
at ``session_end`` (e.g. 15:30), the last expected bar is ``session_end - 1m``
|
|
76
|
+
(e.g. 15:29). ``session_end`` itself is not an expected candle timestamp.
|
|
77
|
+
"""
|
|
78
|
+
out: list[datetime] = []
|
|
79
|
+
for start, end in sessions:
|
|
80
|
+
ts = start
|
|
81
|
+
while ts < end:
|
|
82
|
+
out.append(ts)
|
|
83
|
+
ts += timedelta(minutes=1)
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_calendar(
|
|
88
|
+
root: Path,
|
|
89
|
+
rows: list[dict],
|
|
90
|
+
*,
|
|
91
|
+
market_file: str = "nse",
|
|
92
|
+
) -> Path:
|
|
93
|
+
"""Seed reference calendar (used by tests; not a public MarketStore write API)."""
|
|
94
|
+
path = calendar_path(root, market=market_file)
|
|
95
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
+
table = pa.Table.from_pylist(rows, schema=CALENDAR_SCHEMA)
|
|
97
|
+
pq.write_table(table, path)
|
|
98
|
+
return path
|
tt_data_store/candles.py
ADDED
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
"""Candle read/write pipeline (docs 04, 06, 10)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
from zoneinfo import ZoneInfo
|
|
9
|
+
|
|
10
|
+
import pyarrow as pa
|
|
11
|
+
import pyarrow.compute as pc
|
|
12
|
+
|
|
13
|
+
from tt_data_store.atomic import atomic_replace, temp_path_for
|
|
14
|
+
from tt_data_store.gaps import serialize_gaps
|
|
15
|
+
from tt_data_store.instruments import Instrument
|
|
16
|
+
from tt_data_store.paths import relative_path_for_instrument, years_in_range
|
|
17
|
+
from tt_data_store.parquet import empty_candles, file_sha256, read_candles, write_candles
|
|
18
|
+
from tt_data_store.schema import SCHEMA_VERSION, TZ
|
|
19
|
+
from tt_data_store.validation import (
|
|
20
|
+
VALIDATOR_VERSION,
|
|
21
|
+
ValidationError,
|
|
22
|
+
assess_quality,
|
|
23
|
+
dedupe_exact,
|
|
24
|
+
filter_to_session,
|
|
25
|
+
merge_tables,
|
|
26
|
+
normalize,
|
|
27
|
+
validate_hard,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from tt_data_store.store import MarketStore
|
|
32
|
+
|
|
33
|
+
IST = ZoneInfo(TZ)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class WriteResult:
|
|
38
|
+
instrument: str
|
|
39
|
+
rows_received: int
|
|
40
|
+
rows_inserted: int
|
|
41
|
+
rows_duplicate: int
|
|
42
|
+
rows_conflicted: int
|
|
43
|
+
rows_rejected: int
|
|
44
|
+
previous_rows: int
|
|
45
|
+
new_rows: int
|
|
46
|
+
previous_gaps: list[list[str]] | None
|
|
47
|
+
new_gaps: list[list[str]] | None
|
|
48
|
+
status: str
|
|
49
|
+
file_changed: bool
|
|
50
|
+
path: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _now() -> str:
|
|
54
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _resolve_instrument(store: MarketStore, instrument: Instrument | str) -> Instrument:
|
|
58
|
+
if isinstance(instrument, Instrument):
|
|
59
|
+
return instrument
|
|
60
|
+
found = store.instruments.get(instrument)
|
|
61
|
+
if found is None:
|
|
62
|
+
raise KeyError(f"unknown instrument: {instrument}")
|
|
63
|
+
return found
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _paths_for_table(instrument: Instrument, table: pa.Table) -> dict[str, pa.Table]:
|
|
67
|
+
"""Split a candle table into relative_path → subtable."""
|
|
68
|
+
parsed = instrument.parsed
|
|
69
|
+
if parsed.instrument_type != "INDEX":
|
|
70
|
+
path = relative_path_for_instrument(parsed)
|
|
71
|
+
return {path: table}
|
|
72
|
+
|
|
73
|
+
if table.num_rows == 0:
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
timestamps = table.column("timestamp").to_pylist()
|
|
77
|
+
by_year: dict[int, list[int]] = {}
|
|
78
|
+
for i, ts in enumerate(timestamps):
|
|
79
|
+
year = ts.astimezone(IST).year
|
|
80
|
+
by_year.setdefault(year, []).append(i)
|
|
81
|
+
|
|
82
|
+
out: dict[str, pa.Table] = {}
|
|
83
|
+
for year, indices in by_year.items():
|
|
84
|
+
path = relative_path_for_instrument(parsed, year=year)
|
|
85
|
+
out[path] = table.take(pa.array(indices))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class CandlesAPI:
|
|
90
|
+
def __init__(self, store: MarketStore) -> None:
|
|
91
|
+
self._store = store
|
|
92
|
+
|
|
93
|
+
def read(
|
|
94
|
+
self,
|
|
95
|
+
instrument: Instrument | str,
|
|
96
|
+
start: str | datetime | None = None,
|
|
97
|
+
end: str | datetime | None = None,
|
|
98
|
+
) -> pa.Table:
|
|
99
|
+
inst = _resolve_instrument(self._store, instrument)
|
|
100
|
+
parsed = inst.parsed
|
|
101
|
+
tables: list[pa.Table] = []
|
|
102
|
+
|
|
103
|
+
if parsed.instrument_type == "INDEX":
|
|
104
|
+
if start is None or end is None:
|
|
105
|
+
# Read all known files for this instrument
|
|
106
|
+
files = self._store.files.list(instrument_id=inst.id)
|
|
107
|
+
paths = [f.path for f in files]
|
|
108
|
+
else:
|
|
109
|
+
paths = [
|
|
110
|
+
relative_path_for_instrument(parsed, year=y)
|
|
111
|
+
for y in years_in_range(start, end)
|
|
112
|
+
]
|
|
113
|
+
else:
|
|
114
|
+
paths = [relative_path_for_instrument(parsed)]
|
|
115
|
+
|
|
116
|
+
for rel in paths:
|
|
117
|
+
abs_path = self._store.root / rel
|
|
118
|
+
if abs_path.exists():
|
|
119
|
+
tables.append(read_candles(abs_path))
|
|
120
|
+
|
|
121
|
+
if not tables:
|
|
122
|
+
return empty_candles()
|
|
123
|
+
|
|
124
|
+
combined = pa.concat_tables(tables)
|
|
125
|
+
if start is not None:
|
|
126
|
+
start_ts = _parse_bound(start, end=False)
|
|
127
|
+
combined = combined.filter(
|
|
128
|
+
pc.greater_equal(combined.column("timestamp"), start_ts)
|
|
129
|
+
)
|
|
130
|
+
if end is not None:
|
|
131
|
+
end_ts = _parse_bound(end, end=True)
|
|
132
|
+
combined = combined.filter(
|
|
133
|
+
pc.less_equal(combined.column("timestamp"), end_ts)
|
|
134
|
+
)
|
|
135
|
+
if combined.num_rows == 0:
|
|
136
|
+
return empty_candles()
|
|
137
|
+
# Ensure sorted
|
|
138
|
+
indices = pc.sort_indices(combined, sort_keys=[("timestamp", "ascending")])
|
|
139
|
+
return combined.take(indices)
|
|
140
|
+
|
|
141
|
+
def write(
|
|
142
|
+
self,
|
|
143
|
+
instrument: Instrument | str,
|
|
144
|
+
candles: pa.Table | list[dict[str, Any]],
|
|
145
|
+
*,
|
|
146
|
+
source: str,
|
|
147
|
+
source_instrument_id: str | None = None,
|
|
148
|
+
collector_version: str | None = None,
|
|
149
|
+
requested_start: str | None = None,
|
|
150
|
+
requested_end: str | None = None,
|
|
151
|
+
) -> WriteResult:
|
|
152
|
+
inst = _resolve_instrument(self._store, instrument)
|
|
153
|
+
started_at = _now()
|
|
154
|
+
ingestion_id = self._insert_ingestion(
|
|
155
|
+
source=source,
|
|
156
|
+
source_instrument_id=source_instrument_id,
|
|
157
|
+
collector_version=collector_version,
|
|
158
|
+
requested_start=requested_start,
|
|
159
|
+
requested_end=requested_end,
|
|
160
|
+
started_at=started_at,
|
|
161
|
+
status="RUNNING",
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
if isinstance(candles, list):
|
|
166
|
+
table = pa.Table.from_pylist(candles)
|
|
167
|
+
else:
|
|
168
|
+
table = candles
|
|
169
|
+
|
|
170
|
+
rows_received = table.num_rows
|
|
171
|
+
table = normalize(table)
|
|
172
|
+
table, batch_dups = dedupe_exact(table)
|
|
173
|
+
validate_hard(table)
|
|
174
|
+
|
|
175
|
+
# Partition by target file
|
|
176
|
+
partitions = _paths_for_table(inst, table)
|
|
177
|
+
if not partitions:
|
|
178
|
+
self._finish_ingestion(
|
|
179
|
+
ingestion_id,
|
|
180
|
+
received=rows_received,
|
|
181
|
+
accepted=0,
|
|
182
|
+
rejected=0,
|
|
183
|
+
status="COMPLETED",
|
|
184
|
+
)
|
|
185
|
+
path = relative_path_for_instrument(inst.parsed) if inst.parsed.instrument_type != "INDEX" else ""
|
|
186
|
+
return WriteResult(
|
|
187
|
+
instrument=inst.instrument_key,
|
|
188
|
+
rows_received=rows_received,
|
|
189
|
+
rows_inserted=0,
|
|
190
|
+
rows_duplicate=batch_dups,
|
|
191
|
+
rows_conflicted=0,
|
|
192
|
+
rows_rejected=0,
|
|
193
|
+
previous_rows=0,
|
|
194
|
+
new_rows=0,
|
|
195
|
+
previous_gaps=None,
|
|
196
|
+
new_gaps=None,
|
|
197
|
+
status="UNKNOWN",
|
|
198
|
+
file_changed=False,
|
|
199
|
+
path=path,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# For multi-year INDEX writes, process each partition; return last/aggregate
|
|
203
|
+
total_inserted = 0
|
|
204
|
+
total_dup = batch_dups
|
|
205
|
+
total_rejected = 0
|
|
206
|
+
last_result: WriteResult | None = None
|
|
207
|
+
|
|
208
|
+
for rel_path, part in partitions.items():
|
|
209
|
+
result = self._write_partition(
|
|
210
|
+
inst=inst,
|
|
211
|
+
rel_path=rel_path,
|
|
212
|
+
incoming=part,
|
|
213
|
+
rows_received=part.num_rows,
|
|
214
|
+
batch_dups=0,
|
|
215
|
+
)
|
|
216
|
+
total_inserted += result.rows_inserted
|
|
217
|
+
total_dup += result.rows_duplicate
|
|
218
|
+
total_rejected += result.rows_rejected
|
|
219
|
+
last_result = result
|
|
220
|
+
|
|
221
|
+
assert last_result is not None
|
|
222
|
+
self._finish_ingestion(
|
|
223
|
+
ingestion_id,
|
|
224
|
+
received=rows_received,
|
|
225
|
+
accepted=total_inserted,
|
|
226
|
+
rejected=total_rejected,
|
|
227
|
+
status="COMPLETED",
|
|
228
|
+
)
|
|
229
|
+
return WriteResult(
|
|
230
|
+
instrument=inst.instrument_key,
|
|
231
|
+
rows_received=rows_received,
|
|
232
|
+
rows_inserted=total_inserted,
|
|
233
|
+
rows_duplicate=total_dup,
|
|
234
|
+
rows_conflicted=0,
|
|
235
|
+
rows_rejected=total_rejected,
|
|
236
|
+
previous_rows=last_result.previous_rows,
|
|
237
|
+
new_rows=last_result.new_rows,
|
|
238
|
+
previous_gaps=last_result.previous_gaps,
|
|
239
|
+
new_gaps=last_result.new_gaps,
|
|
240
|
+
status=last_result.status,
|
|
241
|
+
file_changed=last_result.file_changed,
|
|
242
|
+
path=last_result.path,
|
|
243
|
+
)
|
|
244
|
+
except ValidationError:
|
|
245
|
+
self._finish_ingestion(
|
|
246
|
+
ingestion_id,
|
|
247
|
+
received=0,
|
|
248
|
+
accepted=0,
|
|
249
|
+
rejected=0,
|
|
250
|
+
status="FAILED",
|
|
251
|
+
)
|
|
252
|
+
raise
|
|
253
|
+
except Exception:
|
|
254
|
+
self._finish_ingestion(
|
|
255
|
+
ingestion_id,
|
|
256
|
+
received=0,
|
|
257
|
+
accepted=0,
|
|
258
|
+
rejected=0,
|
|
259
|
+
status="FAILED",
|
|
260
|
+
)
|
|
261
|
+
raise
|
|
262
|
+
|
|
263
|
+
def _write_partition(
|
|
264
|
+
self,
|
|
265
|
+
*,
|
|
266
|
+
inst: Instrument,
|
|
267
|
+
rel_path: str,
|
|
268
|
+
incoming: pa.Table,
|
|
269
|
+
rows_received: int,
|
|
270
|
+
batch_dups: int,
|
|
271
|
+
) -> WriteResult:
|
|
272
|
+
abs_path = self._store.root / rel_path
|
|
273
|
+
existing = read_candles(abs_path) if abs_path.exists() else empty_candles()
|
|
274
|
+
previous_rows = existing.num_rows
|
|
275
|
+
|
|
276
|
+
prev_meta = self._store.files.get(rel_path)
|
|
277
|
+
previous_gaps = prev_meta.gaps if prev_meta else None
|
|
278
|
+
|
|
279
|
+
try:
|
|
280
|
+
merged, inserted, duplicates = merge_tables(existing, incoming)
|
|
281
|
+
except ValidationError:
|
|
282
|
+
# Conflict: existing untouched
|
|
283
|
+
raise
|
|
284
|
+
|
|
285
|
+
validate_hard(merged)
|
|
286
|
+
|
|
287
|
+
# Drop out-of-session rows from canonical file when calendar exists
|
|
288
|
+
filtered, rejected = filter_to_session(
|
|
289
|
+
self._store.root, merged, exchange=inst.exchange
|
|
290
|
+
)
|
|
291
|
+
if rejected:
|
|
292
|
+
merged = filtered
|
|
293
|
+
validate_hard(merged)
|
|
294
|
+
|
|
295
|
+
quality = assess_quality(self._store.root, merged, exchange=inst.exchange)
|
|
296
|
+
|
|
297
|
+
file_changed = inserted > 0 or not abs_path.exists() or rejected > 0
|
|
298
|
+
# Also change if first write of empty→empty? no
|
|
299
|
+
if inserted == 0 and duplicates == rows_received and abs_path.exists() and rejected == 0:
|
|
300
|
+
file_changed = False
|
|
301
|
+
|
|
302
|
+
if file_changed or not abs_path.exists():
|
|
303
|
+
tmp = temp_path_for(abs_path)
|
|
304
|
+
try:
|
|
305
|
+
write_candles(tmp, merged)
|
|
306
|
+
atomic_replace(tmp, abs_path)
|
|
307
|
+
finally:
|
|
308
|
+
if tmp.exists():
|
|
309
|
+
tmp.unlink(missing_ok=True)
|
|
310
|
+
|
|
311
|
+
checksum = file_sha256(abs_path)
|
|
312
|
+
size_bytes = abs_path.stat().st_size
|
|
313
|
+
start_time = end_time = None
|
|
314
|
+
if merged.num_rows:
|
|
315
|
+
ts = merged.column("timestamp").to_pylist()
|
|
316
|
+
start_time = ts[0].astimezone(IST).isoformat()
|
|
317
|
+
end_time = ts[-1].astimezone(IST).isoformat()
|
|
318
|
+
|
|
319
|
+
self._upsert_file(
|
|
320
|
+
instrument_id=inst.id,
|
|
321
|
+
path=rel_path,
|
|
322
|
+
start_time=start_time,
|
|
323
|
+
end_time=end_time,
|
|
324
|
+
quality=quality,
|
|
325
|
+
size_bytes=size_bytes,
|
|
326
|
+
checksum=checksum,
|
|
327
|
+
)
|
|
328
|
+
elif prev_meta is None and abs_path.exists():
|
|
329
|
+
# Register existing file metadata after no-op merge on first catalogue miss
|
|
330
|
+
checksum = file_sha256(abs_path)
|
|
331
|
+
size_bytes = abs_path.stat().st_size
|
|
332
|
+
start_time = end_time = None
|
|
333
|
+
if merged.num_rows:
|
|
334
|
+
ts = merged.column("timestamp").to_pylist()
|
|
335
|
+
start_time = ts[0].astimezone(IST).isoformat()
|
|
336
|
+
end_time = ts[-1].astimezone(IST).isoformat()
|
|
337
|
+
self._upsert_file(
|
|
338
|
+
instrument_id=inst.id,
|
|
339
|
+
path=rel_path,
|
|
340
|
+
start_time=start_time,
|
|
341
|
+
end_time=end_time,
|
|
342
|
+
quality=quality,
|
|
343
|
+
size_bytes=size_bytes,
|
|
344
|
+
checksum=checksum,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
return WriteResult(
|
|
348
|
+
instrument=inst.instrument_key,
|
|
349
|
+
rows_received=rows_received,
|
|
350
|
+
rows_inserted=inserted,
|
|
351
|
+
rows_duplicate=duplicates + batch_dups,
|
|
352
|
+
rows_conflicted=0,
|
|
353
|
+
rows_rejected=rejected,
|
|
354
|
+
previous_rows=previous_rows,
|
|
355
|
+
new_rows=merged.num_rows,
|
|
356
|
+
previous_gaps=previous_gaps,
|
|
357
|
+
new_gaps=quality.gaps,
|
|
358
|
+
status=quality.status,
|
|
359
|
+
file_changed=file_changed,
|
|
360
|
+
path=rel_path,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
def _upsert_file(
|
|
364
|
+
self,
|
|
365
|
+
*,
|
|
366
|
+
instrument_id: int,
|
|
367
|
+
path: str,
|
|
368
|
+
start_time: str | None,
|
|
369
|
+
end_time: str | None,
|
|
370
|
+
quality: Any,
|
|
371
|
+
size_bytes: int,
|
|
372
|
+
checksum: str,
|
|
373
|
+
) -> None:
|
|
374
|
+
now = _now()
|
|
375
|
+
gaps_raw = serialize_gaps(quality.gaps)
|
|
376
|
+
existing = self._store.files.get(path)
|
|
377
|
+
conn = self._store.conn
|
|
378
|
+
if existing is None:
|
|
379
|
+
cur = conn.execute(
|
|
380
|
+
"""
|
|
381
|
+
INSERT INTO files (
|
|
382
|
+
instrument_id, path, format, start_time, end_time,
|
|
383
|
+
expected_rows, actual_rows, missing_rows, duplicate_rows,
|
|
384
|
+
invalid_rows, gaps, size_bytes, checksum_sha256,
|
|
385
|
+
schema_version, status, created_at, updated_at, validated_at
|
|
386
|
+
) VALUES (?, ?, 'parquet', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
387
|
+
""",
|
|
388
|
+
(
|
|
389
|
+
instrument_id,
|
|
390
|
+
path,
|
|
391
|
+
start_time,
|
|
392
|
+
end_time,
|
|
393
|
+
quality.expected_rows,
|
|
394
|
+
quality.actual_rows,
|
|
395
|
+
quality.missing_rows,
|
|
396
|
+
quality.duplicate_rows,
|
|
397
|
+
quality.invalid_rows,
|
|
398
|
+
gaps_raw,
|
|
399
|
+
size_bytes,
|
|
400
|
+
checksum,
|
|
401
|
+
SCHEMA_VERSION,
|
|
402
|
+
quality.status,
|
|
403
|
+
now,
|
|
404
|
+
now,
|
|
405
|
+
now,
|
|
406
|
+
),
|
|
407
|
+
)
|
|
408
|
+
file_id = cur.lastrowid
|
|
409
|
+
else:
|
|
410
|
+
conn.execute(
|
|
411
|
+
"""
|
|
412
|
+
UPDATE files SET
|
|
413
|
+
start_time = ?, end_time = ?,
|
|
414
|
+
expected_rows = ?, actual_rows = ?,
|
|
415
|
+
missing_rows = ?, duplicate_rows = ?, invalid_rows = ?,
|
|
416
|
+
gaps = ?, size_bytes = ?, checksum_sha256 = ?,
|
|
417
|
+
schema_version = ?, status = ?,
|
|
418
|
+
updated_at = ?, validated_at = ?
|
|
419
|
+
WHERE path = ?
|
|
420
|
+
""",
|
|
421
|
+
(
|
|
422
|
+
start_time,
|
|
423
|
+
end_time,
|
|
424
|
+
quality.expected_rows,
|
|
425
|
+
quality.actual_rows,
|
|
426
|
+
quality.missing_rows,
|
|
427
|
+
quality.duplicate_rows,
|
|
428
|
+
quality.invalid_rows,
|
|
429
|
+
gaps_raw,
|
|
430
|
+
size_bytes,
|
|
431
|
+
checksum,
|
|
432
|
+
SCHEMA_VERSION,
|
|
433
|
+
quality.status,
|
|
434
|
+
now,
|
|
435
|
+
now,
|
|
436
|
+
path,
|
|
437
|
+
),
|
|
438
|
+
)
|
|
439
|
+
file_id = existing.id
|
|
440
|
+
|
|
441
|
+
conn.execute(
|
|
442
|
+
"""
|
|
443
|
+
INSERT INTO validation_runs (
|
|
444
|
+
file_id, validator_version, expected_rows, actual_rows,
|
|
445
|
+
missing_rows, duplicate_rows, invalid_rows, status,
|
|
446
|
+
started_at, completed_at
|
|
447
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
448
|
+
""",
|
|
449
|
+
(
|
|
450
|
+
file_id,
|
|
451
|
+
VALIDATOR_VERSION,
|
|
452
|
+
quality.expected_rows,
|
|
453
|
+
quality.actual_rows,
|
|
454
|
+
quality.missing_rows,
|
|
455
|
+
quality.duplicate_rows,
|
|
456
|
+
quality.invalid_rows,
|
|
457
|
+
quality.status,
|
|
458
|
+
now,
|
|
459
|
+
now,
|
|
460
|
+
),
|
|
461
|
+
)
|
|
462
|
+
conn.commit()
|
|
463
|
+
|
|
464
|
+
def _insert_ingestion(self, **kwargs: Any) -> int:
|
|
465
|
+
cur = self._store.conn.execute(
|
|
466
|
+
"""
|
|
467
|
+
INSERT INTO ingestions (
|
|
468
|
+
source, source_instrument_id, requested_start, requested_end,
|
|
469
|
+
received_rows, accepted_rows, rejected_rows, collector_version,
|
|
470
|
+
started_at, completed_at, status
|
|
471
|
+
) VALUES (?, ?, ?, ?, 0, 0, 0, ?, ?, NULL, ?)
|
|
472
|
+
""",
|
|
473
|
+
(
|
|
474
|
+
kwargs["source"],
|
|
475
|
+
kwargs.get("source_instrument_id"),
|
|
476
|
+
kwargs.get("requested_start"),
|
|
477
|
+
kwargs.get("requested_end"),
|
|
478
|
+
kwargs.get("collector_version"),
|
|
479
|
+
kwargs["started_at"],
|
|
480
|
+
kwargs["status"],
|
|
481
|
+
),
|
|
482
|
+
)
|
|
483
|
+
self._store.conn.commit()
|
|
484
|
+
return int(cur.lastrowid)
|
|
485
|
+
|
|
486
|
+
def _finish_ingestion(
|
|
487
|
+
self,
|
|
488
|
+
ingestion_id: int,
|
|
489
|
+
*,
|
|
490
|
+
received: int,
|
|
491
|
+
accepted: int,
|
|
492
|
+
rejected: int,
|
|
493
|
+
status: str,
|
|
494
|
+
) -> None:
|
|
495
|
+
self._store.conn.execute(
|
|
496
|
+
"""
|
|
497
|
+
UPDATE ingestions SET
|
|
498
|
+
received_rows = ?, accepted_rows = ?, rejected_rows = ?,
|
|
499
|
+
completed_at = ?, status = ?
|
|
500
|
+
WHERE id = ?
|
|
501
|
+
""",
|
|
502
|
+
(received, accepted, rejected, _now(), status, ingestion_id),
|
|
503
|
+
)
|
|
504
|
+
self._store.conn.commit()
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _parse_bound(value: str | datetime, *, end: bool) -> datetime:
|
|
508
|
+
if isinstance(value, datetime):
|
|
509
|
+
ts = value
|
|
510
|
+
else:
|
|
511
|
+
text = value
|
|
512
|
+
if len(text) == 10:
|
|
513
|
+
# date only
|
|
514
|
+
if end:
|
|
515
|
+
text = text + "T23:59:00"
|
|
516
|
+
else:
|
|
517
|
+
text = text + "T00:00:00"
|
|
518
|
+
ts = datetime.fromisoformat(text)
|
|
519
|
+
if ts.tzinfo is None:
|
|
520
|
+
ts = ts.replace(tzinfo=IST)
|
|
521
|
+
else:
|
|
522
|
+
ts = ts.astimezone(IST)
|
|
523
|
+
return ts
|
tt_data_store/db.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""SQLite metadata database for the archive catalogue."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sqlite3
|
|
6
|
+
from importlib import resources
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def connect(db_path: Path) -> sqlite3.Connection:
|
|
11
|
+
conn = sqlite3.connect(db_path)
|
|
12
|
+
conn.row_factory = sqlite3.Row
|
|
13
|
+
conn.execute("PRAGMA foreign_keys = ON")
|
|
14
|
+
return conn
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def init_schema(conn: sqlite3.Connection) -> None:
|
|
18
|
+
sql = resources.files("tt_data_store").joinpath("schema.sql").read_text(encoding="utf-8")
|
|
19
|
+
conn.executescript(sql)
|
|
20
|
+
conn.commit()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def migrate_schema(conn: sqlite3.Connection) -> None:
|
|
24
|
+
"""Apply additive migrations for existing archives."""
|
|
25
|
+
cols = {row[1] for row in conn.execute("PRAGMA table_info(files)")}
|
|
26
|
+
if "last_synced_checksum_sha256" not in cols:
|
|
27
|
+
conn.execute(
|
|
28
|
+
"ALTER TABLE files ADD COLUMN last_synced_checksum_sha256 TEXT"
|
|
29
|
+
)
|
|
30
|
+
conn.commit()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def open_or_create(db_path: Path) -> sqlite3.Connection:
|
|
34
|
+
exists = db_path.exists()
|
|
35
|
+
conn = connect(db_path)
|
|
36
|
+
if not exists:
|
|
37
|
+
init_schema(conn)
|
|
38
|
+
else:
|
|
39
|
+
migrate_schema(conn)
|
|
40
|
+
return conn
|