Search code examples
postgresqlintervalsbusiness-intelligencebusiness-logicweekday

How do you get a dynamic 12 business day view in Postgresql?


Here is the code I currently have that gives me the last 12 days

SELECT * 
FROM table
WHERE analysis_date >= current_date - interval '12' day;

analysis_date is the date column in the table. I understand why this isn't working because it's not accounting for business days. How can I rewrite this so that I get an interval of the last 12 business days?

I tried search online and found

extract(dow from (date))

But I couldn't find an example where I need a weekday interval. Any help would be appreciated.


Solution

  • This can be solved with a CTE:

    WITH business_days_back AS (
      WITH RECURSIVE bd(back_day, go_back) AS (
        -- Go back to the previous Monday, allowing for current_date in the weekend
        SELECT CASE extract(dow from current_date)
                 WHEN 0 THEN current_date - 6
                 WHEN 6 THEN current_date - 5
                 ELSE current_date - extract(dow from current_date)::int + 1
               END,
               CASE extract(dow from current_date)
                 WHEN 0 THEN 7
                 WHEN 6 THEN 7
                 ELSE 12 - extract(dow from current_date)::int + 1
               END
        UNION
        -- Go back by the week until go_back = 0
        SELECT CASE
             WHEN go_back >= 5 THEN back_day - 7
             WHEN go_back > 0 THEN back_day - 2 - go_back
           END,
           CASE
             WHEN go_back >= 5 THEN go_back - 5
             WHEN go_back > 0 THEN 0
           END
        FROM bd
      )
      SELECT back_day FROM bd WHERE go_back = 0
    )
    SELECT * FROM my_table WHERE analysis_date >= (SELECT * FROM business_days_back);

    Some explanation:

    • The inner CTE starts off by working back to the previous Monday, compensating for a current_date that falls on a weekend day.
    • The recursive term then adds rows by going back full weeks (back_day - 7 for the calendar date and go_back - 5 for the business days) until go_back = 0.
    • The outer CTE returns the back_day date where go_back = 0. This is therefore a scalar query and you can use it as a sub-query in a filter expression.

    You can change the number of business days to look back by simply changing the numbers 12 and 7 in the initial SELECT in the inner CTE. Keep in mind, though, that the value should be such that it goes back to the previous Monday or the query will fail, due to the same initial SELECT of the inner CTE.

    A far more flexible (and probably faster*) solution is to use the following function:

    CREATE FUNCTION business_days_diff(from_date date, diff int) RETURNS date AS $$
    -- This function assumes Mon-Fri business days
    DECLARE
      start_dow int;
      calc_date date;
      curr_diff int;
      weekend   int;
    BEGIN
      -- If no diff requested, return the from_date. This may be a non-business day.
      IF diff = 0 THEN
        RETURN from_date;
      END IF;
    
      start_dow := extract(dow from from_date)::int;
      calc_date := from_date;
    
      IF diff < 0 THEN -- working backwards
        weekend := -2;
        IF start_dow = 0 THEN -- Fudge initial Sunday to the previous Saturday
          calc_date := calc_date - 1;
          start_dow := 6;
        END IF;
        IF start_dow + diff >= 1 THEN -- Stay in this week
          RETURN calc_date + diff;
        ELSE                             -- Work back to Monday
          calc_date := calc_date - start_dow + 1;
          curr_diff := diff + start_dow - 1;
        END IF;
      ELSE -- Working forwards
        weekend := 2;
        IF start_dow = 6 THEN -- Fudge initial Saturday to the following Sunday
          calc_date := calc_date + 1;
          start_dow := 0;
        END IF;
        IF start_dow + diff <= 5 THEN -- Stay in this week
          RETURN calc_date + diff;
        ELSE                             -- Work forwards to Friday
          calc_date := calc_date + 5 - start_dow;
          curr_diff := diff - 5 + start_dow;
        END IF;
      END IF;
    
      -- Move backwards or forwards by full weeks
      calc_date := calc_date + (curr_diff / 5) * 7;
    
      -- Process any remaining days, include weekend
      IF curr_diff % 5 != 0 THEN
        RETURN calc_date + curr_diff % 5 + weekend;
      ELSE
        RETURN calc_date;
      END IF;
    END; $$ LANGUAGE plpgsql STRICT IMMUTABLE;

    This function can take any date to calculate from and any number of days into the future (positive value of diff) or the past (negative value of diff), including diffs within the current week. And since it returns the business day date as a scalar, use in your query is very straightforward:

    SELECT * 
    FROM table
    WHERE analysis_date >= business_days_diff(current_date, -12);
    

    Apart from that, you can also pass in fields from your table and do funky stuff like:

    SELECT t1.some_value - t2.some_value AS value_diff
    FROM table t1
    JOIN table t2 ON t2.analysis_date = business_days_diff(t1.analysis_date, -12);
    

    i.e. a self-join on a certain number of business days separation.

    Note that this function assumes a Monday-Friday business day week.

    * This function does only simple arithmetic on scalar values. The CTE has to set up all manner of structures to support the iteration and the resulting record sets.