1

I've been through every question and every third party library trying to figure out a way to do this where I don't have to manually map dates.

I'm trying to get the week number of the current fiscal quarter. Each quarter starts on the 1st of either January, April, July or October.

Given a date (string or object, it doesn't matter), I need to be able to calculate the week number of the fiscal quarter that it's in.

To make matters a little more complicated, the Fiscal year starts in April.

So for example, today, July 9th 2020 is week 2 of this Fiscal quarter (Q2), because the quarter starts in April. Similarly, the 29 and 30th of June 2020 are week 14 of quarter 1.

Most time formatting libraries and even the standard library ones have methods like ISO date where I can extract the week number fine. But it's the week number from the 1st day of the year.

I can't use arithmetic to simply remove the number of weeks to the current date as there are a different number of weeks in each quarter. Quarters can have 12, 13 or 14 weeks depending on the year.

The closest I've gotten is using the FiscalYear library which is great as it has a Fiscal Quarter class with it. Unfortunately, the inherited method isoformat() doesn't apply to it. Only the FiscalDate class, which doesn't give me the quarter which I need.

Has anyone run into this? Can someone point me in the right direction?

I'd post code snippets but it's just the 100 ways there are in Python to get the current week number (as of today, that's 28).

I've tried using rrules and deltas in dateutils but the closest I can get is the week number of the 1st quarter using offsets. The second quarter, it falls apart.

I'm happy to use pandas or any other 3rd party library if it will help me avoid hard coding the quarter dates or, god forbid, the week number to dates mappings.

Any help in the right direction would be very much appreciated.


Edit: All three answers below solved this issue for me in different ways. I struggled with which one to give the correct answer to, but I gave it to @Paul's answer as it was the one that I could follow most as someone who isn't a senior. It was also the answer that fit in with my personal use case (that I didn't mention), which was receiving a datetime object and getting the results. So that gave it the edge. Sorry to the others who provided amazing answers. I'm thrilled to have gotten the code what all I was hoping for was a nudge in the right direction. Thank you all.

Dave Davis
  • 844
  • 1
  • 9
  • 19
  • 1
    To me it's not entirely clear how to define the week number based on the quarter. My understanding is that week number and quarter are both defined differently by different organizations / standards. One example, what is the "current quarter week number" for 2021-01-01? ISO says that that is the 53rd week of 2020, not the first week of 2021, so is that 2020-Q4-W1{3,4}, or do you want 2021-Q1-W01? – Paul Jul 09 '20 at 17:23
  • Thanks @Paul The week numbers in this case are simple increments from the calendar week starting from the 1st of each of April, July, October and January. So the 1st of each of those months will always be week 1. How many days that week has will depend how soon the next Monday is. Then it will be week 2. On the other end, week 14 will end whenever the calendar month turns over to the 1st of the month of the next quarter. It has nothing to do with ISO week numbers which is why I'm having so much trouble with this. So given say 9/7/20, I need Q2, Week 2. – Dave Davis Jul 09 '20 at 21:10
  • So you'll have as many as 8 partial weeks per year? If June 30th is a Tuesday (as it was this year), then June 30th is in week 14 of Q2, and Wednesday, July 1st is Week 1 of Q3? – Paul Jul 09 '20 at 22:46
  • Yes, almost,. As Q1 starts in April, June 30th is week 14 of Q1. July 1st is Week 1 of Q2. – Dave Davis Jul 10 '20 at 09:12

3 Answers3

2

Unless this is a very common way to reckon week numbering, I don't know if you are going to find a library that will do this exactly for you, but it's easy enough to accomplish using dateutil's relativedelta and a little logic. Here's a simple implementation that returns a tuple (quarter, week). Since you said that Q1 starts April 1st, I am assuming that the period from January 1st to April 1st is called Q0:

from datetime import date, datetime, timedelta
import typing

from dateutil import relativedelta

NEXT_MONDAY = relativedelta.relativedelta(weekday=relativedelta.MO)
LAST_MONDAY = relativedelta.relativedelta(weekday=relativedelta.MO(-1))
ONE_WEEK = timedelta(weeks=1)


def week_in_quarter(dt: datetime) -> typing.Tuple[int, int]:
    d: date = dt.date()
    year = d.year

    # Q0 = January 1, Q1 = April 1, Q2 = July 1, Q3 = October 1
    quarter = ((d.month - 1) // 3)
    quarter_start = date(year, (quarter * 3) + 1, 1)
    quarter_week_2_monday = quarter_start + NEXT_MONDAY

    if d < quarter_week_2_monday:
        week = 1
    else:
        cur_week_monday = d + LAST_MONDAY
        week = int((cur_week_monday - quarter_week_2_monday) / ONE_WEEK) + 2

    return quarter, week

Which returns:

$ python week_in_quarter.py 
2020-01-01: Q0-W01
2020-02-01: Q0-W05
2020-02-29: Q0-W09
2020-03-01: Q0-W09
2020-06-30: Q1-W14
2020-07-01: Q2-W01
2020-09-04: Q2-W10
2020-12-31: Q3-W14

If I've misunderstood the first quarter of the calendar year, and it's actually the case that January 1–April 1 of year X is considered Q4 of year X-1, then you can change the return quarter, week line at the end to this (and change the return type annotation):

if quarter == 0:
    year -= 1
    quarter = 4

return year, quarter, week

Which changes the return values to:

$ python week_in_quarter.py 
2020-01-01: FY2019-Q4-W01
2020-02-01: FY2019-Q4-W05
2020-02-29: FY2019-Q4-W09
2020-03-01: FY2019-Q4-W09
2020-06-30: FY2020-Q1-W14
2020-07-01: FY2020-Q2-W01
2020-09-04: FY2020-Q2-W10
2020-12-31: FY2020-Q3-W14

If this is something that is a speed bottleneck, it should probably be easy to write an optimized version of this that does not use dateutil.relativedelta, but instead calculates this based on day of week, day of year and whether or not this is a leap year (calendar calculations in Python usually go faster if you can turn it into integer operations as early in the process as possible), but I suspect that in most cases this version should be the easiest to read and understand.

If you would like to avoid the dependency on dateutil, you can replace NEXT_MONDAY and LAST_MONDAY with simple functions:

def next_monday(dt: date) -> date:
    weekday = dt.weekday()
    return dt + timedelta(days=(7 - weekday) % 7)

def last_monday(dt: date) -> date:
    weekday = dt.weekday()
    return dt - timedelta(days=weekday)

In which case you would assign the two _monday variables as quarter_week_2_monday = next_monday(quarter_start) and cur_week_monday = last_monday(dt), respectively.

As a note: if I were writing this function, I'd probably not have it return a bare tuple of integers, but instead use attrs or a dataclass to create a simple class for the purpose, like so:

import attr

@attr.s(auto_attribs=True, frozen=True, slots=True)
class QuarterInWeek:
    year: int
    quarter: int
    week: int

    def __str__(self):
        return f"FY{self.year}-Q{self.quarter}-W{self.week:02d}"

(Note that slots=True is optional, and I think not available if you use dataclasses.dataclass instead — it's just that this is a simple struct and I tend to use slots classes for simple structs).

Paul
  • 10,381
  • 13
  • 48
  • 86
  • No Paul, you didn't misunderstand. That's exactly right. Jan to April is the previous fiscal year. Your code is working for me. That's 3 different correct responses with code included. I'm in my element. Also, thank you for including the info on the data class (as you can tell, I'm not very experienced!) That's the approach I'm taking as I'll be using this so frequently. – Dave Davis Jul 10 '20 at 14:13
  • I assumed that you didn't care about dependencies so I used `dateutil` here, but if avoiding any dependencies is desirable to you, I've updated the answer to add two simple functions that replace the need for `dateutil`. – Paul Jul 10 '20 at 14:20
  • Sorry, I meant any more dependencies. Not because of bloat, but for my own understanding. dateutil is fine, but I appreciate the extra help! – Dave Davis Jul 10 '20 at 21:30
  • Nice job! I like the modern Python – dawg Jul 11 '20 at 00:49
1

I think this does what you need (or at least a very good start):

import datetime as dt

def quarter(date):
    return (date.month-1)//3 + 1 
    
def week_in_q(d):
    year=d.year
    soq={1:dt.date(year,1,1),
         2:dt.date(year,4,1),
         3:dt.date(year,7,1),
         4:dt.date(year,10,1)}
    for i, sow in enumerate(soq[quarter(d)]+dt.timedelta(weeks=x) for x in range(5*3)):
        if sow>=d: 
            return i+1
date=dt.date(2020, 1, 1)    

for d in (date+dt.timedelta(weeks=x) for x in range(53)):
    print(f"date: {d}, quarter: {quarter(d)}, week in that quarter: {week_in_q(d)}")

Prints:

date: 2020-01-01, quarter: 1, week in that quarter: 1
date: 2020-01-08, quarter: 1, week in that quarter: 2
date: 2020-01-15, quarter: 1, week in that quarter: 3
date: 2020-01-22, quarter: 1, week in that quarter: 4
date: 2020-01-29, quarter: 1, week in that quarter: 5
date: 2020-02-05, quarter: 1, week in that quarter: 6
date: 2020-02-12, quarter: 1, week in that quarter: 7
date: 2020-02-19, quarter: 1, week in that quarter: 8
date: 2020-02-26, quarter: 1, week in that quarter: 9
date: 2020-03-04, quarter: 1, week in that quarter: 10
date: 2020-03-11, quarter: 1, week in that quarter: 11
date: 2020-03-18, quarter: 1, week in that quarter: 12
date: 2020-03-25, quarter: 1, week in that quarter: 13
date: 2020-04-01, quarter: 2, week in that quarter: 1
date: 2020-04-08, quarter: 2, week in that quarter: 2
date: 2020-04-15, quarter: 2, week in that quarter: 3
date: 2020-04-22, quarter: 2, week in that quarter: 4
date: 2020-04-29, quarter: 2, week in that quarter: 5
date: 2020-05-06, quarter: 2, week in that quarter: 6
date: 2020-05-13, quarter: 2, week in that quarter: 7
date: 2020-05-20, quarter: 2, week in that quarter: 8
date: 2020-05-27, quarter: 2, week in that quarter: 9
date: 2020-06-03, quarter: 2, week in that quarter: 10
date: 2020-06-10, quarter: 2, week in that quarter: 11
date: 2020-06-17, quarter: 2, week in that quarter: 12
date: 2020-06-24, quarter: 2, week in that quarter: 13
date: 2020-07-01, quarter: 3, week in that quarter: 1
date: 2020-07-08, quarter: 3, week in that quarter: 2
date: 2020-07-15, quarter: 3, week in that quarter: 3
date: 2020-07-22, quarter: 3, week in that quarter: 4
date: 2020-07-29, quarter: 3, week in that quarter: 5
date: 2020-08-05, quarter: 3, week in that quarter: 6
date: 2020-08-12, quarter: 3, week in that quarter: 7
date: 2020-08-19, quarter: 3, week in that quarter: 8
date: 2020-08-26, quarter: 3, week in that quarter: 9
date: 2020-09-02, quarter: 3, week in that quarter: 10
date: 2020-09-09, quarter: 3, week in that quarter: 11
date: 2020-09-16, quarter: 3, week in that quarter: 12
date: 2020-09-23, quarter: 3, week in that quarter: 13
date: 2020-09-30, quarter: 3, week in that quarter: 14
date: 2020-10-07, quarter: 4, week in that quarter: 2
date: 2020-10-14, quarter: 4, week in that quarter: 3
date: 2020-10-21, quarter: 4, week in that quarter: 4
date: 2020-10-28, quarter: 4, week in that quarter: 5
date: 2020-11-04, quarter: 4, week in that quarter: 6
date: 2020-11-11, quarter: 4, week in that quarter: 7
date: 2020-11-18, quarter: 4, week in that quarter: 8
date: 2020-11-25, quarter: 4, week in that quarter: 9
date: 2020-12-02, quarter: 4, week in that quarter: 10
date: 2020-12-09, quarter: 4, week in that quarter: 11
date: 2020-12-16, quarter: 4, week in that quarter: 12
date: 2020-12-23, quarter: 4, week in that quarter: 13
date: 2020-12-30, quarter: 4, week in that quarter: 14
dawg
  • 98,345
  • 23
  • 131
  • 206
  • Thanks for this dawg. This was really helpful. The issue that I'm having is that the first quarter starts in April 1st. So all the quarters in your output should be incremented by one. Because you're basing the quarter mapping by using an instance of the current year, when it moves to the next year, I fall down :( If I simply modify the mapping and add `4:dt.date(year + 1,1,1)}`, this changes all week values to 1. `date: 2020-04-01, quarter: 2, week in that quarter: 1` `date: 2020-04-08, quarter: 2, week in that quarter: 1` – Dave Davis Jul 09 '20 at 21:02
  • Those are relatively small changes. Bet you can figure them out! – dawg Jul 10 '20 at 04:21
  • Thanks! I've enough to get working, I'm on the right track. Thank you so much for helping with this! – Dave Davis Jul 10 '20 at 09:13
  • Finally got this working too. For my use case, I was modifying the wrong lines. Only adjustment I needed to make was in the first method. So thank you so much! I cannot tell you how much I appreciate you (and the other poster) going to the effort to provide code. I wasn't expecting that. I also really appreciate that there's no external dependencies. – Dave Davis Jul 10 '20 at 14:08
1

Here is a simple solution using python's isocalendar library to find the week number:

Note: Week starts on Monday.

from datetime import datetime

FISCAL_QUARTERS = [4, 7, 10, 1]  # April, July, October, January
FISCAL_PERIOD = 3

def _calc_quarter_week(day, month, year):
    fiscal_quarter = None
    # Find which quarter the given date falls in
    for fiscal_index in range(len(FISCAL_QUARTERS)):
        f_month = FISCAL_QUARTERS[fiscal_index]
        if month >= f_month and month < f_month + FISCAL_PERIOD:
            fiscal_quarter = fiscal_index + 1
            break

    quarter_start_day = datetime(
        year=year, month=FISCAL_QUARTERS[fiscal_quarter-1], day=1)
    # Quarter week number
    _, q_week_no, _ = quarter_start_day.isocalendar()

    given_date = datetime(year=year, month=month, day=day)
    # Given week number
    _, given_week_no, _ = given_date.isocalendar()

    return fiscal_quarter, given_week_no - q_week_no + 1


day, month, year = map(int, input('Day Month Year\n').strip().split())
fiscal_quarter, week_count = _calc_quarter_week(day, month, year)
print('Fiscal quarter: {}, Week: {}'.format(fiscal_quarter, week_count))

Outputs:

Day Month Year
29 6 2020
Fiscal quarter: 1, Week: 14
Day Month Year
9 7 2020
Fiscal quarter: 2, Week: 2
Sabareesh
  • 711
  • 1
  • 7
  • 14
  • This is working for me (Thank you!) for some dates. 9 7 2020 works, but 29 6 2020 does not. `Line 17, in _calc_quarter_week quarter_start_day = datetime(year=year, month=FISCAL_QUARTERS[fiscal_quarter - 1], day=1) TypeError: unsupported operand type(s) for -: 'NoneType' and 'int'` I'm not sure why the first for loop isn't setting the fiscal quarter correctly in some cases, leaving it at None. At least that's what I think is happening. Any ideas? – Dave Davis Jul 09 '20 at 20:43
  • I think you might have misplaced the day and month inputs. 29 to month and 6 to day? – Sabareesh Jul 10 '20 at 09:45
  • I suspect my issue was with something else now. This is now working for me, so Thank you so much! I still don't understand the logic of how the you're calculating this in the nested conditional, but I have something to rewrite and play with now. Really appreciate you going to the effort of coding something up from scratch. I wasn't looking for or expecting that at all! – Dave Davis Jul 10 '20 at 14:04
  • @DaveDavis I am basically comparing the given month with the fiscal start month to find the fiscal quarter the given date falls in. For e.g. The 1st fiscal quarter covers the months 4, 5, 6. To find if the given month falls in a fiscal quarter, it is enough to check if given month is greater than fiscal start month and less than the next fiscal start month which is fiscal start month + 3 (fiscal period). – Sabareesh Jul 10 '20 at 14:12