Question

I have daily time series (actually business days) for different companies and I work with PostgreSQL. There is also an indicator variable (called flag) taking the value 0 most of the time, and 1 on some rare event days. If the indicator variable takes the value 1 for a company, I want to further investigate the entries from two days before to one day after that event for the corresponding company. Let me refer to that as [-2,1] window with the event day being day 0.

I am using the following query

CREATE TABLE test AS
WITH cte AS (
   SELECT *
        , MAX(flag) OVER(PARTITION BY company ORDER BY day
                         ROWS BETWEEN 1 preceding AND 2 following) Lead1
   FROM mytable)
SELECT *
FROM cte
WHERE Lead1 = 1 
ORDER BY day,company

The query takes the entries ranging from 2 days before the event to one day after the event, for the company experiencing the event. The query does that for all events.

This is a small section of the resulting table.

day              company    flag     
2012-01-23       A          0        
2012-01-24       A          0         
2012-01-25       A          1         
2012-01-25       B          0         
2012-01-26       A          0         
2012-01-26       B          0        
2012-01-27       B          1        
2012-01-30       B          0        
2013-01-10       A          0        
2013-01-11       A          0              
2013-01-14       A          1              

Now I want to do further calculations for every [-2,1] window separately. So I need a variable that allows me to identify each [-2,1] window. The idea is that I count the number of windows for every company with the variable "occur", so that in further calculations I can use the clause

    GROUP BY company, occur

Therefore my desired output looks like that:

day              company    flag     occur
2012-01-23       A          0        1
2012-01-24       A          0        1 
2012-01-25       A          1        1 
2012-01-25       B          0        1 
2012-01-26       A          0        1 
2012-01-26       B          0        1
2012-01-27       B          1        1
2012-01-30       B          0        1
2013-01-10       A          0        2
2013-01-11       A          0        2
2013-01-14       A          1        2 

In the example, the company B only occurs once (occur = 1). But the company A occurs two times. For the first time from 2012-01-23 to 2012-01-26. And for the second time from 2013-01-10 to 2013-01-14. The second time range of company A does not consist of all four days surrounding the event day (-2,-1,0,1) since the company leaves the dataset before the end of that time range.

As I said I am working with business days. I don't care for holidays, I have data from monday to friday. Earlier I wrote the following function:

CREATE OR REPLACE FUNCTION addbusinessdays(date, integer)
  RETURNS date AS
$BODY$ 
WITH alldates AS (
    SELECT i,
    $1 + (i * CASE WHEN $2 < 0 THEN -1 ELSE 1 END) AS date
    FROM generate_series(0,(ABS($2) + 5)*2) i
),
days AS (
    SELECT i, date, EXTRACT('dow' FROM date) AS dow
    FROM alldates
),
businessdays AS (
    SELECT i, date, d.dow FROM days d
    WHERE d.dow BETWEEN 1 AND 5
    ORDER BY i
)

-- adding business days to a date --
SELECT date FROM businessdays WHERE
        CASE WHEN $2 > 0 THEN date >=$1 WHEN $2 < 0
             THEN date <=$1 ELSE date =$1 END
    LIMIT 1
    offset ABS($2)
$BODY$
  LANGUAGE 'sql' VOLATILE;

It can add/substract business days from a given date and works like that:

    select * from addbusinessdays('2013-01-14',-2)

delivers the result 2013-01-10. So in Jakub's approach we can change the second and third last line to

      w.day BETWEEN addbusinessdays(t1.day, -2) AND addbusinessdays(t1.day, 1)

and can deal with the business days.

Was it helpful?

Solution

Function

While using the function addbusinessdays(), consider this instead:

CREATE OR REPLACE FUNCTION addbusinessdays(date, integer)
  RETURNS date AS
$func$ 
SELECT day
FROM  (
    SELECT i, $1 + i * sign($2)::int AS day
    FROM   generate_series(0, ((abs($2) * 7) / 5) + 3) i
    ) sub
WHERE  EXTRACT(ISODOW FROM day) < 6  -- truncate weekend
ORDER  BY i
OFFSET abs($2)
LIMIT  1
$func$  LANGUAGE sql IMMUTABLE;

Major points

  • Never quote the language name sql. It's an identifier, not a string.

  • Why was the function VOLATILE? Make it IMMUTABLE for better performance in repeated use and more options (like using it in a functional index).

  • (ABS($2) + 5)*2) is way too much padding. Replace with ((abs($2) * 7) / 5) + 3).

  • Multiple levels of CTEs were useless cruft.

  • ORDER BY in last CTE was useless, too.

  • As mentioned in my previous answer, extract(ISODOW FROM ...) is more convenient to truncate weekends.

Query

That said, I wouldn't use above function for this query at all. Build a complete grid of relevant days once instead of calculating the range of days for every single row.

Based on this assertion in a comment (should be in the question, really!):

two subsequent windows of the same firm can never overlap.

WITH range AS (              -- only with flag
   SELECT company
        , min(day) - 2 AS r_start
        , max(day) + 1 AS r_stop
   FROM   tbl t 
   WHERE  flag <> 0
   GROUP  BY 1
   )
, grid AS (
   SELECT company, day::date
   FROM   range r
         ,generate_series(r.r_start, r.r_stop, interval '1d') d(day)
   WHERE  extract('ISODOW' FROM d.day) < 6
   )
SELECT *, sum(flag) OVER(PARTITION BY company ORDER BY day
                         ROWS BETWEEN UNBOUNDED PRECEDING
                         AND 2 following) AS window_nr
FROM  (
   SELECT t.*, max(t.flag) OVER(PARTITION BY g.company ORDER BY g.day
                           ROWS BETWEEN 1 preceding
                           AND 2 following) in_window
   FROM   grid     g
   LEFT   JOIN tbl t USING (company, day)
   ) sub
WHERE  in_window > 0      -- only rows in [-2,1] window
AND    day IS NOT NULL    -- exclude missing days in [-2,1] window
ORDER  BY company, day;

How?

  • Build a grid of all business days: CTE grid.

  • To keep the grid to its smallest possible size, extract minimum and maximum (plus buffer) day per company: CTE range.

  • LEFT JOIN actual rows to it. Now the frames for ensuing window functions works with static numbers.

  • To get distinct numbers per flag and company (window_nr), just count flags from the start of the grid (taking buffers into account).

  • Only keep days inside your [-2,1] windows (in_window > 0).

  • Only keep days with actual rows in the table.

Voilá.

SQL Fiddle.

OTHER TIPS

Basically the strategy is to first enumarate the flag days and then join others with them:

WITH windows AS(
SELECT t1.day
       ,t1.company
       ,rank() OVER (PARTITION BY company ORDER BY day) as rank
FROM table1 t1
WHERE flag =1)

SELECT t1.day
      ,t1.company
      ,t1.flag
      ,w.rank
FROM table1 AS t1
JOIN windows AS w
ON
  t1.company = w.company
  AND
  w.day BETWEEN 
 t1.day - interval '2 day' AND t1.day + interval '1 day'
ORDER BY t1.day, t1.company;

Fiddle.

However there is a problem with work days as those can mean whatever (do holidays count?).

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top