vikron-nt 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.
vikron_nt/__init__.py ADDED
@@ -0,0 +1,40 @@
1
+ """Vikron NT: consumer-side Catalog registration and reading.
2
+
3
+ Vikron independently develops and maintains this integration. Nautech Systems Pty Ltd and the NautilusTrader project have no affiliation with it and do not endorse or support it.
4
+ """
5
+ from .data import (
6
+ Liquidation, OpenInterestUpdate, LastPriceUpdate,
7
+ PredictedFundingRateUpdate, register_custom_types,
8
+ )
9
+ from .official_v1 import prepare_catalog_v1
10
+ from ._paths import catalog_root
11
+
12
+
13
+ def prepare_catalog(path, *, fallback=None):
14
+ """Initialize an existing local catalog and return its absolute path.
15
+
16
+ Selects compatibility setup from the installed NautilusTrader version.
17
+ Call once per process, before using official Catalog or BacktestNode APIs.
18
+ The returned path keeps the caller's form: mapped network drives are not
19
+ expanded to UNC paths, which NautilusTrader's Rust catalog cannot open.
20
+
21
+ ``fallback="downcast"`` applies to catalog v1 on a 64-bit runtime (always the
22
+ case on Windows): a 16-byte fixed-point catalog is converted to an 8-byte
23
+ copy once and that copy is returned. The default raises instead, and
24
+ ``VIKRON_WIDE_PRECISION_FALLBACK=downcast`` sets the choice globally.
25
+ """
26
+ from .data import V2
27
+
28
+ if not V2:
29
+ return prepare_catalog_v1(path, fallback=fallback)
30
+ root = catalog_root(path)
31
+ if not (root / "data").is_dir():
32
+ raise ValueError(f"Not a Catalog directory: {root} (missing data directory)")
33
+ register_custom_types()
34
+ return str(root)
35
+
36
+ __all__ = [
37
+ "register_custom_types", "prepare_catalog", "prepare_catalog_v1", "Liquidation",
38
+ "OpenInterestUpdate", "LastPriceUpdate",
39
+ "PredictedFundingRateUpdate",
40
+ ]
vikron_nt/_compat.py ADDED
@@ -0,0 +1,17 @@
1
+ """Supported Nautilus Python version families."""
2
+ from packaging.specifiers import SpecifierSet
3
+ from packaging.version import Version
4
+
5
+ SUPPORTED_VERSIONS = {
6
+ 1: SpecifierSet(">=1.226.0,<2.0.0"),
7
+ 2: SpecifierSet(">=2.0.0rc6"),
8
+ }
9
+
10
+
11
+ def catalog_version(nautilus_version):
12
+ version = Version(nautilus_version)
13
+ for catalog, supported in SUPPORTED_VERSIONS.items():
14
+ if supported.contains(version, prereleases=True):
15
+ return catalog
16
+ ranges = "; ".join(f"v{catalog}: {spec}" for catalog, spec in SUPPORTED_VERSIONS.items())
17
+ raise ImportError(f"Unsupported Nautilus version {version}; supported ranges: {ranges}")
vikron_nt/_paths.py ADDED
@@ -0,0 +1,160 @@
1
+ """Filesystem helpers shared by the v1 and v2 catalog initialisers.
2
+
3
+ Two Windows behaviours are handled here:
4
+
5
+ * ``Path.resolve()`` expands a mapped network drive (``Z:\\``) into a UNC path
6
+ (``\\\\server\\share``). NautilusTrader's Rust catalog cannot open UNC paths,
7
+ so the caller's own form is preserved instead.
8
+ * Creating a directory symlink needs ``SeCreateSymbolicLinkPrivilege`` (Developer
9
+ Mode or an elevated terminal). When that fails, an NTFS *junction*
10
+ (``mklink /J``) is created instead: it needs no privilege and behaves the same
11
+ for NautilusTrader, which only needs to resolve the alias to a directory.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import subprocess
17
+ from pathlib import Path
18
+
19
+ __all__ = [
20
+ "catalog_root", "create_directory_link", "is_directory_link",
21
+ "remove_directory_link", "normalize",
22
+ ]
23
+
24
+ # Symlink creation errors that justify the junction fallback on Windows:
25
+ # 1314 = privilege not held, 5 = access denied, 1 = unspecified.
26
+ _WINDOWS_LINK_ERRORS = frozenset({1, 5, 1314})
27
+
28
+
29
+ def normalize(path: str | os.PathLike[str]) -> str:
30
+ """Return a comparison key for *path* that survives junctions and drive maps."""
31
+ return os.path.normcase(os.path.abspath(os.fspath(path)))
32
+
33
+
34
+ _mapped_shares: dict[str, Path] = {}
35
+
36
+
37
+ def _net_use_mappings() -> dict[str, str]:
38
+ """Return ``{r'\\\\server\\share': 'Z:'}`` for the current user's mappings."""
39
+ try:
40
+ result = subprocess.run(["net", "use"], capture_output=True, text=True,
41
+ encoding="utf-8", errors="replace",
42
+ stdin=subprocess.DEVNULL, timeout=30)
43
+ except (OSError, subprocess.SubprocessError): # pragma: no cover - `net.exe` unavailable
44
+ return {}
45
+ mappings: dict[str, str] = {}
46
+ for line in (result.stdout or "").splitlines():
47
+ tokens = line.split()
48
+ for index, token in enumerate(tokens):
49
+ if len(token) == 2 and token[1] == ":" and token[0].isalpha():
50
+ for following in tokens[index + 1:]:
51
+ if following.startswith("\\\\"):
52
+ mappings[following.rstrip("\\").lower()] = token.upper()
53
+ break
54
+ return mappings
55
+
56
+
57
+ def map_unc_to_drive(path: Path) -> Path:
58
+ """Return a drive-letter path for a UNC path, mapping the share if needed.
59
+
60
+ NautilusTrader's Rust catalog cannot open ``\\\\server\\share`` paths, so the
61
+ share is mapped to a free drive letter (no privilege required, and the same
62
+ thing Explorer does). A share that is already mapped is reused.
63
+ """
64
+ text = str(path)
65
+ if os.name != "nt" or not text.startswith("\\\\"):
66
+ return path
67
+ server, _, remainder = text[2:].partition("\\")
68
+ share, _, rest = remainder.partition("\\")
69
+ if not server or not share:
70
+ return path
71
+ key = f"\\\\{server}\\{share}".lower()
72
+ cached = _mapped_shares.get(key)
73
+ if cached is not None:
74
+ return cached / rest
75
+
76
+ mappings = _net_use_mappings()
77
+ if key in mappings:
78
+ drive = Path(f"{mappings[key]}\\")
79
+ _mapped_shares[key] = drive
80
+ return drive / rest
81
+
82
+ for letter in "ZYXWVUTSRQPONMLKJIHGFED":
83
+ if Path(f"{letter}:\\").exists():
84
+ continue
85
+ try:
86
+ result = subprocess.run(["net", "use", f"{letter}:", f"\\\\{server}\\{share}",
87
+ "/persistent:no"], capture_output=True, text=True,
88
+ encoding="utf-8", errors="replace",
89
+ stdin=subprocess.DEVNULL, timeout=60)
90
+ except (OSError, subprocess.SubprocessError):
91
+ # `net use` failed or hung (e.g. it wanted credentials): try the
92
+ # next letter, and never block the caller on an interactive prompt.
93
+ continue
94
+ if result.returncode != 0:
95
+ continue
96
+ drive = Path(f"{letter}:\\")
97
+ _mapped_shares[key] = drive
98
+ return drive / rest
99
+ raise OSError(f"could not map {server}\\{share} to a drive letter; "
100
+ "map it manually with `net use Z: \\\\server\\share`")
101
+
102
+
103
+ def catalog_root(path: str | os.PathLike[str]) -> Path:
104
+ """Return an absolute catalog root without resolving links or drive maps.
105
+
106
+ UNC paths are mapped to a drive letter on Windows because the Rust catalog
107
+ cannot open them. Raises ``FileNotFoundError`` when the path does not exist,
108
+ matching the previous ``Path.resolve(strict=True)`` behaviour.
109
+ """
110
+ root = Path(os.path.abspath(os.path.expanduser(os.fspath(path))))
111
+ root = map_unc_to_drive(root)
112
+ if not root.exists():
113
+ raise FileNotFoundError(f"Catalog directory does not exist: {root}")
114
+ return root
115
+
116
+
117
+ def is_directory_link(path: Path) -> bool:
118
+ """Return True for a symlink or an NTFS junction."""
119
+ if path.is_symlink():
120
+ return True
121
+ is_junction = getattr(path, "is_junction", None)
122
+ try:
123
+ return bool(is_junction is not None and is_junction())
124
+ except OSError: # pragma: no cover - unreadable reparse point
125
+ return False
126
+
127
+
128
+ def _create_junction(alias: Path, target: Path) -> None:
129
+ result = subprocess.run(
130
+ ["cmd", "/c", "mklink", "/J", str(alias), str(target.resolve())],
131
+ capture_output=True, text=True, encoding="utf-8", errors="replace",
132
+ )
133
+ if result.returncode != 0:
134
+ detail = (result.stderr or result.stdout or "").strip()
135
+ raise OSError(f"failed to create junction {alias} -> {target}: {detail}")
136
+
137
+
138
+ def create_directory_link(alias: Path, target: Path) -> None:
139
+ """Point *alias* at the *target* directory.
140
+
141
+ Tries a relative symlink first. On Windows, when symlink creation is denied,
142
+ falls back to an NTFS junction so that unprivileged accounts work too.
143
+ """
144
+ try:
145
+ alias.symlink_to(os.path.relpath(target, alias.parent), target_is_directory=True)
146
+ return
147
+ except OSError as exc:
148
+ if os.name != "nt" or getattr(exc, "winerror", None) not in _WINDOWS_LINK_ERRORS:
149
+ raise
150
+ _create_junction(alias, target)
151
+
152
+
153
+ def remove_directory_link(path: Path) -> None:
154
+ """Remove a symlink or junction without touching the directory it points at."""
155
+ if path.is_symlink():
156
+ path.unlink()
157
+ elif is_directory_link(path):
158
+ os.rmdir(path) # junction: removes the reparse point only
159
+ else:
160
+ raise ValueError(f"{path} is not a directory link")
vikron_nt/data.py ADDED
@@ -0,0 +1,142 @@
1
+ import json
2
+ from decimal import Decimal
3
+ from importlib.metadata import version
4
+
5
+ import pyarrow as pa
6
+
7
+ from ._compat import catalog_version
8
+
9
+ NAUTILUS_VERSION = version("nautilus_trader")
10
+ V2 = catalog_version(NAUTILUS_VERSION) == 2
11
+ if V2:
12
+ from nautilus_trader.model import InstrumentId
13
+ BaseData = object
14
+ else:
15
+ from nautilus_trader.core.data import Data as BaseData
16
+ from nautilus_trader.model.identifiers import InstrumentId
17
+
18
+
19
+ class _Observation(BaseData):
20
+ fields = ()
21
+ decimals = ()
22
+
23
+ def __init__(self, instrument_id, ts_event, ts_init, **values):
24
+ if set(values) != set(self.fields):
25
+ raise ValueError(f"{type(self).__name__} requires fields {self.fields}")
26
+ self.instrument_id = InstrumentId.from_str(str(instrument_id))
27
+ self._ts_event = int(ts_event)
28
+ self._ts_init = int(ts_init)
29
+ if not (0 <= self._ts_event < 2**64 and 0 <= self._ts_init < 2**64):
30
+ raise ValueError("invalid event/init timestamps")
31
+ for name, value in values.items():
32
+ setattr(self, name, Decimal(str(value)) if name in self.decimals else value)
33
+
34
+ @property
35
+ def ts_event(self):
36
+ return self._ts_event
37
+
38
+ @property
39
+ def ts_init(self):
40
+ return self._ts_init
41
+
42
+ @classmethod
43
+ def type_name_static(cls):
44
+ return f"Vikron{cls.__name__}"
45
+
46
+ def to_dict(self):
47
+ return dict(
48
+ type=self.type_name_static(), instrument_id=str(self.instrument_id),
49
+ ts_event=self.ts_event, ts_init=self.ts_init,
50
+ **{name: str(getattr(self, name)) if name in self.decimals else getattr(self, name)
51
+ for name in self.fields},
52
+ )
53
+
54
+ @classmethod
55
+ def from_dict(cls, values):
56
+ values = dict(values)
57
+ if values.pop("type", cls.type_name_static()) != cls.type_name_static():
58
+ raise ValueError("unexpected custom data type")
59
+ return cls(**values)
60
+
61
+ def to_json(self):
62
+ return json.dumps(self.to_dict())
63
+
64
+ @classmethod
65
+ def from_json(cls, value):
66
+ return cls.from_dict(value if isinstance(value, dict) else json.loads(value))
67
+
68
+ @classmethod
69
+ def arrow_schema_py(cls):
70
+ timestamp = pa.timestamp("ns", tz="UTC") if V2 else pa.uint64()
71
+ return pa.schema([
72
+ pa.field("payload", pa.string(), nullable=False),
73
+ pa.field("ts_event", timestamp, nullable=False),
74
+ pa.field("ts_init", timestamp, nullable=False),
75
+ ])
76
+
77
+ def encode_record_batch_py(self, items):
78
+ return pa.RecordBatch.from_arrays([
79
+ pa.array([item.to_json() for item in items]),
80
+ pa.array([item.ts_event for item in items], type=pa.int64() if V2 else pa.uint64()).cast(self.arrow_schema_py().field("ts_event").type),
81
+ pa.array([item.ts_init for item in items], type=pa.int64() if V2 else pa.uint64()).cast(self.arrow_schema_py().field("ts_init").type),
82
+ ], schema=self.arrow_schema_py())
83
+
84
+ @classmethod
85
+ def decode_record_batch_py(cls, metadata, batch):
86
+ times = batch.column("ts_init").cast(pa.int64() if V2 else pa.uint64()).to_pylist()
87
+ events = batch.column("ts_event").cast(pa.int64() if V2 else pa.uint64()).to_pylist()
88
+ rows = []
89
+ for payload, event, init in zip(batch.column("payload").to_pylist(), events, times):
90
+ values = json.loads(payload)
91
+ values.update(ts_event=event, ts_init=init)
92
+ rows.append(cls.from_dict(values))
93
+ return rows
94
+
95
+
96
+ class Liquidation(_Observation):
97
+ fields = ("source_id", "side", "price", "size")
98
+ decimals = ("price", "size")
99
+
100
+
101
+ class OpenInterestUpdate(_Observation):
102
+ fields = ("open_interest",)
103
+ decimals = fields
104
+
105
+
106
+ class LastPriceUpdate(_Observation):
107
+ fields = ("price",)
108
+ decimals = fields
109
+
110
+
111
+ class PredictedFundingRateUpdate(_Observation):
112
+ fields = ("rate", "funding_timestamp")
113
+ decimals = ("rate",)
114
+
115
+
116
+ CUSTOM_TYPES = (
117
+ Liquidation, OpenInterestUpdate,
118
+ LastPriceUpdate, PredictedFundingRateUpdate,
119
+ )
120
+ _registered = False
121
+
122
+
123
+ def register_custom_types():
124
+ """Register once per interpreter, before querying/subscribing to custom data."""
125
+ global _registered
126
+ if _registered:
127
+ return
128
+ if V2:
129
+ from nautilus_trader.model import register_custom_data_class
130
+ for cls in CUSTOM_TYPES:
131
+ register_custom_data_class(cls)
132
+ else:
133
+ from nautilus_trader.serialization import register_serializable_type
134
+ from nautilus_trader.serialization.arrow.serializer import register_arrow
135
+ for cls in CUSTOM_TYPES:
136
+ register_serializable_type(cls, cls.to_dict, cls.from_dict)
137
+ register_arrow(
138
+ cls, cls.arrow_schema_py(),
139
+ encoder=lambda obj: obj.encode_record_batch_py([obj]),
140
+ decoder=lambda batch, cls=cls: cls.decode_record_batch_py({}, batch),
141
+ )
142
+ _registered = True
vikron_nt/downcast.py ADDED
@@ -0,0 +1,278 @@
1
+ """Downcast a Downloader catalog-v1 from 16-byte to 8-byte fixed point.
2
+
3
+ Official Downloader v1 catalogs store ``price``/``size``/``value`` as 16-byte
4
+ (128-bit) fixed-point integers scaled by 1e-16. NautilusTrader's Python 1.x
5
+ wheels for Windows are always built with 64-bit precision
6
+ (``PRECISION_BYTES == 8``, scale 1e-9), so those files abort the Rust catalog
7
+ instead of raising. This module rewrites the same catalog with 8-byte columns,
8
+ keeping the decimal value (raw / 1e7) and every other column untouched, which
9
+ lets Windows read official v1 catalogs.
10
+
11
+ python -m vikron_nt.downcast SRC DST
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import os
17
+ import shutil
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ WIDE = 16 # bytes of the stored fixed point
22
+ NARROW = 8 # bytes the 64-bit runtime expects
23
+ WIDE_DECIMALS = 16 # 16-byte values are scaled by 1e-16
24
+ NARROW_DECIMALS = 9 # 8-byte values are scaled by 1e-9
25
+ SCALE = 10 ** (WIDE_DECIMALS - NARROW_DECIMALS) # 1e7
26
+
27
+
28
+ def _i128_to_i64(raw: bytes) -> int:
29
+ """Rescale a 128-bit fixed-point integer to 64-bit, rounding half away from zero."""
30
+ value = int.from_bytes(raw, "little", signed=True)
31
+ quotient, remainder = divmod(abs(value), SCALE)
32
+ if 2 * remainder >= SCALE:
33
+ quotient += 1
34
+ return -quotient if value < 0 else quotient
35
+
36
+
37
+ def _column_to_narrow(column):
38
+ """Convert one fixed_size_binary[16] column to fixed_size_binary[8].
39
+
40
+ Fast path: reinterpret the buffer as 128-bit little-endian words and do the
41
+ rescale with uint64 arithmetic after checking the rounded int64 range.
42
+ Wrapped intermediate sums are recomputed with Python integers; columns with
43
+ nulls and negative values use the per-value path.
44
+ """
45
+ import numpy as np
46
+ import pyarrow as pa
47
+
48
+ if isinstance(column, pa.ChunkedArray):
49
+ column = column.chunk(0) if column.num_chunks == 1 else column.combine_chunks()
50
+
51
+ if column.null_count == 0:
52
+ buffers = column.buffers()
53
+ raw = buffers[1] if len(buffers) > 1 else None
54
+ if raw is not None and len(raw) == len(column) * WIDE:
55
+ words = np.frombuffer(raw, dtype="<u8")
56
+ low = words[0::2].astype(np.uint64)
57
+ high = words[1::2].astype(np.uint64)
58
+ if not (high & np.uint64(1 << 63)).any():
59
+ # Check before uint64 arithmetic: the scaled result can wrap to
60
+ # a small positive number, so checking only the output is unsafe.
61
+ max_raw = (2 ** 63 - 1) * SCALE + SCALE // 2 - 1
62
+ max_high, max_low = divmod(max_raw, 1 << 64)
63
+ if ((high > max_high) | ((high == max_high) & (low > max_low))).any():
64
+ raise OverflowError("downcast value out of int64 range")
65
+ half = np.uint64(SCALE // 2)
66
+ quotient = np.uint64((1 << 64) // SCALE)
67
+ remainder = np.uint64((1 << 64) % SCALE)
68
+ carry = high * remainder + low + half
69
+ wrapped = carry < low
70
+ out = high * quotient + carry // np.uint64(SCALE)
71
+ if wrapped.any():
72
+ for index in np.nonzero(wrapped)[0]:
73
+ value = (int(high[index]) << 64) + int(low[index]) + SCALE // 2
74
+ out[index] = np.uint64(value // SCALE)
75
+ narrowed = out.astype("<i8")
76
+ return pa.Array.from_buffers(
77
+ pa.binary(NARROW), len(column),
78
+ [None, pa.py_buffer(narrowed.tobytes())])
79
+
80
+ out = []
81
+ for raw_value in column.to_pylist():
82
+ if raw_value is None:
83
+ out.append(None)
84
+ continue
85
+ converted = _i128_to_i64(raw_value)
86
+ if not -(2 ** 63) <= converted < 2 ** 63:
87
+ raise OverflowError(f"downcast value out of int64 range: {converted}")
88
+ out.append(converted.to_bytes(NARROW, "little", signed=True))
89
+ return pa.array(out, type=pa.binary(NARROW))
90
+
91
+
92
+ def _narrow_schema(schema):
93
+ import pyarrow as pa
94
+
95
+ fields = []
96
+ for field in schema:
97
+ if pa.types.is_fixed_size_binary(field.type) and field.type.byte_width == WIDE:
98
+ fields.append(pa.field(field.name, pa.binary(NARROW), field.nullable,
99
+ field.metadata))
100
+ else:
101
+ fields.append(field)
102
+ return pa.schema(fields, metadata=_narrow_metadata(schema.metadata))
103
+
104
+
105
+ def _narrow_metadata(metadata):
106
+ """Rewrite the ``*_precision`` schema metadata from 16 decimals to 9.
107
+
108
+ NautilusTrader rebuilds ``Price``/``Quantity`` from these values when it
109
+ decodes a file, so leaving ``price_precision=16`` makes it parse an 8-byte
110
+ value with a 16-decimal scale and abort with "precision exceeded maximum".
111
+ """
112
+ if not metadata:
113
+ return metadata
114
+ narrowed = {}
115
+ for key, value in metadata.items():
116
+ name = key.decode() if isinstance(key, bytes) else key
117
+ text = value.decode() if isinstance(value, bytes) else value
118
+ if name.endswith("precision") and text == str(WIDE_DECIMALS):
119
+ text = str(NARROW_DECIMALS)
120
+ narrowed[key] = text
121
+ return narrowed
122
+
123
+
124
+ def _wide_columns(table):
125
+ return [name for name, field in zip(table.column_names, table.schema)
126
+ if str(field.type) == f"fixed_size_binary[{WIDE}]"]
127
+
128
+
129
+ def narrow_table(table):
130
+ """Return *table* with its 16-byte fixed-point columns narrowed to 8 bytes.
131
+
132
+ This is the in-memory counterpart of :func:`downcast_file`: useful for a
133
+ direct read on a 64-bit runtime without writing a converted catalog. Tables
134
+ that carry no 16-byte columns are returned unchanged.
135
+ """
136
+ import pyarrow as pa
137
+
138
+ wide = _wide_columns(table)
139
+ if not wide:
140
+ return table
141
+ arrays = list(table.columns)
142
+ for name in wide:
143
+ arrays[table.column_names.index(name)] = _column_to_narrow(table.column(name))
144
+ return pa.Table.from_arrays(arrays, schema=_narrow_schema(table.schema))
145
+
146
+
147
+ def _is_complete(path: Path) -> bool:
148
+ """True when *path* is a readable parquet file (guards against half-written copies)."""
149
+ try:
150
+ import pyarrow.parquet as pq
151
+
152
+ return pq.ParquetFile(path).metadata.num_rows >= 0
153
+ except Exception: # noqa: BLE001 - unreadable file means "convert again"
154
+ return False
155
+
156
+
157
+ def _replace(src: Path, dst: Path) -> None:
158
+ """Move *src* onto *dst* atomically (same filesystem)."""
159
+ os.replace(src, dst)
160
+
161
+
162
+ def downcast_file(src: Path, dst: Path) -> bool:
163
+ """Rewrite one parquet file. Returns False when the file had nothing to change."""
164
+ import pyarrow.parquet as pq
165
+
166
+ table = pq.read_table(src)
167
+ dst.parent.mkdir(parents=True, exist_ok=True)
168
+ staging = dst.with_name(dst.name + ".part")
169
+ if not _wide_columns(table):
170
+ shutil.copy2(src, staging)
171
+ _replace(staging, dst)
172
+ return False
173
+
174
+ narrowed = narrow_table(table)
175
+ # Keep zstd: the Downloader writes zstd, and snappy would inflate the copy.
176
+ pq.write_table(narrowed, staging, compression="zstd")
177
+ _replace(staging, dst)
178
+ return True
179
+
180
+
181
+ _narrow_cache: dict[str, Path] = {}
182
+
183
+
184
+ def ensure_narrow_catalog(src, *, cache_root=None) -> tuple[Path, bool]:
185
+ """Return an 8-byte copy of *src*, converting (or refreshing) it as needed.
186
+
187
+ This is the runtime fallback for a 16-byte catalog on a 64-bit NautilusTrader
188
+ build (always the case on Windows). The copy lives beside the catalog when
189
+ that directory is writable, otherwise under ``%LOCALAPPDATA%/vikron``; the
190
+ conversion is incremental, so a daily append only rewrites the new files.
191
+
192
+ Returns ``(path, converted)``: ``converted`` is False when this process had
193
+ already prepared the same source.
194
+ """
195
+ source = Path(src)
196
+ if not (source / "data").is_dir():
197
+ raise ValueError(f"Not a Catalog directory: {source} (missing data directory)")
198
+
199
+ key = str(source).lower()
200
+ cached = _narrow_cache.get(key)
201
+ if cached is not None:
202
+ return cached, False
203
+
204
+ sibling = source.with_name(source.name + "-int64")
205
+ try:
206
+ sibling.mkdir(parents=True, exist_ok=True)
207
+ probe = sibling / ".write-probe"
208
+ probe.write_text("", encoding="utf-8")
209
+ probe.unlink()
210
+ target = sibling
211
+ except OSError:
212
+ import hashlib
213
+ import os
214
+ import tempfile
215
+ base = Path(cache_root or os.environ.get("LOCALAPPDATA")
216
+ or tempfile.gettempdir()) / "vikron" / "catalog-int64"
217
+ digest = hashlib.sha1(str(source).lower().encode("utf-8")).hexdigest()[:12]
218
+ target = base / f"{source.name}-{digest}"
219
+ target.mkdir(parents=True, exist_ok=True)
220
+ downcast_catalog(source, target, incremental=True, quiet=True)
221
+ _narrow_cache[key] = target
222
+ return target, True
223
+
224
+
225
+ def downcast_catalog(src, dst=None, *, overwrite: bool = False,
226
+ incremental: bool = False, quiet: bool = False) -> Path:
227
+ """Downcast every parquet file under *src* into *dst* (default ``<src>-int64``).
228
+
229
+ ``incremental`` leaves files already present in *dst* alone, which is what a
230
+ daily-append catalog needs: only the new day is converted. ``quiet`` silences
231
+ the summary line for library callers.
232
+ """
233
+ source = Path(src)
234
+ target = Path(dst) if dst is not None else source.with_name(source.name + "-int64")
235
+ if not (source / "data").is_dir():
236
+ raise ValueError(f"Not a Catalog directory: {source} (missing data directory)")
237
+ if target.exists() and any(target.rglob("*.parquet")) and not (overwrite or incremental):
238
+ raise FileExistsError(
239
+ f"{target} already holds parquet files; pass overwrite=True or incremental=True")
240
+
241
+ converted = copied = skipped = 0
242
+ for path in sorted((source / "data").rglob("*.parquet")):
243
+ destination = target / path.relative_to(source)
244
+ if incremental and destination.exists() and _is_complete(destination):
245
+ skipped += 1
246
+ continue
247
+ changed = downcast_file(path, destination)
248
+ converted += int(changed)
249
+ copied += int(not changed)
250
+ for extra in source.glob("*.json"):
251
+ shutil.copy2(extra, target / extra.name)
252
+ suffix = f", skipped {skipped} existing file(s)" if skipped else ""
253
+ if not quiet:
254
+ print(f"downcast {converted} file(s), copied {copied} unchanged file(s){suffix} -> {target}")
255
+ return target
256
+
257
+
258
+ def main(argv=None):
259
+ ap = argparse.ArgumentParser(description=__doc__,
260
+ formatter_class=argparse.RawDescriptionHelpFormatter)
261
+ ap.add_argument("src", help="catalog-v1 root containing data/")
262
+ ap.add_argument("dst", nargs="?", help="destination root (default: <src>-int64)")
263
+ ap.add_argument("--overwrite", action="store_true",
264
+ help="rewrite every file, including ones already converted")
265
+ ap.add_argument("--incremental", action="store_true",
266
+ help="only convert files missing from the destination (daily appends)")
267
+ args = ap.parse_args(argv)
268
+ try:
269
+ downcast_catalog(args.src, args.dst, overwrite=args.overwrite,
270
+ incremental=args.incremental)
271
+ except (ValueError, FileExistsError, OverflowError) as exc:
272
+ print(f"error: {exc}", file=sys.stderr)
273
+ return 1
274
+ return 0
275
+
276
+
277
+ if __name__ == "__main__":
278
+ sys.exit(main())