Skip to content

Commit 3b79b10

Browse files
committed
⭐ hl: add get date interval and range funcs.
1 parent 4e76c6a commit 3b79b10

2 files changed

Lines changed: 458 additions & 10 deletions

File tree

src/ddeutil/core/dtutils.py

Lines changed: 324 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77

88
import calendar
99
import enum
10-
from datetime import datetime, timedelta
10+
import re
11+
from datetime import date, datetime, timedelta, timezone
1112
from typing import (
1213
Literal,
1314
Optional,
@@ -16,9 +17,11 @@
1617
from zoneinfo import ZoneInfo
1718

1819
try:
20+
from dateutil.parser import parse
1921
from dateutil.relativedelta import relativedelta
2022
except ImportError: # pragma: no cove
2123
relativedelta = None
24+
parse = None
2225

2326
from . import first
2427

@@ -43,6 +46,138 @@
4346
"microsecond",
4447
)
4548

49+
FrequencyMode = Literal["1T", "1H", "1D", "1M", "1Y"]
50+
FREQUENCY_SET: tuple[str, ...] = ("1T", "1H", "1D", "1M", "1Y")
51+
_TIMEZONE_CACHE: dict[int, timezone] = {0: timezone.utc}
52+
# Pre-compiled regex for better performance
53+
_DATETIME_PATTERN: re.Pattern[str] = re.compile(
54+
r"^(\d{4})-(\d{2})-(\d{2})" # YYYY-MM-DD
55+
r"(?:[T\s](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?" # Optional time with microseconds
56+
r"(?:(Z)|([+-])(\d{2}):?(\d{2}))?$" # Optional timezone
57+
)
58+
59+
60+
def _get_timezone(offset_minutes: int) -> timezone:
61+
"""Get timezone object from cache or create new one."""
62+
if offset_minutes not in _TIMEZONE_CACHE:
63+
_TIMEZONE_CACHE[offset_minutes] = timezone(
64+
timedelta(minutes=offset_minutes)
65+
)
66+
return _TIMEZONE_CACHE[offset_minutes]
67+
68+
69+
def parse_dt_default(dt: Union[datetime, str]) -> datetime:
70+
"""Parse datetime string using only Python built-in packages.
71+
72+
Supports formats:
73+
- YYYY-MM-DD
74+
- YYYY-MM-DD HH:MM:SS[.ffffff]
75+
- YYYY-MM-DD HH:MM:SS[.ffffff]Z
76+
- YYYY-MM-DD HH:MM:SS[.ffffff]±HH:MM
77+
78+
Args:
79+
dt: A datetime object or string to parse
80+
81+
Returns:
82+
datetime: Parsed datetime object with appropriate timezone
83+
84+
Raises:
85+
ValueError: If the input cannot be parsed
86+
"""
87+
# Handle datetime objects directly
88+
if isinstance(dt, datetime):
89+
return dt
90+
91+
# Handle date objects (convert to datetime at midnight)
92+
if isinstance(dt, date):
93+
return datetime(dt.year, dt.month, dt.day)
94+
95+
# Handle string parsing
96+
if not isinstance(dt, str):
97+
raise ValueError("Input must be datetime, date, or string")
98+
99+
match = _DATETIME_PATTERN.match(dt.strip())
100+
if not match:
101+
raise ValueError(f"Unable to parse datetime string: {dt}")
102+
103+
(
104+
year,
105+
month,
106+
day,
107+
hour,
108+
minute,
109+
second,
110+
microsecond_str,
111+
utc_z,
112+
tz_sign,
113+
tz_hour,
114+
tz_minute,
115+
) = match.groups()
116+
117+
# Convert required components
118+
year, month, day = int(year), int(month), int(day)
119+
hour = int(hour) if hour else 0
120+
minute = int(minute) if minute else 0
121+
second = int(second) if second else 0
122+
123+
# Handle microseconds efficiently
124+
microsecond = 0
125+
if microsecond_str:
126+
# Normalize to 6 digits and convert
127+
microsecond_str = microsecond_str.ljust(6, "0")[:6]
128+
microsecond = int(microsecond_str)
129+
130+
# Handle timezone
131+
tz = None
132+
if utc_z:
133+
tz = timezone.utc
134+
elif tz_sign and tz_hour and tz_minute:
135+
offset_minutes = int(tz_hour) * 60 + int(tz_minute)
136+
if tz_sign == "-":
137+
offset_minutes = -offset_minutes
138+
tz = _get_timezone(offset_minutes)
139+
140+
return datetime(year, month, day, hour, minute, second, microsecond, tz)
141+
142+
143+
def parse_dt(dt: Union[datetime, str], **kwargs) -> datetime:
144+
"""Parse datetime string using dateutil and return datetime object.
145+
146+
:param dt: A datetime value that want to parse.
147+
"""
148+
try:
149+
return parse_dt_default(dt)
150+
except ValueError:
151+
# Fallback to dateutil for complex formats
152+
try:
153+
from dateutil.parser import parse
154+
from dateutil.tz import tzoffset
155+
except ImportError as e:
156+
raise ValueError(
157+
f"Unable to parse '{dt}' with built-in parser. "
158+
"Install `dateutil` for complex datetime parsing: pip install "
159+
"`python-dateutil`."
160+
) from e
161+
162+
if isinstance(dt, str):
163+
dt = parse(dt, **kwargs)
164+
165+
if isinstance(dt, date) and not isinstance(dt, datetime):
166+
return datetime(dt.year, dt.month, dt.day)
167+
168+
if isinstance(dt, datetime):
169+
# Convert dateutil timezone to built-in timezone
170+
if dt.tzinfo and isinstance(dt.tzinfo, tzoffset):
171+
offset_seconds = int(dt.utcoffset().total_seconds())
172+
if offset_seconds == 0:
173+
return dt.replace(tzinfo=timezone.utc)
174+
else:
175+
offset_minutes = offset_seconds // 60
176+
return dt.replace(tzinfo=_get_timezone(offset_minutes))
177+
return dt
178+
179+
raise ValueError(f"Unable to parse datetime: {dt}") from None
180+
46181

