36

I'm trying to add n (integer) working days to a given date, the date addition has to avoid the holidays and weekends (it's not included in the working days)

JasonMArcher
  • 14,195
  • 22
  • 56
  • 52
cyberbrain
  • 639
  • 2
  • 6
  • 12

16 Answers16

46

Skipping weekends would be pretty easy doing something like this:

import datetime
def date_by_adding_business_days(from_date, add_days):
    business_days_to_add = add_days
    current_date = from_date
    while business_days_to_add > 0:
        current_date += datetime.timedelta(days=1)
        weekday = current_date.weekday()
        if weekday >= 5: # sunday = 6
            continue
        business_days_to_add -= 1
    return current_date

#demo:
print '10 business days from today:'
print date_by_adding_business_days(datetime.date.today(), 10)

The problem with holidays is that they vary a lot by country or even by region, religion, etc. You would need a list/set of holidays for your use case and then skip them in a similar way. A starting point may be the calendar feed that Apple publishes for iCal (in the ics format), the one for the US would be http://files.apple.com/calendars/US32Holidays.ics

You could use the icalendar module to parse this.

omz
  • 53,243
  • 5
  • 129
  • 141
21

If you don't mind using a 3rd party library then dateutil is handy

from dateutil.rrule import *
print "In 4 business days, it's", rrule(DAILY, byweekday=(MO,TU,WE,TH,FR))[4]

You can also look at rruleset and using .exdate() to provide the holidays to skip those in the calculation, and optionally there's a cache option to avoid re-calculating that might be worth looking in to.

Jon Clements
  • 138,671
  • 33
  • 247
  • 280
  • 1
    Looks like there might be an extension of `dateutil` that provides this support: https://pypi.python.org/pypi/bdateutil/0.1 – Blairg23 Mar 03 '17 at 20:30
7

There is no real shortcut to do this. Try this approach:

  1. Create a class which has a method skip(self, d) which returns True for dates that should be skipped.
  2. Create a dictionary in the class which contains all holidays as date objects. Don't use datetime or similar because the fractions of a day will kill you.
  3. Return True for any date that is in the dictionary or d.weekday() >= 5

To add N days, use this method:

def advance(d, days):
    delta = datetime.timedelta(1)

    for x in range(days):
        d = d + delta
        while holidayHelper.skip(d):
            d = d + delta

    return d
Aaron Digulla
  • 321,842
  • 108
  • 597
  • 820
7

Thanks based on omz code i made some little changes ...it maybe helpful for other users:

import datetime
def date_by_adding_business_days(from_date, add_days,holidays):
    business_days_to_add = add_days
    current_date = from_date
    while business_days_to_add > 0:
        current_date += datetime.timedelta(days=1)
        weekday = current_date.weekday()
        if weekday >= 5: # sunday = 6
            continue
        if current_date in holidays:
            continue
        business_days_to_add -= 1
    return current_date

#demo:
Holidays =[datetime.datetime(2012,10,3),datetime.datetime(2012,10,4)]
print date_by_adding_business_days(datetime.datetime(2012,10,2), 10,Holidays)
cyberbrain
  • 639
  • 2
  • 6
  • 12
6

I wanted a solution that wasn't O(N) and it looked like a fun bit of code golf. Here's what I banged out in case anyone's interested. Works for positive and negative numbers. Let me know if I missed anything.

def add_business_days(d, business_days_to_add):
    num_whole_weeks  = business_days_to_add / 5
    extra_days       = num_whole_weeks * 2

    first_weekday    = d.weekday()
    remainder_days   = business_days_to_add % 5

    natural_day      = first_weekday + remainder_days
    if natural_day > 4:
        if first_weekday == 5:
            extra_days += 1
        elif first_weekday != 6:
            extra_days += 2

    return d + timedelta(business_days_to_add + extra_days)
royal
  • 530
  • 1
  • 6
  • 12
  • Yes, we need something not O(N), but is that really possible? How to avoid the holidays in between? – Ethan Feb 11 '16 at 04:57
  • 1
    Awesome solution, although it doesn't handle holidays as the OP wanted. Also, you should use `math.floor(business_days_to_add / 5)` in the second line so it works in Python3 as well. – lufte Oct 19 '16 at 14:56
  • Some unit tests worth adding: `assert add_business_days(dt.date(2019,1,4),0) == dt.date(2019, 1, 4); assert add_business_days(dt.date(2019,1,4),1) == dt.date(2019, 1, 7); assert add_business_days(dt.date(2019,1,4),5) == dt.date(2019, 1, 11); assert add_business_days(dt.date(2019,1,4),6) == dt.date(2019, 1, 14);` – Shadi Nov 23 '22 at 19:49
