Présentation
Calculer le prochain jour ouvrable semble trivial — jusqu'à ce qu'un vendredi de décrampon tombe un Aid Al Adha, ou qu'un lundi de reporting tombe sur une fête nationale décrétée deux jours plus tôt. Ce type d'erreur silencieuse dans un pipeline ETL produit des trous dans les séries chronologiques, des VNI calculées sur des dates incorrectes, ou des orchestrateurs qui tournent à vide.
L'API des jours ouvrables de Calendar API centralise cette logique et expose des opérations métier directement exploitables : prochain/précédent jour ouvrable, comptage, listing, et le concept de CalSpan pour les bornes de périodes.
Définition d'un jour ouvrable
Dans le contexte de Calendar API pour le Maroc, un jour ouvrable est un jour qui remplit toutes les conditions suivantes :
- N'est pas un samedi ni un dimanche
- N'est pas un jour férié
National,ReligiousouExceptionalau statutOfficial
Estimated, les jours ouvrables sont calculés en supposant qu'elle sera confirmée à la date estimée. Consultez le guide jours fériés pour le pattern de polling. Installation
pip install pycalendar-api
from pycalendar_api import CalendarApi, to_date
api = CalendarApi.from_env() ## export PYCALENDAR_APIKEY = YOUR_API_KEY
# ou : CalendarApi('YOUR_KEY')
Prochain / précédent jour ouvrable
Ces deux opérations sont le cœur de l'API pour les cas d'usage de type settlement, valeur liquidative ou date d'effet : trouver le prochain ou le précédent jour où les marchés et institutions sont ouverts.
from pycalendar_api import CalendarApi, to_date
from datetime import date
api = CalendarApi.from_env()
# Lundi 13 jan 2025 → mardi 15 (le 14 est Nouvel An Amazigh)
nxt = api.bdays.next_date(to_date('13/01/2025'))
print(nxt)
# NextDate(date=datetime.date(2025, 1, 13),
# next_date=datetime.date(2025, 1, 15))
# Mercredi 15 jan → lundi 13 (le 14 est férié)
prev = api.bdays.previous_date(to_date('15/01/2025'))
print(prev)
# PreviousDate(date=datetime.date(2025, 1, 15),
# previous_date=datetime.date(2025, 1, 13))
# Utiliser date.today() directement
nxt_today = api.bdays.next_date(date.today())
Comptage entre deux dates
Retourne le nombre de jours ouvrables dans un intervalle [start, end] inclusif. Utile pour les calculs de délais contractuels, de périodes de préavis, ou pour valider la complétude d'une série chronologique.
count = api.bdays.count(
to_date('02/01/2025'),
to_date('31/12/2025')
)
print(count)
# DaysCount(start=2025-01-02, end=2025-12-31, count=247)
# Vérifier l'intégrité d'une série chronologique
import polars as pl
df = pl.read_parquet('data_2025.parquet')
expected = api.bdays.count(df['market_date'].min(), df['market_date'].max()).count
actual = df['date_marche'].unique().len()
if actual != expected:
raise ValueError(f"Série incomplète : {actual} observations, attendu {expected}")
Listing mensuel / annuel
Retourne un DateSeries contenant l'ensemble des jours ouvrables d'un mois ou d'une année entière, sous forme de SortedSet[date].
# Mois de juin 2025
june = api.bdays.bdays_of(2025, month=6)
print(june)
# DateSeries(nitems=19, serie=SortedSet([date(2025,6,2), ...]))
# Année complète 2025
full_year = api.bdays.bdays_of(2025)
print(full_year.nitems)
# 247
# Conversion en liste Python
bdays_list = list(full_year.serie)
# Alimenter un calendrier Polars
import polars as pl
cal_df = pl.DataFrame({'date': bdays_list})
CalSpan — bornes ouvrables de périodes
CalSpan est le concept le plus distinctif de l'API. Il retourne les bornes ouvrables d'une période — mois, trimestre, semestre ou année — sous la forme [start, end] où start est le dernier jour ouvrable de la période précédente et end est le dernier jour ouvrable de la période demandée.
Cette convention est très utilisée dans la finance de marché pour le calcul d'une performance sur une période. Pour un instrument financier, la borne d'ouverture est le cours du dernier jour ouvrable du trimestre précédent, et la borne de fermeture est le cours du dernier jour ouvrable du trimestre en cours. Les règles d'inclusion ou d'exclusion sont laissées à la discretion de l'utilisateur.
Exemple: Année 2025 — CalSpan par trimestre Q1 Q2 Q3 Q4 |———————————————————————|———————————————————————|———————————————————————| start = dernier j.ouv. période N-1 | end = dernier j.ouv. période N Les spans s'enchaînent sans trou ni chevauchement → BETWEEN :start AND :end
# Année complète
y2025 = api.bdays.span(2025)
# CalSpan(start_date=2024-12-31, end_date=2025-12-31, year=2025, ...)
# Par trimestre (quarter= 1..4)
q1 = api.bdays.span(2025, quarter=1)
# CalSpan(start_date=2024-12-31, end_date=2025-03-28, year=2025, quarter=1, ...)
q2 = api.bdays.span(2025, quarter=2)
# CalSpan(start_date=2025-03-28, end_date=2025-06-30, year=2025, quarter=2, ...)
# Par semestre (semester= 1..2)
s1 = api.bdays.span(2025, semester=1)
# CalSpan(start_date=2024-12-31, end_date=2025-06-30, year=2025, semester=1, ...)
# Par mois (month= 1..12)
jul = api.bdays.span(2025, month=7)
# CalSpan(start_date=2025-06-30, end_date=2025-07-31, year=2025, month=7, ...)
Usage SQL avec CalSpan
Les bornes CalSpan sont directement utilisables dans une clause SQL BETWEEN pour les séries ou par égalité pour les points aux bornes. Les spans étant conçus pour s'enchaîner sans trou ni chevauchement, il n'y a pas de risque de double comptage entre deux périodes.
from sqlalchemy import text
from pycalendar_api import CalendarApi
api = CalendarApi.from_env()
span = api.bdays.span(2025, quarter=2)
# CalSpan(start_date=2025-03-28, end_date=2025-06-30)
# Requête de performance trimestrielle de l'indice du MASI.20
query = text("""
SELECT
s.index_key,
s.index_value AS start_value,
e.index_value AS end_value,
((e.index_value /NULLIF(s.index_value, 0) - 1) * 100 AS perf_q2
FROM
index_table s
LEFT JOIN
index_table e ON s.index_id = e.index_id AND
e.market_date = :end_date
WHERE
s.market_date = :start_date AND
s.index_key = :index_key
""")
with engine.connect() as conn:
result = conn.execute(query, {
'start_date': span.start_date,
'end_date': span.end_date,
'index_key': 'MASI.20'
})
Enchaînement de spans
Pour générer tous les spans d'une année en une seule passe — utile pour construire un reporting glissant ou initialiser une table de calendrier.
# 12 spans mensuels — enchaînés sans trou
monthly_spans = [
api.bdays.span(2025, month=m)
for m in range(1, 13)
]
# Vérification : end[n] == start[n+1]
for i in range(len(monthly_spans) - 1):
assert monthly_spans[i].end == monthly_spans[i + 1].start
# Construire une table de référence Polars
import polars as pl
spans_df = pl.DataFrame([
{'month': s.month, 'start': s.start_date, 'end': s.end_date} for s in monthly_spans])
Intégration avec les orchestrateurs
Un cas d'usage fréquent : conditionner l'exécution d'un DAG ou d'un job au fait que la date courante soit un jour ouvrable. Sans Calendar API, cela repose sur une liste hardcodée de jours fériés — source de bugs silencieux.
Airflow
from airflow.sdk import dag, task
from airflow.exceptions import AirflowSkipException
from pycalendar_api import CalendarApi
from datetime import date, timedelta
api = CalendarApi.from_env()
def is_business_day(d: date) -> bool:
r = api.holidays.is_holiday(d)
return not r.is_holiday and d.weekday() < 5
@dag(schedule="0 18 * * 1-5", catchup=False)
def daily_index_pipeline():
@task.short_circuit
def check_business_day(**ctx):
run_date = ctx['data_interval_end'].date()
if not is_business_day(run_date):
raise AirflowSkipException(f"{run_date} is not a business day")
return True
@task
def collect_market_data(**ctx):
run_date = ctx['data_interval_end'].date()
span = api.bdays.span(run_date.year, month=run_date.month)
# span.start_date / span.end_date → bornes pour les requêtes SQL
...
check_business_day() >> collect_market_data()
Dagster
from dagster import sensor, RunRequest, SkipReason, define_asset_job
from pycalendar_api import CalendarApi
from datetime import date
api = CalendarApi.from_env()
collect_job = define_asset_job("collect_job", selection="*")
@sensor(job=collect_job, minimum_interval_seconds=3600)
def business_day_sensor(context):
today = date.today()
r = api.holidays.is_holiday(today)
if r.is_holiday or today.weekday() < 5:
yield RunRequest(run_key=str(today))
else:
yield SkipReason(f"{today} n'est pas un jour ouvrable")
Modèles de réponse
@dataclass
class NextDate:
date: date #La date passée en paramètre
next_date: date # Le prochain jour ouvrable
@dataclass
class PreviousDate:
date: date # La date passée en paramètre
previous_date: date # Le dernier jour ouvrable. (La veille)
@dataclass
class DaysCount:
start_date: date
end_date: date
count: int
@dataclass
class DateSeries:
nitems: int # nombre de jours ouvrables
min_date: date # Date min de la série
max_date: date # Date max de la série
serie: SortedSet[date] # ensemble ordonné des dates
@dataclass
class CalSpan:
start_date: date # dernier j.ouv. de la période précédente
end_date: date # dernier j.ouv. de la période demandée
year: int # L'année de la période
semester: int # Le semestre de la période. Si semestre
quarter: int # Le trimestre de la période. Si Trimestre
month: int # Le mois de la période. Si Mois