47182
def get_datetime_replace(
48183
year: Optional[int] = None,
@@ -65,13 +200,13 @@ def get_datetime_replace(
65200
class DatetimeDim(enum.IntEnum):
66201
"""Datetime dimension enum object."""
67202

68-
MICROSECOND: int = 0
69-
SECOND: int = 1
70-
MINUTE: int = 2
71-
HOUR: int = 3
72-
DAY: int = 4
73-
MONTH: int = 5
74-
YEAR: int = 6
203+
MICROSECOND = 0
204+
SECOND = 1
205+
MINUTE = 2
206+
HOUR = 3
207+
DAY = 4
208+
MONTH = 5
209+
YEAR = 6
75210

76211
@classmethod
77212
def get_dim(cls, value: str) -> int:
@@ -293,7 +428,7 @@ def next_date_freq(dt: datetime, freq: str, prev: bool = False) -> datetime:
293428
"""
294429
if relativedelta is None:
295430
raise ImportError(
296-
"This function require relativedelta from the dateutil package, "
431+
"This function require `relativedelta` from the dateutil package, "
297432
"you should install with `pip install ddeutil[dateutil]`"
298433
)
299434
assert freq in ("D", "W", "M", "Q", "Y")
@@ -359,3 +494,183 @@ def calc_date_freq(dt: datetime, freq: str) -> datetime:
359494
return dt.replace(month=12, day=31) - relativedelta(years=1)
360495
return dt
361496
return dt
497+
498+
499+
def calc_time_units(
500+
start_dt: datetime, end_dt: datetime, binding_days: bool = True
501+
) -> tuple[Optional[str], Union[float, int]]:
502+
"""Calculate time difference and return the primary unit type and value.
503+
504+
:param start_dt: (datetime)
505+
:param end_dt: (datetime)
506+
:param binding_days: (bool)
507+
"""
508+
rdelta = relativedelta(end_dt, start_dt)
509+
510+
# NOTE: Check in order of precedence: years, months, hours, minutes
511+
if rdelta.years != 0:
512+
return "years", rdelta.years
513+
elif rdelta.months != 0 or rdelta.years != 0:
514+
return "months", rdelta.years * 12 + rdelta.months
515+
else:
516+
# NOTE: For days, hours and minutes, use total seconds for accuracy
517+
total_seconds: float = (end_dt - start_dt).total_seconds()
518+
519+
# NOTE: 86400 seconds in a day
520+
total_days = int(total_seconds // 86400)
521+
total_hours = int(total_seconds // 3600)
522+
total_minutes = int(total_seconds // 60)
523+
524+
if total_days != 0 and not binding_days:
525+
return "days", total_days
526+
elif total_hours != 0:
527+
return "hours", total_hours
528+
elif total_minutes != 0:
529+
return "minutes", total_minutes
530+
else:
531+
return None, 0
532+
533+
534+
def gen_date_range(
535+
start_dt: datetime,
536+
end_dt: datetime,
537+
freq: FrequencyMode,
538+
) -> list[datetime]:
539+
"""Generate a list of datetime objects between start and end with given frequency"""
540+
if start_dt > end_dt:
541+
return []
542+
543+
dates: list[datetime] = []
544+
current = start_dt
545+
546+
if freq == "1D":
547+
delta = timedelta(days=1)
548+
elif freq == "1H":
549+
delta = timedelta(hours=1)
550+
elif freq == "1T":
551+
delta = timedelta(minutes=1)
552+
else:
553+
raise ValueError(f"Unsupported frequency: {freq}")
554+
555+
while current <= end_dt:
556+
dates.append(current)
557+
current += delta
558+
559+
return dates
560+
561+
562+
def get_date_range(
563+
start: Union[str, datetime],
564+
end: Union[str, datetime],
565+
execution_step: int = 1,
566+
execution_offset: int = 0,
567+
freq: Optional[FrequencyMode] = None,
568+
binding_days: bool = True,
569+
) -> list[datetime]:
570+
"""Get datetime range with date intervals
571+
572+
:param start: (str | datetime)
573+
:param end: (str | datetime)
574+
:param execution_step: (int)
575+
:param execution_offset: (int)
576+
:param freq:
577+
:param binding_days:
578+
579+
Note:
580+
The date range will force to daily for the maximum generator.
581+
"""
582+
start_dt: datetime = parse_dt(start)
583+
end_dt: datetime = parse_dt(end)
584+
if freq:
585+
if freq not in FREQUENCY_SET:
586+
raise ValueError(f"Frequency, {freq!r}, does not support.")
587+
588+
range_freq: FrequencyMode = "1D" if freq in ["1Y", "1M"] else freq
589+
min_dt, max_dt = min(start_dt, end_dt), max(start_dt, end_dt)
590+
return gen_date_range(min_dt, max_dt, range_freq)
591+
592+
# Calculate new start and end based on unit type
593+
time_unit, time_value = calc_time_units(
594+
start_dt, end_dt, binding_days=binding_days
595+
)
596+
if time_unit is None:
597+
raise ValueError(f"Cannot find time difference between {start}, {end}")
598+
elif time_unit == "years":
599+
freq = "1Y"
600+
start_dt += relativedelta(years=time_value * execution_offset)
601+
end_dt = start_dt + relativedelta(years=time_value * execution_step)
602+
elif time_unit == "months":
603+
freq = "1M"
604+
start_dt += relativedelta(months=time_value * execution_offset)
605+
end_dt = start_dt + relativedelta(months=time_value * execution_step)
606+
elif time_unit == "days":
607+
freq = "1D"
608+
start_dt += relativedelta(days=time_value * execution_offset)
609+
end_dt = start_dt + relativedelta(days=time_value * execution_step)
610+
elif time_unit == "hours":
611+
freq = "1H"
612+
start_dt += relativedelta(hours=time_value * execution_offset)
613+
end_dt = start_dt + relativedelta(hours=time_value * execution_step)
614+
elif time_unit == "minutes":
615+
freq = "1T"
616+
start_dt += relativedelta(minutes=time_value * execution_offset)
617+
end_dt = start_dt + relativedelta(minutes=time_value * execution_step)
618+
else:
619+
raise ValueError(f"Time unit, {time_unit!r} does not support.")
620+
621+
# NOTE: Convert frequency for date range generation
622+
# (years/months use daily frequency).
623+
range_freq: FrequencyMode = "1D" if freq in ["1Y", "1M"] else freq
624+
min_dt, max_dt = min(start_dt, end_dt), max(start_dt, end_dt)
625+
return gen_date_range(min_dt, max_dt, range_freq)
626+
627+
628+
def get_date_interval(
629+
start: Union[str, datetime],
630+
end: Union[str, datetime],
631+
execution_step: int = 1,
632+
execution_offset: int = 0,
633+
start_add_hours: int = 0,
634+
end_add_hours: int = 0,
635+
binding_days: bool = True,
636+
) -> tuple[datetime, datetime]:
637+
"""Get datetime interval with optional hour adjustments.
638+
639+
:param start: (str | datetime)
640+
:param end: (str | datetime)
641+
:param execution_step:
642+
:param execution_offset:
643+
:param start_add_hours:
644+
:param end_add_hours:
645+
:param binding_days:
646+
"""
647+
start_dt: datetime = parse_dt(start)
648+
end_dt: datetime = parse_dt(end)
649+
time_unit, time_value = calc_time_units(
650+
start_dt, end_dt, binding_days=binding_days
651+
)
652+
653+
if time_unit is None:
654+
raise ValueError(f"Cannot find time difference between {start}, {end}")
655+
656+
# NOTE: Calculate new start and end based on unit type
657+
if time_unit == "years":
658+
start_dt += relativedelta(years=time_value * execution_offset)
659+
end_dt = start_dt + relativedelta(years=time_value * execution_step)
660+
elif time_unit == "months":
661+
start_dt += relativedelta(months=time_value * execution_offset)
662+
end_dt = start_dt + relativedelta(months=time_value * execution_step)
663+
elif time_unit == "days":
664+
start_dt += relativedelta(days=time_value * execution_offset)
665+
end_dt = start_dt + relativedelta(days=time_value * execution_step)
666+
elif time_unit == "hours":
667+
start_dt += relativedelta(hours=time_value * execution_offset)
668+
end_dt = start_dt + relativedelta(hours=time_value * execution_step)
669+
elif time_unit == "minutes":
670+
start_dt += relativedelta(minutes=time_value * execution_offset)
671+
end_dt = start_dt + relativedelta(minutes=time_value * execution_step)
672+
673+
# NOTE: Apply hour adjustments
674+
start_dt += relativedelta(hours=start_add_hours)
675+
end_dt += relativedelta(hours=end_add_hours)
676+
return min(start_dt, end_dt), max(start_dt, end_dt)

0 commit comments

Comments
 (0)