2

I know it does not handle holidays, but I found this solution more helpful because it is constant in time. It consists of counting the number of whole weeks, adding holidays is a little more complex. I hope it can help somebody :)

def add_days(days):
    today = datetime.date.today()
    weekday = today.weekday() + ceil(days)
    complete_weeks = weekday // 7
    added_days = weekday + complete_weeks * 2
    return today + datetime.timedelta(days=added_days)
polmonroig
  • 937
  • 4
  • 12
  • 22
1

This will take some work since there isn't any defined construct for holidays in any library (by my knowledge at least). You will need to create your own enumeration of those.

Checking for weekend days is done easily by calling .weekday() < 6 on your datetime object.

Tim Lamballais
  • 1,056
  • 5
  • 10
  • This actually is a little confusing. `.weekday()` returns 0 - 6. 0 is Monday and 6 is Sunday. So if you are wanting to check if a day is a weekend you need to check `.weekday() > 4`. This would return true for Saturday (5) and Sunday (6). `.weekday() < 6` would actually return true for Monday through Saturday. Would you mind updating your answer? – brandonbanks Jun 05 '19 at 14:39
1

Refactoring omz code, and using holidays package, this is what I use to add business days taking into account the country's holidays

import datetime
import holidays


def today_is_holiday(date):
    isHoliday = date.date() in [key for key in holidays.EN(years = date.year).keys()]
    isWeekend = date.weekday() >= 5
    return isWeekend or isHoliday


def date_by_adding_business_days(from_date, add_days):
    business_days_to_add = add_days
    current_date = from_date
    while business_days_to_add > 0:
        current_date += datetime.timedelta(days=1)
        if today_is_holiday(current_date):
            continue
        business_days_to_add -= 1
    return current_date

Dinis Rodrigues
  • 558
  • 6
  • 19
0

Hope this helps. It's not O(N) but O(holidays). Also, holidays only works when the offset is positive.

