#!/usr/bin/env python
"""Date Internal Module - Date/time conversion and timezone utilities.
This module provides internal utilities for date/time conversions,
timezone handling, and numeric date representations used throughout
backtrader.
Classes:
_UTC: UTC timezone implementation.
_LocalTimezone: Local timezone with DST support.
Functions:
tzparse: Parse timezone specification.
Localizer: Add localize method to timezone objects.
num2date: Convert numeric date to datetime.
num2dt: Convert numeric date to date.
num2time: Convert numeric date to time.
date2num: Convert datetime to numeric format.
time2num: Convert time to numeric format.
Constants:
UTC: Singleton UTC timezone instance.
TZLocal: Singleton local timezone instance.
TIME_MAX: Maximum time value (23:59:59.999990).
TIME_MIN: Minimum time value (00:00:00).
"""
import datetime
import math
import time as _time
from functools import lru_cache
import pytz
from .py3 import string_types
# from numba import jit
# Time difference for 0
ZERO = datetime.timedelta(0)
# Use time module's timezone attribute to return local timezone's (without DST) offset seconds from Greenwich (>0 Americas, <=0 most Europe, Asia, Africa)
# STDOFFSET represents offset when not in DST
STDOFFSET = datetime.timedelta(seconds=-_time.timezone)
# time.daylight being 0 means no DST, non-zero means DST
if _time.daylight:
# time.altzone returns local DST timezone offset, seconds west of UTC (if one is defined)
# DSTOFFSET offset during DST
DSTOFFSET = datetime.timedelta(seconds=-_time.altzone)
else:
DSTOFFSET = STDOFFSET
# DSTDIFF represents difference between DST and non-DST offsets
DSTDIFF = DSTOFFSET - STDOFFSET
# To avoid rounding errors, taking dates to next day
# Set TIME_MAX to avoid rounding errors causing dates to enter next day
TIME_MAX = datetime.time(23, 59, 59, 999990)
# To avoid rounding errors, taking dates to next day
# Set TIME_MIN to avoid rounding errors causing dates to enter next day
TIME_MIN = datetime.time.min
# Get the most recent bar update time point
[docs]
def get_last_timeframe_timestamp(timestamp, time_diff):
"""Get previous whole minute timestamp based on current timestamp
:params timestamp int, calculate from int(time.time())
:params time_diff int, e.g. 1m timeframe using 60
:returns timestamp int
"""
while True:
if timestamp % time_diff == 0:
return timestamp
timestamp -= 1
[docs]
def get_string_tz_time(tz="Asia/Singapore", string_format="%Y-%m-%d %H:%M:%S.%f"):
"""generate string timezone datetime in particular timezone
param: tz (str): timezone in pytz.common_timezones
param: string_format (str): string format
Return: now (String): timestamp
"""
tz = pytz.timezone(tz)
now = datetime.datetime.now(tz).strftime(string_format)
return now
[docs]
def timestamp2datetime(timestamp):
"""Convert timestamp to datetime
param: timestamp timestamp
param: string_format (str): string format
Return: formatted_time (Str): timestamp
"""
# Convert timestamp to datetime object
dt_object = datetime.datetime.fromtimestamp(timestamp)
return dt_object
[docs]
def timestamp2datestr(timestamp):
"""Convert timestamp to string time
param: timestamp timestamp
param: string_format (str): string format
Return: formatted_time (Str): timestamp
"""
# Convert timestamp to datetime object
dt_object = datetime.datetime.fromtimestamp(timestamp)
# Format datetime object as string
formatted_time = dt_object.strftime("%Y-%m-%d %H:%M:%S.%f")
return formatted_time
[docs]
def datetime2timestamp(time_date, string_format="%Y-%m-%d %H:%M:%S.%f"):
"""Convert datetime to timestamp
param: datetime_string (str): timezone in pytz.common_timezones
param: string_format (str): string format
Return: timestamp
"""
# Format datetime object as timestamp
timestamp = time_date.timestamp()
return timestamp
[docs]
def datestr2timestamp(
datetime_string="2023-06-01 09:30:00.0", string_format="%Y-%m-%d %H:%M:%S.%f"
):
"""Convert datetime to timestamp
param: datetime_string (str): timezone in pytz.common_timezones
param: string_format (str): string format
Return: timestamp
"""
# Convert timestamp to datetime object
time_date = datetime.datetime.strptime(datetime_string, string_format)
# Format datetime object as timestamp
timestamp = time_date.timestamp()
return timestamp
[docs]
def str2datetime(datetime_string="2023-06-01 09:30:00.0", string_format="%Y-%m-%d %H:%M:%S.%f"):
"""Convert string format time to datetime
param: datetime_string (str): timezone in pytz.common_timezones
param: string_format (str): string format
Return: datetime
"""
return datetime.datetime.strptime(datetime_string, string_format)
[docs]
def datetime2str(datetime_obj, string_format="%Y-%m-%d %H:%M:%S.%f"):
"""Convert datetime to string format time
param: datetime_obj (datetime): timezone in pytz.common_timezones
param: string_format (str): string format
Return: datetime_str
"""
return datetime_obj.strftime(string_format)
[docs]
def tzparse(tz):
"""Parse a timezone specification into a tzinfo object.
Args:
tz: Timezone specification (string, tzinfo object, or None).
Returns:
A tzinfo object. If pytz is available and tz is a string,
returns the corresponding pytz timezone. Otherwise returns
a Localizer-wrapped tz object.
"""
# This function attempts to convert tz
# If no object has been provided by the user and a timezone can be
# found via contractdtails, then try to get it from pytz, which may or
# may not be available.
tzstr = isinstance(tz, string_types)
if tz is None or not tzstr:
return Localizer(tz)
try:
import pytz # keep the import very local
except ImportError:
return Localizer(tz) # nothing can be done
tzs = tz
if tzs == "CST": # usual alias
tzs = "CST6CDT"
try:
tz = pytz.timezone(tzs)
except pytz.UnknownTimeZoneError:
return Localizer(tz) # nothing can be done
return tz
[docs]
def Localizer(tz):
"""Add a localize method to a timezone object.
This function adds a localize method to tz objects that don't
have one, allowing consistent timezone localization across
different timezone implementations.
Args:
tz: Timezone object to add localize method to.
Returns:
The same timezone object with a localize method added.
"""
# This function adds a localize method to tz, this localize method adds timezone info to dt
# tzparse and Localizer are mainly for handling different timezones during live trading
import types
def localize(self, dt):
return dt.replace(tzinfo=self)
if tz is not None and not hasattr(tz, "localize"):
# patch the tz instance with a bound method
tz.localize = types.MethodType(localize, tz)
return tz
# A UTC class, same as the one in the Python Docs
class _UTC(datetime.tzinfo):
"""UTC timezone implementation.
A simple UTC timezone class that implements the tzinfo interface
with zero offset (no DST).
"""
# UTC class
def utcoffset(self, dt):
"""Return UTC offset (always zero)."""
return ZERO
def tzname(self, dt):
"""Return timezone name (UTC)."""
return "UTC"
def dst(self, dt):
"""Return DST offset (always zero - UTC has no DST)."""
return ZERO
def localize(self, dt):
"""Localize a naive datetime to UTC.
Args:
dt: Naive datetime to localize.
Returns:
Datetime with UTC timezone info.
"""
return dt.replace(tzinfo=self)
class _LocalTimezone(datetime.tzinfo):
"""Local timezone with DST support.
Implements the local system timezone with automatic DST
(daylight saving time) calculation.
"""
# Timezone offset
def utcoffset(self, dt):
"""Return the UTC offset for this timezone.
Args:
dt: Datetime to calculate offset for.
Returns:
Timedelta offset from UTC (includes DST if applicable).
"""
if self._isdst(dt):
return DSTOFFSET
else:
return STDOFFSET
# DST offset, offset is 0 when not in DST
def dst(self, dt):
"""Return the DST offset.
Args:
dt: Datetime to calculate DST offset for.
Returns:
Timedelta DST adjustment (zero if not in DST).
"""
if self._isdst(dt):
return DSTDIFF
else:
return ZERO
# Possibly timezone name
def tzname(self, dt):
"""Return the timezone name.
Args:
dt: Datetime to get name for.
Returns:
String timezone name (e.g., 'EST' or 'EDT').
"""
return _time.tzname[self._isdst(dt)]
# Determine if current time is DST
def _isdst(self, dt):
tt = (dt.year, dt.month, dt.day, dt.hour, dt.minute, dt.second, dt.weekday(), 0, 0)
try:
stamp = _time.mktime(tt)
except (ValueError, OverflowError):
return False # Too far in the future, not relevant
tt = _time.localtime(stamp)
return tt.tm_isdst > 0
# Add timezone info to dt
def localize(self, dt):
"""Localize a naive datetime to this timezone.
Args:
dt: Naive datetime to localize.
Returns:
Datetime with local timezone info.
"""
return dt.replace(tzinfo=self)
UTC = _UTC()
TZLocal = _LocalTimezone()
HOURS_PER_DAY = 24.0 # 24 hours in a day
MINUTES_PER_HOUR = 60.0 # 60 minutes in 1 hour
SECONDS_PER_MINUTE = 60.0 # 60 seconds in 1 minute
MUSECONDS_PER_SECOND = 1e6 # How many microseconds in 1 second
MINUTES_PER_DAY = MINUTES_PER_HOUR * HOURS_PER_DAY # How many minutes in 1 day
SECONDS_PER_DAY = SECONDS_PER_MINUTE * MINUTES_PER_DAY # How many seconds in 1 day
MUSECONDS_PER_DAY = MUSECONDS_PER_SECOND * SECONDS_PER_DAY # How many microseconds in 1 day
# The following four functions are frequently used, after comments are completed,
# try using cython to rewrite, see how much speed can be improved
@lru_cache(maxsize=8192)
def _num2date_cached(x):
"""Cached core computation for num2date (tz=None, naive=True case).
PERFORMANCE OPTIMIZATION: Cache common datetime conversions.
Called 2.2M+ times, caching reduces repeated calculations.
"""
# CRITICAL FIX: Handle invalid datetime values (0, NaN, negative)
if x != x or x <= 0: # NaN check or invalid value
return datetime.datetime(1970, 1, 1)
ix = int(x)
if ix < 1:
ix = 1
dt = datetime.datetime.fromordinal(ix)
remainder = float(x) - ix
hour, remainder = divmod(HOURS_PER_DAY * remainder, 1)
minute, remainder = divmod(MINUTES_PER_HOUR * remainder, 1)
second, remainder = divmod(SECONDS_PER_MINUTE * remainder, 1)
microsecond = int(MUSECONDS_PER_SECOND * remainder)
if microsecond < 10:
microsecond = 0
dt = datetime.datetime(
dt.year, dt.month, dt.day, int(hour), int(minute), int(second), microsecond
)
if microsecond > 999990:
dt += datetime.timedelta(microseconds=1e6 - microsecond)
return dt
[docs]
def num2date(x, tz=None, naive=True):
# Same as matplotlib except if tz is None, a naive datetime object
# will be returned.
"""
*x* is a float value that gives the number of days
(fraction part represents hours, minutes, seconds) since
0001-01-01 00:00:00 UTC *plus* *one*.
The addition of one here is a historical artifact. Also, note
that the Gregorian calendar is assumed; this is not universal
practice. For details, see the module docstring.
Return value is a: class:`datetime` instance in timezone *tz* (default to
rcparams TZ value).
If *x* is a sequence, a sequence of: class:`datetime` objects will
be returned.
"""
# PERFORMANCE OPTIMIZATION: Fast path for most common case (tz=None, naive=True)
if tz is None:
return _num2date_cached(x)
# Slow path: handle timezone conversion
# CRITICAL FIX: Handle invalid datetime values (0, NaN, negative)
if x != x or x <= 0: # NaN check or invalid value
return datetime.datetime(1970, 1, 1)
ix = int(x)
if ix < 1:
ix = 1
dt = datetime.datetime.fromordinal(ix)
remainder = float(x) - ix
hour, remainder = divmod(HOURS_PER_DAY * remainder, 1)
minute, remainder = divmod(MINUTES_PER_HOUR * remainder, 1)
second, remainder = divmod(SECONDS_PER_MINUTE * remainder, 1)
microsecond = int(MUSECONDS_PER_SECOND * remainder)
if microsecond < 10:
microsecond = 0
# Compose time with timezone
dt = datetime.datetime(
dt.year, dt.month, dt.day, int(hour), int(minute), int(second), microsecond, tzinfo=UTC
)
dt = dt.astimezone(tz)
if naive:
dt = dt.replace(tzinfo=None)
if microsecond > 999990:
dt += datetime.timedelta(microseconds=1e6 - microsecond)
return dt
# Convert number to date
[docs]
def num2dt(num, tz=None, naive=True):
"""Convert numeric date to date object.
Args:
num: Numeric date value (days since 0001-01-01 UTC + 1).
tz: Timezone for the result (optional).
naive: If True, return naive date without timezone info.
Returns:
date: Date object extracted from the datetime.
"""
return num2date(num, tz=tz, naive=naive).date()
# Convert number to time
[docs]
def num2time(num, tz=None, naive=True):
"""Convert numeric date to time object.
Args:
num: Numeric date value (days since 0001-01-01 UTC + 1).
tz: Timezone for the result (optional).
naive: If True, return naive time without timezone info.
Returns:
time: Time object extracted from the datetime.
"""
return num2date(num, tz=tz, naive=naive).time()
# Convert datetime to number
[docs]
def date2num(dt, tz=None):
"""
Convert: mod:`datetime` to the Gregorian date as UTC float days,
preserving hours, minutes, seconds and microseconds. Return value
is a: func:`float`.
"""
if tz is not None:
dt = tz.localize(dt)
if hasattr(dt, "tzinfo") and dt.tzinfo is not None:
delta = dt.tzinfo.utcoffset(dt)
if delta is not None:
dt -= delta
base = float(dt.toordinal())
if hasattr(dt, "hour"):
# base += (dt.hour / HOURS_PER_DAY +
# dt.minute / MINUTES_PER_DAY +
# dt.second / SECONDS_PER_DAY +
# dt.microsecond / MUSECONDS_PER_DAY)
base = math.fsum(
(
base,
dt.hour / HOURS_PER_DAY,
dt.minute / MINUTES_PER_DAY,
dt.second / SECONDS_PER_DAY,
dt.microsecond / MUSECONDS_PER_DAY,
)
)
return base
# Convert time to number
[docs]
def time2num(tm):
"""
Converts the hour/minute/second/microsecond part of tm (datetime.datetime
or time) to a num
"""
num = (
tm.hour / HOURS_PER_DAY
+ tm.minute / MINUTES_PER_DAY
+ tm.second / SECONDS_PER_DAY
+ tm.microsecond / MUSECONDS_PER_DAY
)
return num