Way too often, SAS programmers run into a task when for a given date (e.g. event date) there is a need to shift (add or subtract) it by a specified number of days excluding weekends and holidays — in other words to move a date by a given number of workdays. It does not matter how many days off are in our date span as long as it contains exactly the required number of workdays.
For the purpose of this blog post, we will use the following words as synonyms: workdays, work days, working days, business days; as opposed to their antonym: days off.
In the ideal world
If not for gifts from governments and employers called holidays, shifting (incrementing or decrementing) a date by a number of workdays using SAS would be a piece of cake. It’s literally a single line of code using INTNX function with the WEEKDAY date interval. For example, the following DATA Step code:
data _null_; event_date = '02JAN2020'd; shift_date = intnx('weekday', event_date, -10); put shift_date= date9.; run;
produces in the SAS log:
- 'weekday' is date interval covering Monday through Friday;
- event_date is starting date point;
- -10 is number of workdays to shift by (positive number means increment; negative number means decrement).
Note, that the WEEKDAY date interval can be modified to accommodate different weekend days. For example:
- WEEKDAY17W - five-day work week with a Sunday (1) and Saturday (7) weekend (same as WEEKDAY);
- WEEKDAY1W - six-day week with Sunday (1) as the only weekend day;
- WEEKDAY67W - five-day week with Friday (6) and Saturday (7) as weekend days, etc.
In the real world, however, weekends defined by the WEEKDAY interval are not the only days off, as they do not account for holidays. In the example above, when we shifted our starting date (2 January 2020) by -10 we arrived at 19 December 2019 which means we miscounted several holidays as workdays.
Which holidays (and how many) we miscounted depends on jurisdiction (country, state, business), as their holidays schedules vary. For example, for US federal agencies we would miss (1. New Year – 1Jan2020, 2. Christmas Day – 25Dec2019, and 3. Christmas Eve Day – 24Dec2019 – although this is not an official US federal holiday, most federal employees were given that day off by presidential executive order).
For SAS Institute (USA), we would miscue 6 non-weekend holiday days (Winter Holiday 25Dec2019 – 27Dec2019 and 30Dec2019 - 1Jan2020).
In other countries or businesses, this holidays schedule might be quite different, and that is why this date-shifting task that would account for holidays schedule causes so much confusion. Let’s straighten it out with the help of our old friend – SAS user-defined format. But first, let’s create a workday calendar – a data table listing all OUR work days as well as days off.
Practically every organization has (or must have) a workday calendar that defines the valid working days and consists of a repeating pattern of days on and days off, as well as exceptions to that pattern. While such a calendar may span multiple years, for our purposes, we can use a subset of that calendar, which reliably covers the date range of our interest.
Let’s create an example of the workday calendar as a SAS data table:
data DAYS_OF_WEEK; format DATE date9.; do DATE='01JAN2019'd to '31JAN2020'd; WEEK_DAY = weekday(DATE); DAY_NAME = put(DATE,downame.); WORK_DAY = 1<WEEK_DAY<7; output; end; run; data DAYS_HOLIDAY; format DATE date9.; input DATE date9.; WORK_DAY = 0; datalines; 01JAN2019 21JAN2019 18FEB2019 27MAY2019 04JUL2019 02SEP2019 11NOV2019 28NOV2019 24DEC2019 25DEC2019 01JAN2020 20JAN2020 ; /* Overlay holidays onto weekdays */ data DAYS_WEEKENDS_AND_HOLIDAYS; merge DAYS_OF_WEEK DAYS_HOLIDAY; by DATE; run;
Here is a fragment of the resulting workday calendar table:
If date shifting is needed on an individual-level, then workday calendars should be created for every person and must include working days, weekends, holidays as well as personal days off such as vacations, sick days etc.
SAS format to distinguish workdays from days off
Now, for the dates range of our interest, we want to create a SAS user-defined format that lists all the days off while workdays are lumped into the other category. It’s just more efficient that way, as the number of days off is usually smaller than the number of work days so our explicit list of dates will be shorter. For example:
proc format; value dayoff '01DEC2019'd = 'Y' '07DEC2019'd = 'Y' '08DEC2019'd = 'Y' . . . '24DEC2019'd = 'Y' '25DeC2019'd = 'Y' '01JAN2020'd = 'Y' '20JAN2020'd = 'Y' other = 'N' ; run;
In this user-defined SAS format values labeled 'Y' mean day off, and values labeled 'N' mean workday. That includes and takes care of both weekends and holidays.
The proc format above serves only for illustrational purposes of what kind of format we are going to create. However, by no means do I suggest implementing it this hard-coded way. Quite the contrary, we are going to create format dynamically and 100% data-driven. Here is how we do it:
data WORK.DAYSOFF (rename=(DATE=START)); set DAYS_WEEKENDS_AND_HOLIDAYS(where=(WORK_DAY=0)) end=last; retain FMTNAME 'dayoff' TYPE 'n' LABEL 'Y'; output; if last then do; HLO = 'O'; LABEL = 'N'; output; end; run; proc format cntlin=WORK.DAYSOFF; run;
In the above code, HLO='O' and LABEL='N' are responsible for generating the OTHER category for the dayoff format.
Shifting dates by a number of workdays using dayoff format
With the dayoff user-defined format at hands, we can easily increment or decrement dates by a number of workdays. Here is how:
/* data table of some dates */ data EVENTS; do EVENT_DATE='01DEC2019'd to '31DEC2019'd; output; end; format EVENT_DATE date9.; run; /* Calculating new dates shifted by a number of workdays */ data EVENTS_WITH_SHIFTS; set EVENTS; /* Decrement EVENT_DATE by 10 workdays */ d = EVENT_DATE; do i=1 to 10; d = d - 1; if put(d, dayoff.)='Y' then i = i - 1; end; BEFORE_DATE = d; /* Increment EVENT_DATE by 12 workdays */ d = EVENT_DATE; do i=1 to 12; d = d + 1; if put(d, dayoff.)='Y' then i = i - 1; end; AFTER_DATE = d; format BEFORE_DATE AFTER_DATE date9.; drop d i; run;
In this code, we decrement (d=d-1) or increment (d=d+1) our event date every time the do-loop iterates. It will iterate while counter i does not exceed the number of workdays. However, within the do-loop we modify counter i to i-1 every time we come across a day off as determined by condition put(d,dayoff.)='Y'. This will effectively exclude days off from counting towards the number of workdays. The do-loop will iterate the number of workdays plus the number of days off thus moving date d by the number of days that includes exactly the given number of workdays (plus some number of days off which we don’t care about). Just pause for a second and think to absorb this.
This simple technique can be modularized by implementing it as a SAS user-defined function or a SAS data-step macro.
User-defined function to shift a date by a number of workdays
Here is the user-defined function shiftwd() that shifts a beginning date specified in the first argument from_date by a number of workdays specified in the second argument shift_by. The second argument can be either positive or negative. Positive second argument means advancing the first argument (incrementing); negative second argument means subtracting workdays from the first argument (decrementing). Both arguments can be either variable names or numerals representing whole numbers.
libname funclib 'c:\projects\shift\functions'; proc fcmp outlib=funclib.funcs.dates; function shiftwd(from_date, shift_by); d = from_date; do i=1 to abs(shift_by); d = d + sign(shift_by); if put(d,dayoff.)='Y' then i = i - 1; end; return(d); endfunc; run;
Function usage example:
libname funclib 'c:\projects\shift\functions'; options cmplib= funclib.funcs; data EVENTS_WITH_SHIFTS; set EVENTS; BEFORE_DATE = shiftwd(EVENT_DATE,-10); /* Decrement EVENT_DATE by 10 workdays */ AFTER_DATE = shiftwd(EVENT_DATE, 12); /* Increment EVENT_DATE by 12 workdays */ format BEFORE_DATE AFTER_DATE date9.; run;
SAS macro to shift a date by a number of workdays
Similarly, the same can be implemented as a data-step macro:
%macro shiftwd (fromvar=,endvar=,wdays=,sign=); &endvar = &fromvar; do i=1 to &wdays; &endvar = &endvar &sign 1; if put(&endvar, dayoff.)='Y' then i = i - 1; end; drop i; %mend;
This macro has 4 required parameters:
- fromvar - variable name of the beginning date;
- endvar - variable name of the ending date;
- wdays - variable name or numeral representing number of workdays to move from the beginning date;
- sign - operation sign defining direction of the date move (+ for incrementing, - for decrementing).
Macro usage example:
data EVENTS_WITH_SHIFTS; set EVENTS; %shiftwd(fromvar=EVENT_DATE,endvar=BEFORE_DATE,wdays=10,sign=-); /* Decrement EVENT_DATE by 10 workdays */ %shiftwd(fromvar=EVENT_DATE,endvar=AFTER_DATE, wdays=12,sign=+); /* Increment EVENT_DATE by 12 workdays */ format BEFORE_DATE AFTER_DATE date9.; run;
Custom Time Intervals (SAS Documentation)
Do you find this material useful? How do you handle the task of adding or subtracting workdays from a date? Please share in the comments section below.