def add_working_days(start, working_days, holidays=()):
    """
    Add working_days to start start date , skipping weekends and holidays.

    :param start: the date to start from
    :type start: datetime.datetime|datetime.date
    :param working_days: offset in working days you want to add (can be negative)
    :type working_days: int
    :param holidays: iterator of datetime.datetime of datetime.date instances
    :type holidays: iter(datetime.date|datetime.datetime)
    :return: the new date wroking_days date from now
    :rtype: datetime.datetime
    :raise:
        ValueError if working_days < 0  and holidays 
    """
    assert isinstance(start, (datetime.date, datetime.datetime)), 'start should be a datetime instance'
    assert isinstance(working_days, int)
    if working_days < 0 and holidays:
        raise ValueError('Holidays and a negative offset is not implemented. ')
    if working_days  == 0:
        return start
    # first just add the days
    new_date = start + datetime.timedelta(working_days)
    # now compensate for the weekends.
    # the days is 2 times plus the amount of weeks are included in the offset added to the day of the week
    # from the start. This compensates for adding 1 to a friday because 4+1 // 5 = 1
    new_date += datetime.timedelta(2 * ((working_days + start.weekday()) // 5))
    # now compensate for the holidays
    # process only the relevant dates so order the list and abort the handling when the holiday is no longer
    # relevant. Check each holiday not being in a weekend, otherwise we don't mind because we skip them anyway
    # next, if a holiday is found, just add 1 to the date, using the add_working_days function to compensate for
    # weekends. Don't pass the holiday to avoid recursion more then 1 call deep.
    for hday in sorted(holidays):
        if hday < start:
            # ignore holidays before start, we don't care
            continue
        if hday.weekday() > 4:
            # skip holidays in weekends
            continue
        if hday <= new_date:
            # only work with holidays up to and including the current new_date.
            # increment using recursion to compensate for weekends
            new_date = add_working_days(new_date, 1)
        else:
            break
    return new_date
Remco
  • 435
  • 3
  • 10
  • I see a few bugs. If you start on a Sunday and add 1 day. since start.weekday() = 6, so it's going to add 2 additional days even though only 1 is all that is needed. It should be Monday not Wednesday. Also when you add days for the weekend, you don't check if any weekends are going to be passed over. – Super Scary Jun 11 '18 at 21:05
0

If someone needs to add/substract days, extending @omz's answer:

def add_business_days(from_date, ndays):
    business_days_to_add = abs(ndays)
    current_date = from_date
    sign = ndays/abs(ndays)
    while business_days_to_add > 0:
        current_date += datetime.timedelta(sign * 1)
        weekday = current_date.weekday()
        if weekday >= 5: # sunday = 6
            continue
        business_days_to_add -= 1
    return current_date
arod
  • 13,481
  • 6
  • 31
  • 39
0

similar to @omz solution but recursively:

def add_days_skipping_weekends(start_date, days):
    if not days:
        return start_date
    start_date += timedelta(days=1)
    if start_date.weekday() < 5:
        days -= 1
    return add_days_skipping_weekends(start_date, days)
Adam
  • 459
  • 2
  • 17
0

If you are interested in using NumPy, then you can follow the solution below:

import numpy as np
from datetime import datetime, timedelta

def get_future_date_excluding_weekends(date,no_of_days):
 """This methods return future date by adding given number of days excluding 
  weekends"""
  future_date = date + timedelta(no_of_days)
  no_of_busy_days = int(np.busday_count(date.date(),future_date.date()))
  if no_of_busy_days != no_of_days:
    extend_future_date_by = no_of_days - no_of_busy_days
    future_date = future_date + timedelta(extend_future_date_by)
  return future_date
Shayan Shafiq
  • 1,447
  • 5
  • 18
  • 25
0

This is the best solution because it has O(1) complexity (no loop) and no 3-rd party, but it does not take into account the holidays:

 def add_working_days_to_date(self, start_date, days_to_add):
    from datetime import timedelta
    start_weekday = start_date.weekday()

    # first week
    total_days = start_weekday + days_to_add
    if total_days < 5:
        return start_date + timedelta(days=total_days)
    else:
        # first week
        total_days = 7 - start_weekday
        days_to_add -= 5 - start_weekday

        # middle whole weeks
        whole_weeks = days_to_add // 5
        remaining_days = days_to_add % 5
        total_days += whole_weeks * 7
        days_to_add -= whole_weeks * 5

        # last week
        total_days += remaining_days

        return start_date + timedelta(days=total_days)

Even though this does not fully solves your problem, I wanted to let it here because the solutions found on the internet for adding working days to dates, all of them have O(n) complexity.

Keep in mind that, if you want to add 500 days to a date, you will go through a loop and make the same set of computations 500 times. The above approach operates in the same amount of time, no matter how many days you have.

This was heavily tested.

Alex M.M.
  • 501
  • 1
  • 7
  • 18
0

Use numpy (you can skip holidays too):

np.busday_offset(
    np.datetime64('2022-12-08'),
    offsets=range(12),
    roll='following',
    weekmask="1111100",
    holidays=[])

Result:

array(['2022-12-08', '2022-12-09', '2022-12-12', '2022-12-13',
       '2022-12-14', '2022-12-15', '2022-12-16', '2022-12-19',
       '2022-12-20', '2022-12-21', '2022-12-22', '2022-12-23'],
      dtype='datetime64[D]')
Steve Schulist
  • 931
  • 1
  • 11
  • 18
0

Similar to @omz, but allows days to be added and subtracted

import datetime as dt
import operator


def add_weekdays(start_date: dt.date | dt.datetime, days: int) -> dt.date | dt.datetime:
    op = operator.sub if days < 0 else operator.add
    days_moved, end_date = 0, start_date

    while days_moved < abs(days):
        end_date = op(end_date, dt.timedelta(days=1))

        if end_date.isoweekday() < 6:
            days_moved += 1

    return end_date
rwb
  • 4,309
  • 8
  • 36
  • 59
-1

I am using following code to handle business date delta. For holidays, you need to create your own list to skip.

today = datetime.now()
t_1 = today - BDay(1)
t_5 = today - BDay(5)
t_1_str = datetime.strftime(t_1, "%Y%m%d")
Jaroslav Bezděk
  • 6,967
  • 6
  • 29
  • 46