ohlcutils 0.2.0__tar.gz → 0.2.2__tar.gz
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.
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/PKG-INFO +1 -1
- ohlcutils-0.2.2/ohlcutils/archive.py +99 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils/data.py +44 -25
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils.egg-info/PKG-INFO +1 -1
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils.egg-info/SOURCES.txt +3 -1
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/pyproject.toml +1 -1
- ohlcutils-0.2.2/tests/test_archive.py +90 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/README.md +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils/__init__.py +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils/_arg_validators.py +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils/charting.py +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils/config/config_sample.yaml +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils/config.py +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils/enums.py +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils/indicators.py +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils/testing.py +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils.egg-info/dependency_links.txt +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils.egg-info/requires.txt +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/ohlcutils.egg-info/top_level.txt +0 -0
- {ohlcutils-0.2.0 → ohlcutils-0.2.2}/setup.cfg +0 -0
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Extract one archived persecond session day from a yearly tar into a temp dir."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import tempfile
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def persecond_tar_path(archive_root: Path, kind: str, year: str) -> Path:
|
|
14
|
+
return archive_root / f"persecond-{kind}-{year}.tar.gz"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def live_persecond_day_dir(data_root: Path, kind: str, day: str) -> Path:
|
|
18
|
+
return data_root / "persecond" / kind / day
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PersecondDayResolver:
|
|
22
|
+
"""Resolve a persecond RDS path, extracting one day from archive if needed."""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
data_root: Path,
|
|
27
|
+
archive_root: Path | None = None,
|
|
28
|
+
extract_root: Path | None = None,
|
|
29
|
+
):
|
|
30
|
+
self.data_root = Path(data_root)
|
|
31
|
+
self.archive_root = Path(archive_root) if archive_root is not None else self.data_root / "archive"
|
|
32
|
+
self._extract_root = Path(extract_root) if extract_root is not None else None
|
|
33
|
+
self._owned_tmps: list[Path] = []
|
|
34
|
+
self._extract_parent_dir: Path | None = None
|
|
35
|
+
self._day_dirs: dict[tuple[str, str], Path] = {}
|
|
36
|
+
|
|
37
|
+
def resolve(self, path: str) -> str:
|
|
38
|
+
source = Path(path)
|
|
39
|
+
if source.exists():
|
|
40
|
+
return path
|
|
41
|
+
try:
|
|
42
|
+
day = source.parent.name
|
|
43
|
+
kind = source.parent.parent.name
|
|
44
|
+
periodicity = source.parent.parent.parent.name
|
|
45
|
+
except Exception:
|
|
46
|
+
return path
|
|
47
|
+
if periodicity != "persecond":
|
|
48
|
+
return path
|
|
49
|
+
day_dir = self.ensure_day(kind, day)
|
|
50
|
+
if day_dir is None:
|
|
51
|
+
return path
|
|
52
|
+
return str(day_dir / source.name)
|
|
53
|
+
|
|
54
|
+
def ensure_day(self, kind: str, day: str) -> Path | None:
|
|
55
|
+
key = (kind, day)
|
|
56
|
+
if key in self._day_dirs:
|
|
57
|
+
return self._day_dirs[key]
|
|
58
|
+
live = live_persecond_day_dir(self.data_root, kind, day)
|
|
59
|
+
if live.is_dir():
|
|
60
|
+
self._day_dirs[key] = live
|
|
61
|
+
return live
|
|
62
|
+
year = day[:4]
|
|
63
|
+
tar_path = persecond_tar_path(self.archive_root, kind, year)
|
|
64
|
+
if not tar_path.is_file():
|
|
65
|
+
return None
|
|
66
|
+
dest_parent = self._shared_extract_parent()
|
|
67
|
+
dest_parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
try:
|
|
69
|
+
subprocess.run(
|
|
70
|
+
["tar", "-xzf", str(tar_path), "-C", str(dest_parent), day],
|
|
71
|
+
check=True,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
text=True,
|
|
74
|
+
)
|
|
75
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
76
|
+
return None
|
|
77
|
+
extracted = dest_parent / day
|
|
78
|
+
if not extracted.is_dir():
|
|
79
|
+
return None
|
|
80
|
+
self._day_dirs[key] = extracted
|
|
81
|
+
return extracted
|
|
82
|
+
|
|
83
|
+
def _shared_extract_parent(self) -> Path:
|
|
84
|
+
if self._extract_parent_dir is not None:
|
|
85
|
+
return self._extract_parent_dir
|
|
86
|
+
if self._extract_root is not None:
|
|
87
|
+
parent = self._extract_root / str(uuid.uuid4())
|
|
88
|
+
else:
|
|
89
|
+
parent = Path(tempfile.gettempdir()) / "rds-untar" / str(os.getpid()) / str(uuid.uuid4())
|
|
90
|
+
self._extract_parent_dir = parent
|
|
91
|
+
self._owned_tmps.append(parent)
|
|
92
|
+
return parent
|
|
93
|
+
|
|
94
|
+
def cleanup(self) -> None:
|
|
95
|
+
for tmp in self._owned_tmps:
|
|
96
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
97
|
+
self._owned_tmps.clear()
|
|
98
|
+
self._extract_parent_dir = None
|
|
99
|
+
self._day_dirs.clear()
|
|
@@ -4,6 +4,7 @@ import datetime as dt
|
|
|
4
4
|
import os
|
|
5
5
|
import sys
|
|
6
6
|
import warnings
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
from typing import Literal
|
|
8
9
|
|
|
9
10
|
import numpy as np
|
|
@@ -28,6 +29,7 @@ from chameli.interactions import readRDS
|
|
|
28
29
|
|
|
29
30
|
from ._arg_validators import _process_kwargs, _valid_load_symbol_kwargs
|
|
30
31
|
from .config import get_config, get_market_close_time, get_market_open_time
|
|
32
|
+
from .archive import PersecondDayResolver
|
|
31
33
|
from .enums import Periodicity
|
|
32
34
|
from .indicators import create_index_if_missing
|
|
33
35
|
|
|
@@ -96,17 +98,29 @@ def get_linked_symbols(short_symbol: str, complete=False) -> pd.DataFrame:
|
|
|
96
98
|
"""
|
|
97
99
|
out = pd.DataFrame()
|
|
98
100
|
end_time = dt.date.today().strftime("%Y-%m-%d")
|
|
101
|
+
# Several rows can share a symbol (NSE lists e.g. NOVARTIS and NOVARTIND -> EVONILE); take the latest
|
|
102
|
+
# row (by date, then file order) and stop on repeats so the chain can never loop forever.
|
|
99
103
|
if complete:
|
|
104
|
+
seen = {short_symbol}
|
|
100
105
|
while True:
|
|
101
106
|
temp = symbolchange.loc[(symbolchange["oldsymbol"] == short_symbol) & (symbolchange["newsymbol"] != "")]
|
|
102
|
-
if len(temp) == 1:
|
|
103
|
-
short_symbol = temp["newsymbol"].item()
|
|
104
107
|
if len(temp) == 0:
|
|
105
108
|
break
|
|
109
|
+
temp = temp.sort_values("effectivedate", kind="stable").tail(1)
|
|
110
|
+
if temp["newsymbol"].item() in seen:
|
|
111
|
+
break
|
|
112
|
+
short_symbol = temp["newsymbol"].item()
|
|
113
|
+
seen.add(short_symbol)
|
|
106
114
|
|
|
115
|
+
seen = {short_symbol}
|
|
107
116
|
while True:
|
|
108
117
|
temp = symbolchange.loc[(symbolchange["newsymbol"] == short_symbol) & (symbolchange["oldsymbol"] != "")]
|
|
118
|
+
if len(temp) > 1:
|
|
119
|
+
temp = temp.sort_values("effectivedate", kind="stable").tail(1)
|
|
120
|
+
if len(temp) == 1 and temp.iloc[0]["oldsymbol"] in seen:
|
|
121
|
+
temp = temp.iloc[0:0]
|
|
109
122
|
if len(temp) == 1:
|
|
123
|
+
seen.add(temp.iloc[0]["oldsymbol"])
|
|
110
124
|
new_row = {
|
|
111
125
|
"starttime": temp.iloc[0]["effectivedate"],
|
|
112
126
|
"endtime": end_time,
|
|
@@ -187,9 +201,7 @@ def _split_adjust_market_data(md: pd.DataFrame, src: Periodicity, tz: str) -> pd
|
|
|
187
201
|
symbol_vector = md.symbol.iloc[0].split("_", -1)
|
|
188
202
|
split_info = get_split_info(symbol_vector[0])
|
|
189
203
|
if not split_info.empty:
|
|
190
|
-
split_info = split_info.groupby("date", sort=False).
|
|
191
|
-
lambda x: x.select_dtypes(include=[np.number]).prod()
|
|
192
|
-
)
|
|
204
|
+
split_info = split_info.groupby("date", sort=False).prod(numeric_only=True)
|
|
193
205
|
split_info["splitadjust"] = split_info["newshares"] / split_info["oldshares"]
|
|
194
206
|
split_info["splitadjust"] = split_info["splitadjust"].cumprod()
|
|
195
207
|
tz_locale = pytz.timezone(tz)
|
|
@@ -393,29 +405,36 @@ def load_symbol(symbol: str, **kwargs) -> pd.DataFrame:
|
|
|
393
405
|
symbol_temp_str = "_".join(symbol_temp)
|
|
394
406
|
paths.extend([_get_data_path(symbol_temp_str, params["src"], params["src"])])
|
|
395
407
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
return pd.DataFrame()
|
|
405
|
-
else:
|
|
406
|
-
list_out = []
|
|
407
|
-
for p in paths:
|
|
408
|
+
resolver = None
|
|
409
|
+
try:
|
|
410
|
+
if params["src"] == Periodicity.PERSECOND:
|
|
411
|
+
resolver = PersecondDayResolver(data_root=Path(datapath))
|
|
412
|
+
paths = [resolver.resolve(p) for p in paths]
|
|
413
|
+
if len(paths) == 0:
|
|
414
|
+
out = pd.DataFrame()
|
|
415
|
+
elif len(paths) == 1:
|
|
408
416
|
try:
|
|
409
|
-
out = readRDS(
|
|
410
|
-
list_out.append(out)
|
|
417
|
+
out = readRDS(paths[0])
|
|
411
418
|
except Exception as e:
|
|
412
|
-
get_ohlcutils_logger().log_error("Failed to read RDS file", e, {"file_path":
|
|
413
|
-
|
|
414
|
-
filtered_list_out = [df.dropna(axis=1, how="all") for df in list_out if is_good_df(df)]
|
|
415
|
-
if filtered_list_out:
|
|
416
|
-
out = pd.concat(filtered_list_out, ignore_index=True)
|
|
419
|
+
get_ohlcutils_logger().log_error("Failed to read RDS file", e, {"file_path": paths[0], "symbol": symbol})
|
|
420
|
+
out = pd.DataFrame()
|
|
417
421
|
else:
|
|
418
|
-
|
|
422
|
+
list_out = []
|
|
423
|
+
for p in paths:
|
|
424
|
+
try:
|
|
425
|
+
out = readRDS(p)
|
|
426
|
+
list_out.append(out)
|
|
427
|
+
except Exception as e:
|
|
428
|
+
get_ohlcutils_logger().log_error("Failed to read RDS file", e, {"file_path": p, "symbol": symbol})
|
|
429
|
+
warnings.warn(f"Failed to read RDS file {p}: {e}", RuntimeWarning)
|
|
430
|
+
filtered_list_out = [df.dropna(axis=1, how="all") for df in list_out if is_good_df(df)]
|
|
431
|
+
if filtered_list_out:
|
|
432
|
+
out = pd.concat(filtered_list_out, ignore_index=True)
|
|
433
|
+
else:
|
|
434
|
+
out = pd.DataFrame()
|
|
435
|
+
finally:
|
|
436
|
+
if resolver is not None:
|
|
437
|
+
resolver.cleanup()
|
|
419
438
|
|
|
420
439
|
if not isinstance(out, pd.DataFrame):
|
|
421
440
|
return pd.DataFrame()
|
|
@@ -2,6 +2,7 @@ README.md
|
|
|
2
2
|
pyproject.toml
|
|
3
3
|
ohlcutils/__init__.py
|
|
4
4
|
ohlcutils/_arg_validators.py
|
|
5
|
+
ohlcutils/archive.py
|
|
5
6
|
ohlcutils/charting.py
|
|
6
7
|
ohlcutils/config.py
|
|
7
8
|
ohlcutils/data.py
|
|
@@ -13,4 +14,5 @@ ohlcutils.egg-info/SOURCES.txt
|
|
|
13
14
|
ohlcutils.egg-info/dependency_links.txt
|
|
14
15
|
ohlcutils.egg-info/requires.txt
|
|
15
16
|
ohlcutils.egg-info/top_level.txt
|
|
16
|
-
ohlcutils/config/config_sample.yaml
|
|
17
|
+
ohlcutils/config/config_sample.yaml
|
|
18
|
+
tests/test_archive.py
|
|
@@ -27,7 +27,7 @@ include-package-data = true
|
|
|
27
27
|
|
|
28
28
|
[project]
|
|
29
29
|
name = "ohlcutils"
|
|
30
|
-
version = "0.2.
|
|
30
|
+
version = "0.2.2"
|
|
31
31
|
description = "A lightweight Python library for python utilities for holidays, dates, options and other tools"
|
|
32
32
|
readme = "README.md"
|
|
33
33
|
license = "MIT"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from ohlcutils.archive import PersecondDayResolver, persecond_tar_path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _make_day(root: Path, kind: str, day: str, names: list[str]) -> Path:
|
|
9
|
+
day_dir = root / "persecond" / kind / day
|
|
10
|
+
day_dir.mkdir(parents=True)
|
|
11
|
+
for name in names:
|
|
12
|
+
(day_dir / name).write_text("payload-" + name, encoding="utf-8")
|
|
13
|
+
return day_dir
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _tar_year(data_root: Path, archive_root: Path, kind: str, year: str, days: list[str]) -> Path:
|
|
17
|
+
archive_root.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
dest = persecond_tar_path(archive_root, kind, year)
|
|
19
|
+
parent = data_root / "persecond" / kind
|
|
20
|
+
subprocess.run(["tar", "-czf", str(dest), "-C", str(parent), *days], check=True)
|
|
21
|
+
return dest
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_live_day_needs_no_tar(tmp_path: Path):
|
|
25
|
+
day = "2018-01-02"
|
|
26
|
+
_make_day(tmp_path, "stk", day, ["AAA_STK____2018-01-02.rds"])
|
|
27
|
+
resolver = PersecondDayResolver(tmp_path, archive_root=tmp_path / "archive")
|
|
28
|
+
try:
|
|
29
|
+
path = tmp_path / "persecond" / "stk" / day / "AAA_STK____2018-01-02.rds"
|
|
30
|
+
assert resolver.resolve(str(path)) == str(path)
|
|
31
|
+
assert resolver._owned_tmps == []
|
|
32
|
+
finally:
|
|
33
|
+
resolver.cleanup()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_extract_one_day_then_cleanup(tmp_path: Path):
|
|
37
|
+
day = "2018-01-02"
|
|
38
|
+
other = "2018-01-03"
|
|
39
|
+
_make_day(tmp_path, "stk", day, ["AAA_STK____2018-01-02.rds"])
|
|
40
|
+
_make_day(tmp_path, "stk", other, ["BBB_STK____2018-01-03.rds"])
|
|
41
|
+
archive_root = tmp_path / "archive"
|
|
42
|
+
_tar_year(tmp_path, archive_root, "stk", "2018", [day, other])
|
|
43
|
+
live_day = tmp_path / "persecond" / "stk" / day
|
|
44
|
+
other_dir = tmp_path / "persecond" / "stk" / other
|
|
45
|
+
import shutil
|
|
46
|
+
|
|
47
|
+
shutil.rmtree(live_day)
|
|
48
|
+
shutil.rmtree(other_dir)
|
|
49
|
+
|
|
50
|
+
missing = tmp_path / "persecond" / "stk" / day / "AAA_STK____2018-01-02.rds"
|
|
51
|
+
assert not missing.exists()
|
|
52
|
+
extract_root = tmp_path / "extracts"
|
|
53
|
+
resolver = PersecondDayResolver(tmp_path, archive_root=archive_root, extract_root=extract_root)
|
|
54
|
+
try:
|
|
55
|
+
resolved = Path(resolver.resolve(str(missing)))
|
|
56
|
+
assert resolved.exists()
|
|
57
|
+
assert resolved.read_text(encoding="utf-8") == "payload-AAA_STK____2018-01-02.rds"
|
|
58
|
+
assert resolved.parent.name == day
|
|
59
|
+
other_extracted = extract_root.rglob("BBB_STK____2018-01-03.rds")
|
|
60
|
+
assert list(other_extracted) == []
|
|
61
|
+
owned = list(resolver._owned_tmps)
|
|
62
|
+
assert owned
|
|
63
|
+
finally:
|
|
64
|
+
resolver.cleanup()
|
|
65
|
+
for owned in owned:
|
|
66
|
+
assert not owned.exists()
|
|
67
|
+
assert not missing.exists()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_missing_tar_returns_original_path(tmp_path: Path):
|
|
71
|
+
path = tmp_path / "persecond" / "stk" / "2018-01-02" / "AAA_STK____2018-01-02.rds"
|
|
72
|
+
resolver = PersecondDayResolver(tmp_path, archive_root=tmp_path / "archive")
|
|
73
|
+
try:
|
|
74
|
+
assert resolver.resolve(str(path)) == str(path)
|
|
75
|
+
finally:
|
|
76
|
+
resolver.cleanup()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_tar_member_checksum_stable(tmp_path: Path):
|
|
80
|
+
day = "2014-04-28"
|
|
81
|
+
_make_day(tmp_path, "ind", day, ["NIFTY_IND____2014-04-28.rds"])
|
|
82
|
+
archive_root = tmp_path / "archive"
|
|
83
|
+
dest = _tar_year(tmp_path, archive_root, "ind", "2014", [day])
|
|
84
|
+
listing = subprocess.check_output(["tar", "-tzf", str(dest)], text=True)
|
|
85
|
+
digest = hashlib.sha256("".join(sorted(listing.splitlines(True))).encode()).hexdigest()
|
|
86
|
+
listing2 = subprocess.check_output(["tar", "-tzf", str(dest)], text=True)
|
|
87
|
+
digest2 = hashlib.sha256("".join(sorted(listing2.splitlines(True))).encode()).hexdigest()
|
|
88
|
+
assert digest == digest2
|
|
89
|
+
top = sorted({line.split("/")[0] for line in listing.splitlines() if line.strip()})
|
|
90
|
+
assert top == [day]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|