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 +40 -0
- vikron_nt/_compat.py +17 -0
- vikron_nt/_paths.py +160 -0
- vikron_nt/data.py +142 -0
- vikron_nt/downcast.py +278 -0
- vikron_nt/official_v1.py +263 -0
- vikron_nt-0.1.0.dist-info/METADATA +466 -0
- vikron_nt-0.1.0.dist-info/RECORD +10 -0
- vikron_nt-0.1.0.dist-info/WHEEL +4 -0
- vikron_nt-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|