Repository navigation
Expand file tree
/
Copy path_utils.py
More file actions
172 lines (140 loc) · 5.57 KB
/
Copy path_utils.py
File metadata and controls
172 lines (140 loc) · 5.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
"""Shared utility functions deduplicated across the GEX app codebase."""
from __future__ import annotations
import json
import os
from datetime import date, datetime, timezone
from typing import Any, Optional
from zoneinfo import ZoneInfo
import pandas as pd
def to_json(resp: Any) -> Any:
"""Convert a Schwab response object to a dict if possible."""
if hasattr(resp, "json"):
return resp.json()
return resp
def nested_get(d: dict, *keys: str, default: Any = None) -> Any:
"""Safely traverse nested dicts: nested_get(d, 'a', 'b', 'c', default=0)
returns d['a']['b']['c'] or default if any key is missing."""
for key in keys:
if not isinstance(d, dict):
return default
d = d.get(key, default) or default
return d
def ensure_dir(path: str) -> None:
"""Ensure the parent directory of *path* exists."""
os.makedirs(os.path.dirname(path), exist_ok=True)
def load_json(path: str, default: Any = None) -> Any:
"""Load JSON from *path*, returning *default* on error."""
try:
with open(path) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return default
def save_json(path: str, data: Any, secure: bool = False) -> None:
"""Save *data* as JSON to *path*. If *secure* is True, use 0o600 perms."""
ensure_dir(path)
if secure:
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
json.dump(data, f)
else:
with open(path, "w") as f:
json.dump(data, f)
def compute_strike_increment(options_data: list[dict]) -> float | None:
"""Compute the minimum strike spacing from a list of option dicts.
Returns None if fewer than 2 unique strikes.
"""
strikes = sorted(set(e["strike"] for e in options_data))
if len(strikes) >= 2:
return min(strikes[i + 1] - strikes[i] for i in range(len(strikes) - 1))
return None
def format_expiration(exp: str | None) -> str:
"""Format expiration date into MM-DD (X days) for display.
Returns empty string for None/invalid input.
"""
if not exp:
return ""
try:
exp_date = date.fromisoformat(exp)
dte = (exp_date - date.today()).days
mmdd = exp[5:10] # "MM-DD"
return f"{mmdd} ({dte}d)" if dte >= 0 else f"{mmdd} (0d)"
except (ValueError, TypeError):
return exp or ""
def floor_ts_to_tf(
ts_ms: int, timeframe: str, tf_minutes: int | None = None
) -> int:
"""Floor a timestamp (ms since epoch, UTC) to a timeframe boundary.
For intraday timeframes (*tf_minutes* is set), simply floors to the
nearest *tf_minutes* interval.
For daily/weekly/monthly (when *tf_minutes* is None), floors to the
start of the US/Eastern trading day so it aligns with TDA's daily
cache bars (which use midnight ET, not midnight UTC).
"""
if tf_minutes is None:
tf_ms = 24 * 60 * 60 * 1000
try:
ny_tz = ZoneInfo("America/New_York")
now_ny = datetime.fromtimestamp(ts_ms / 1000, tz=ZoneInfo("UTC")).astimezone(ny_tz)
day_start_ny = now_ny.replace(hour=0, minute=0, second=0, microsecond=0)
return int(day_start_ny.astimezone(ZoneInfo("UTC")).timestamp() * 1000)
except Exception:
return (ts_ms // tf_ms) * tf_ms
else:
tf_ms = tf_minutes * 60 * 1000
return (ts_ms // tf_ms) * tf_ms
def floor_dataframe_timestamps(
df: pd.DataFrame,
col: str = "datetime",
timeframe: str = "1d",
tf_minutes: int | None = None,
) -> pd.DataFrame:
"""Floor a DataFrame's timestamp column to the chart's timeframe boundary.
For daily/weekly/monthly (tf_minutes is None), uses US/Eastern day
boundary so live bars align with TDA's daily cache (midnight ET).
For intraday (tf_minutes is set), simply floors to the interval.
"""
if tf_minutes is None:
tf_ms = 24 * 60 * 60 * 1000
try:
ny_tz = ZoneInfo("America/New_York")
tick_dt = pd.to_datetime(df[col], unit="ms", utc=True).dt.tz_convert(ny_tz)
day_start_ny = tick_dt.dt.normalize()
df[col] = day_start_ny.dt.tz_convert("UTC").astype("int64")
except Exception:
df[col] = (df[col] // tf_ms) * tf_ms
else:
tf_ms = tf_minutes * 60 * 1000
df[col] = (df[col] // tf_ms) * tf_ms
return df
def compute_current_bucket(timeframe: str, tf_minutes: int | None = None) -> int:
"""Compute the current timeframe-aligned bucket timestamp (ms UTC).
For daily/weekly/monthly (tf_minutes is None), uses US/Eastern day
boundary. For intraday, floors to the nearest interval.
"""
now_utc_ms = int(pd.Timestamp.now(tz="UTC").value // 1_000_000)
if tf_minutes is None:
try:
ny_tz = ZoneInfo("America/New_York")
return int(
pd.Timestamp.now(tz=ny_tz)
.normalize()
.tz_convert("UTC")
.value // 1_000_000
)
except Exception:
tf_ms = 24 * 60 * 60 * 1000
return (now_utc_ms // tf_ms) * tf_ms
else:
tf_ms = tf_minutes * 60 * 1000
return (now_utc_ms // tf_ms) * tf_ms
def extract_last_price(quote: dict) -> float | None:
"""Extract the best available price from a Schwab quote dict."""
last = quote.get("lastPrice") or quote.get("mark") or quote.get("closePrice")
if last is not None:
try:
v = float(last)
if v > 0:
return v
except (ValueError, TypeError):
pass
return None