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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ohlcutils
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A lightweight Python library for python utilities for holidays, dates, options and other tools
5
5
  Author-email: Pankaj Sharma <sharma.pankaj.kumar@gmail.com>
6
6
  License-Expression: MIT
@@ -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).apply(
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
- # Read rds from path
397
- if len(paths) == 0:
398
- return pd.DataFrame()
399
- elif len(paths) == 1:
400
- try:
401
- out = readRDS(paths[0])
402
- except Exception as e:
403
- get_ohlcutils_logger().log_error("Failed to read RDS file", e, {"file_path": paths[0], "symbol": symbol})
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(p)
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": p, "symbol": symbol})
413
- warnings.warn(f"Failed to read RDS file {p}: {e}", RuntimeWarning)
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
- out = pd.DataFrame()
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ohlcutils
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: A lightweight Python library for python utilities for holidays, dates, options and other tools
5
5
  Author-email: Pankaj Sharma <sharma.pankaj.kumar@gmail.com>
6
6
  License-Expression: MIT
@@ -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.0"
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