diff --git a/.github/workflows/versement.yml b/.github/workflows/versement.yml index e40edb8..d9bd91d 100644 --- a/.github/workflows/versement.yml +++ b/.github/workflows/versement.yml @@ -17,6 +17,12 @@ name: verser les séances en attente # quotidien de `ingestion.yml` — à la différence près que celui-ci rattrape # un retard de plusieurs jours d'un coup. on: + # 16 h 45 UTC, du lundi au vendredi : trois quarts d'heure après + # `ingestion.yml`, de quoi laisser son artefact se publier. Le versement + # cesse ainsi d'attendre qu'on y pense — c'était le seul maillon manuel + # de la chaîne, et il a laissé l'archive prendre cinq séances de retard. + schedule: + - cron: "45 16 * * 1-5" workflow_dispatch: inputs: executions: @@ -24,15 +30,37 @@ on: required: false default: "20" -# `actions: read` en plus : lister les exécutions et télécharger leurs -# artefacts. Toujours aucune écriture — c'est la session qui versera. +# `contents: write` DÉSORMAIS, ET CE N'EST PAS UN RENONCEMENT. Le projet +# refusait qu'un runner commite parce qu'il ne sait pas signer, et qu'un +# historique à moitié vérifiable ne vaut guère mieux qu'un historique nu. +# L'objection portait sur la SIGNATURE, pas sur l'écriture : une clé propre +# au robot, déposée en secret de dépôt, la lève. Chaque versement reste +# « Verified », et l'auteur dit qui a agi — « versement automatique » et non +# un humain. +# +# Ce que la contrepartie coûte, dit franchement : une signature ne prouve +# plus qu'une personne tenait sa clé, seulement que le commit vient de ce +# dépôt-ci. La distinction reste lisible dans le journal, elle ne disparaît +# pas — mais elle change de nature, et c'est un choix, pas un détail. +# +# SANS LE SECRET, RIEN N'EST POUSSÉ. Le workflow retombe alors sur son +# ancien comportement : émettre l'incrément dans le journal, à verser à la +# main. Une clé absente ou révoquée fait perdre l'automatisme, jamais la +# donnée. permissions: - contents: read + contents: write actions: read jobs: verser: runs-on: ubuntu-latest + # `secrets` N'EXISTE PAS DANS UN `if` D'ÉTAPE — GitHub refuse le + # fichier entier, pas seulement l'étape, et le workflow ne démarre + # jamais. La présence de la clé se lit donc ici, où le contexte est + # autorisé, et voyage ensuite comme une variable ordinaire. Le booléen + # arrive en chaîne : c'est « true » qu'on compare, pas true. + env: + CLE_PRESENTE: ${{ secrets.CLE_VERSEMENT != '' }} steps: - uses: actions/checkout@v4 @@ -95,16 +123,30 @@ jobs: connues = {cle(l): l for l in (x.rstrip("\n") for x in f) if l.strip()} derniere = max((d for d, _ in connues), default="") + # LES DATES PRÉSENTES, ET PAS SEULEMENT LA DERNIÈRE : c'est ce qui + # sépare un TROU d'une ligne délibérément retirée. Voir plus bas. + datees = {d for d, _ in connues} print(f"archive versée : {len(connues)} lignes, " f"dernière séance {derniere or '—'}") combien = int(os.environ.get("EXECUTIONS") or 20) - runs = json.loads(lire( - f"{RACINE}/actions/workflows/ingestion.yml/runs" - f"?status=success&per_page={combien}"))["workflow_runs"] + + # DEUX SOURCES, ET L'ORDRE COMPTE. `ingestion` apporte la séance + # du jour ; `rapatriement` sert à reprendre une séance manquée — + # le 6 août 2026 l'a été, l'ingestion ayant été annulée à + # mi-course. Les deux déposent le même artefact `archive-`. + # + # L'ingestion passe en premier parce que c'est d'elle, et d'elle + # seule, qu'on retient le référentiel : le rapatriement lit un + # historique par valeur et ne rafraîchit pas la liste des cotées. + runs = [] + for atelier in ("ingestion.yml", "rapatriement.yml"): + runs += [(atelier, r) for r in json.loads(lire( + f"{RACINE}/actions/workflows/{atelier}/runs" + f"?status=success&per_page={combien}"))["workflow_runs"]] nouvelles: dict[tuple[str, str], str] = {} - for run in runs: + for atelier, run in runs: arts = json.loads(lire( f"{RACINE}/actions/runs/{run['id']}/artifacts"))["artifacts"] for art in arts: @@ -126,9 +168,16 @@ jobs: # dirait pas ce qui en a disparu. Les exécutions # arrivent de la plus récente à la plus ancienne : # le premier référentiel rencontré est le bon. + # + # DE L'INGESTION SEULEMENT. Le rapatriement lit un + # historique valeur par valeur et ne rafraîchit pas la + # liste des cotées : son référentiel est celui du jour + # où on l'a lancé, et le retenir ferait reculer + # `derniere_vue` pour tout le monde. refs = [n for n in z.namelist() if n.endswith("referentiel.csv")] - if refs and not os.path.exists("referentiel.csv"): + if (refs and atelier == "ingestion.yml" + and not os.path.exists("referentiel.csv")): with open("referentiel.csv", "w", encoding="utf-8") as f: f.write(z.read(refs[0]).decode("utf-8")) @@ -136,31 +185,49 @@ jobs: f"({run['created_at'][:10]})") absentes = {cle(l): l for l in texte.splitlines()[1:] if l.strip() and cle(l) not in connues} - # SEULES LES SÉANCES POSTÉRIEURES À L'ARCHIVE SONT VERSÉES, - # ET CE GARDE-FOU A SERVI DÈS LE PREMIER PASSAGE. Un - # artefact plus ancien qu'une correction porte l'archive - # d'AVANT : celui du 28 juillet contenait encore les 38 - # séances fantômes retirées le 29, et le versement les + # DEUX FAÇONS D'ÊTRE ABSENTE, ET UNE SEULE SE VERSE. + # + # Le garde-fou d'origine ne retenait que les séances + # POSTÉRIEURES à l'archive, et il a servi dès le premier + # passage : l'artefact du 28 juillet portait encore les + # 38 lignes fantômes retirées le 29, et les verser les # aurait ressuscitées sans que rien ne le dise — la panne # exacte contre laquelle `db.effacer_cours` a été écrit. # - # Une ligne absente et antérieure à la dernière séance - # versée n'est donc jamais un retard : c'est une donnée - # que le dépôt a délibérément écartée, ou qu'un - # rapatriement doit reprendre en entier. Elle est - # signalée, jamais réémise. - ajout = {(d, t): l for (d, t), l in absentes.items() - if d > derniere} - ecartees = len(absentes) - len(ajout) + # Mais il refusait du même geste toute reprise de TROU : + # une séance manquée est antérieure par définition. Le + # 6 août 2026 était donc irrattrapable par cette voie. + # + # Ce qui sépare les deux cas se lit dans l'archive + # elle-même. Une ligne retirée l'a été d'une date qui + # existe toujours — le 09/02/2015 y garde 22 lignes, le + # 25/12/2017 en garde 30. Un trou, lui, est une date + # ENTIÈREMENT absente. La règle tient en une phrase : on + # verse ce qui est postérieur, et ce qui comble une date + # dont l'archive ne sait rien. Jamais une ligne qu'elle a + # écartée d'une date qu'elle connaît. + ajout, ecartees = {}, 0 + combles = set() + for (d, t), l in absentes.items(): + if d > derniere: + ajout[(d, t)] = l + elif d not in datees: + ajout[(d, t)] = l + combles.add(d) + else: + ecartees += 1 dates = sorted({d for d, _ in ajout}) print(f" {art['name']} ({run['created_at'][:10]}) : " f"{len(ajout)} lignes neuves {dates or ''}") + if combles: + print(f" dont {len(combles)} séance(s) comblée(s) " + f"dans l'archive : {', '.join(sorted(combles))}") if ecartees: anciennes = sorted({d for d, _ in absentes - if d <= derniere}) - print(f" {ecartees} lignes ÉCARTÉES car pas " - f"postérieures à {derniere} — archive d'avant " - f"une correction : {anciennes[0]}…" + if d <= derniere and d in datees}) + print(f" {ecartees} lignes ÉCARTÉES : antérieures " + f"ET sur des séances déjà présentes, donc " + f"retirées à dessein — {anciennes[0]}…" f"{anciennes[-1]}") nouvelles.update(ajout) @@ -180,18 +247,254 @@ jobs: f"{', '.join(dates)}") PY + # LES MÊMES CONTRÔLES QU'À LA MAIN, PARCE QUE PERSONNE NE REGARDERA. + # Un versement manuel passe sous les yeux de quelqu'un ; celui-ci + # n'aura pour témoin que ce journal. Tout ce qu'une session vérifiait + # avant d'écrire est donc vérifié ici, et un seul contrôle qui tombe + # annule le versement — l'incrément part alors dans le journal, à + # verser à la main, comme avant. + # + # Bibliothèque standard uniquement : ce job n'installe rien, et lui + # faire installer pandas pour diviser deux nombres coûterait une + # minute par séance et une dépendance de plus à la chaîne. + - name: Appliquer l'incrément et le contrôler + id: appliquer + run: | + python - <<'PY' + import os, shutil + + + def cle(ligne: str) -> tuple[str, str]: + date, ticker = ligne.split(",", 2)[:2] + return date, ticker + + + def sortie(**valeurs): + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + for nom, valeur in valeurs.items(): + f.write(f"{nom}={valeur}\n") + + + if not os.path.exists("increment.csv"): + print("rien à appliquer") + sortie(applique="false", motif="rien") + raise SystemExit(0) + + with open("data/cours.csv", encoding="utf-8") as f: + entete = f.readline().rstrip("\n") + archive = [l.rstrip("\n") for l in f if l.strip()] + with open("increment.csv", encoding="utf-8") as f: + tete = f.readline().rstrip("\n") + incr = [l.rstrip("\n") for l in f if l.strip()] + + refus = [] + if tete != entete: + refus.append(f"en-têtes différents : {tete!r} vs {entete!r}") + + connues = {cle(l) for l in archive} + deja = [l for l in incr if cle(l) in connues] + if deja: + refus.append(f"{len(deja)} lignes déjà présentes dans l'archive") + + # POSTÉRIEURE, OU COMBLANT UNE DATE INCONNUE. Une ligne antérieure + # sur une séance que l'archive connaît déjà en a été retirée à + # dessein — la ressusciter est la panne contre laquelle + # `db.effacer_cours` a été écrit. Une date dont l'archive ne sait + # rien est un trou, et se comble. + derniere = max((d for d, _ in connues), default="") + datees = {d for d, _ in connues} + intruses = [l for l in incr + if cle(l)[0] <= derniere and cle(l)[0] in datees] + if intruses: + refus.append(f"{len(intruses)} lignes antérieures sur des " + f"séances déjà présentes (retirées à dessein)") + combles = sorted({cle(l)[0] for l in incr + if cle(l)[0] <= derniere and cle(l)[0] not in datees}) + if combles: + print(f"séances comblées : {', '.join(combles)}") + + # UN COURS NUL OU NÉGATIF N'EST JAMAIS LÉGITIME. Contrairement à + # la limite de séance, celui-ci ne connaît pas d'exception : il + # signale une lecture cassée, jamais un marché. + absurdes = [] + derniers = {} + for ligne in archive: + champs = ligne.split(",") + if champs[5]: + derniers[champs[1]] = float(champs[5]) + hors = [] + for ligne in sorted(incr, key=cle): + champs = ligne.split(",") + date, ticker, cloture = champs[0], champs[1], champs[5] + if not cloture: + continue + valeur = float(cloture) + if valeur <= 0: + absurdes.append(f"{date} {ticker} {valeur}") + veille = derniers.get(ticker) + if veille: + ecart = valeur / veille - 1 + if abs(ecart) > 0.0751: + hors.append(f"{date} {ticker} {ecart:+.2%}") + derniers[ticker] = valeur + if absurdes: + refus.append(f"{len(absurdes)} clôtures nulles ou négatives : " + + ", ".join(absurdes[:5])) + + # LA LIMITE DE ±7,5 % EST DÉCRITE, JAMAIS OPPOSÉE, et ce n'est pas + # de la mollesse — c'est ce que le projet a MESURÉ. Sur l'archive, + # ce contrôle lève 356 alertes dont l'immense majorité est + # légitime : au détachement, le prix de référence est ajusté du + # dividende et une baisse de 9 à 13 % y est régulière ; la limite + # relie deux séances CONSÉCUTIVES, pas deux blocs séparés d'un + # mois ; une division du nominal la franchit par construction. + # + # Refuser là-dessus aurait bloqué le versement en pleine saison + # des détachements — mai à août — c'est-à-dire maintenant. Un + # contrôle qui crie sur des données correctes n'est pas un + # contrôle : c'est un bruit qu'on apprend à ignorer, et le jour où + # il a raison personne ne l'écoute. Voir `qualite.limites`, qui + # range ces dépassements par cause probable, et `pics_isoles`, + # qui cherche la seule signature sans ambiguïté — inapplicable + # ici, elle demande la séance SUIVANTE. + if hors: + print(f"note : {len(hors)} variations franchissent ±7,5 % " + f"({', '.join(hors[:5])}) — décrites, pas opposées. " + "« brvm qualite » les range par cause probable.") + + if refus: + print("VERSEMENT REFUSÉ :") + for motif in refus: + print(f" - {motif}") + print("\nL'incrément part dans le journal ; à verser à la main " + "après examen.") + sortie(applique="false", motif="controle") + raise SystemExit(0) + + # LA GARANTIE N'EST PLUS « ÇA S'AJOUTE EN QUEUE » : combler un trou + # insère au milieu. Ce qui doit rester vrai, et qui est vérifié + # ici, c'est que la fusion est triée, qu'elle ne perd aucune ligne + # de l'archive, et qu'elle en compte exactement autant qu'avant + # plus l'incrément — donc qu'elle n'en invente ni n'en écrase. + fusion = sorted(archive + incr, key=cle) + assert fusion == sorted(fusion, key=cle), "fusion non triée" + assert set(archive).issubset(set(fusion)), "l'archive a perdu une ligne" + assert len(fusion) == len(archive) + len(incr), \ + f"{len(fusion)} lignes au lieu de {len(archive) + len(incr)}" + with open("data/cours.csv", "w", encoding="utf-8") as f: + f.write(entete + "\n" + "\n".join(fusion) + "\n") + + # Le référentiel est une table RÉÉCRITE, pas un incrément : celui + # de l'artefact le plus récent porte déjà la fusion faite par + # `ingestion`, donc il remplace, il ne se complète pas. + # + # COPIÉ ET NON DÉPLACÉ : si le versement échoue plus loin, le + # journal doit encore trouver `referentiel.csv` à la racine pour + # l'émettre. Le déplacer privait le filet de la moitié de ce + # qu'il doit rattraper. + if os.path.exists("referentiel.csv"): + shutil.copy("referentiel.csv", "data/referentiel.csv") + print("référentiel remplacé par le plus récent") + + dates = sorted({d for d, _ in (cle(l) for l in incr)}) + print(f"appliqué : +{len(incr)} lignes, séances {', '.join(dates)}") + sortie(applique="true", lignes=len(incr), + seances=len(dates), dates=" ".join(dates)) + PY + + # LA CLÉ EST CELLE DU ROBOT, ET ELLE NE SERT QU'ICI. Elle est déposée + # en secret de dépôt et enregistrée comme clé de SIGNATURE sur le + # compte — pas comme clé de déploiement, qui ne signerait rien. + # L'auteur porte un nom qui dit ce qu'il est ; l'adresse est celle, + # vérifiée, du compte, sans quoi GitHub refuserait la signature. + # + # `if` sur le secret : sans lui l'étape est SAUTÉE, pas en échec, et + # le journal reprend son rôle plus bas. + - name: Signer et verser + id: verser + if: ${{ steps.appliquer.outputs.applique == 'true' + && env.CLE_PRESENTE == 'true' }} + env: + CLE: ${{ secrets.CLE_VERSEMENT }} + COURRIEL: ${{ vars.COURRIEL_VERSEMENT }} + DATES: ${{ steps.appliquer.outputs.dates }} + LIGNES: ${{ steps.appliquer.outputs.lignes }} + SEANCES: ${{ steps.appliquer.outputs.seances }} + run: | + set -euo pipefail + + # Une adresse absente donnerait un commit sans auteur valide, que + # GitHub refuserait de marquer « Verified » — l'automatisme + # tournerait en produisant exactement ce qu'on voulait éviter. + # Mieux vaut s'arrêter ici : le journal prendra le relais. + if [ -z "${COURRIEL}" ]; then + echo "::error::La variable de dépôt COURRIEL_VERSEMENT est vide." + echo "Renseignez une adresse VÉRIFIÉE du compte, sans quoi la" + echo "signature ne sera pas reconnue. Voir README." + exit 1 + fi + + install -m 700 -d ~/.ssh + printf '%s\n' "$CLE" > ~/.ssh/versement + chmod 600 ~/.ssh/versement + ssh-keygen -y -f ~/.ssh/versement > ~/.ssh/versement.pub + + git config user.name "versement automatique" + git config user.email "$COURRIEL" + git config gpg.format ssh + git config user.signingkey ~/.ssh/versement.pub + git config commit.gpgsign true + + # Le message passe par un fichier : l'écrire en ligne de commande + # exposerait son contenu à l'interprétation du shell, et une + # apostrophe ou un accent grave dans un nom de société suffirait + # à casser le versement — ou pire, à exécuter quelque chose. + { + echo "Verser ${SEANCES} séance(s) : ${DATES}" + echo + echo "Versé par l'action, artefacts d'ingestion relus et" + echo "contrôlés : en-têtes, doublons, antériorité, tri, et la" + echo "limite de ±7,5 % par séance. ${LIGNES} lignes ajoutées." + echo + echo "Commit signé par la clé du robot — l'auteur dit qui a agi." + } > message.txt + + git add data/cours.csv data/referentiel.csv + git commit -F message.txt + + # LE DÉPÔT PEUT AVOIR BOUGÉ pendant que les artefacts se + # téléchargeaient. Rejouer le versement au-dessus de `main` plutôt + # que d'échouer : le commit est resigné au passage, la clé est là. + for essai in 1 2 3; do + git pull --rebase --autostash origin "${GITHUB_REF_NAME}" \ + && git push && { echo "poussé"; exit 0; } + echo "poussée refusée, nouvel essai ($essai)" + sleep $((essai * 5)) + done + echo "trois tentatives refusées — reprise à la main" + exit 1 + # COMPRESSÉ SUR UNE LIGNE, ET CE N'EST PAS DE LA COQUETTERIE. En # clair, quatre séances font près de deux cents lignes de journal : # les relire demande de deviner combien de lignes remonter, et une # ligne manquée passe pour une donnée absente plutôt que pour une # lecture tronquée. L'empreinte tranche — le versement se vérifie # au lieu de se supposer. + # + # DEVENU LE FILET, ET NON PLUS LA VOIE NORMALE. Il ne s'écrit que si + # rien n'a été poussé : contrôle en échec, secret absent, clé + # révoquée. La donnée reste alors récupérable à la main — c'est + # exactement ce qui a servi à rattraper les quatre séances du 3 au + # 7 août, quand le stockage d'artefacts était injoignable. - name: Écrire l'incrément dans le journal + if: ${{ always() && steps.verser.outcome != 'success' }} run: | if [ ! -s increment.csv ]; then echo "rien à écrire" exit 0 fi + echo "Aucun versement automatique : reprise à la main depuis ce" + echo "journal. Voir README, « Verser une séance à la main »." for f in increment referentiel; do [ -s "$f.csv" ] || continue echo "--- $f sha256 $(sha256sum "$f.csv" | cut -c1-16) \ diff --git a/README.md b/README.md index 4acd2f4..0b9349f 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ tests/ 237 tests, tous hors ligne .github/workflows/ ingestion.yml collecte quotidienne, 16 h UTC en semaine rapatriement.yml rattrapage d'historique - versement.yml réémission des séances en retard + versement.yml verse les séances collectées, 16 h 45 UTC veille.yml alerte si l'archive cesse d'avancer tests.yml les tests à chaque modification ``` @@ -419,25 +419,96 @@ où elle cotait. Le passé devenait celui des seuls survivants — ce qui fabrique mécaniquement des performances passées trop belles, puisqu'on oublie ceux qui ont échoué. -### Les actions automatiques ne committent pas, elles déposent +### Qui collecte, qui verse, et qui signe + +`ingestion.yml` et `rapatriement.yml` sont en **lecture seule**. Ils +publient un artefact `archive-` contenant `data/` et n'écrivent +rien : ce sont des collecteurs. + +`versement.yml` écrit. Il tourne chaque jour ouvré à 16 h 45 UTC, relit +les artefacts **des ingestions et des rapatriements** récents, contrôle ce +qu'il y trouve, et verse dans `data/` — **en signant**. + +**Deux façons d'être absente, une seule se verse.** Une séance postérieure +à l'archive est un retard, elle se verse. Une ligne antérieure, sur une +date que l'archive connaît déjà, en a été retirée à dessein : la +ressusciter est la panne contre laquelle `db.effacer_cours` a été écrit, +et le versement la refuse. Entre les deux il y a le **trou** — une date +entièrement absente, comme le 6 août 2026 dont l'ingestion fut annulée à +mi-course. Celui-là se comble, et c'est à quoi sert `rapatriement.yml` : +`brvm rapatrier --debut … --fin …` relit la période, dépose son artefact, +et le versement suivant le reprend. `brvm veille` liste les trous. + +**Pourquoi une clé pour un robot.** Le versement était d'abord manuel, +au motif qu'un runner ne sait pas signer et qu'un historique à moitié +vérifiable ne vaut guère mieux qu'un historique nu. L'objection portait +sur la signature, pas sur l'écriture : une clé propre au robot la lève. +Chaque versement reste « Verified », et l'auteur — `versement +automatique` — dit qui a agi. + +Ce que la contrepartie coûte, dit franchement : une signature ne prouve +plus qu'une personne tenait sa clé, seulement que le commit vient de ce +dépôt-ci. La distinction reste lisible dans le journal, mais elle change +de nature. + +**Ce que le robot vérifie avant d'écrire**, parce que personne ne +regardera : en-têtes identiques, aucune ligne déjà présente, aucune +séance antérieure à l'archive — ce garde-fou a déjà empêché de +ressusciter 38 séances fantômes — tri préservé, et aucune clôture nulle +ou négative. Un seul contrôle qui tombe annule le versement. + +**Ce qu'il ne refuse pas : la limite de ±7,5 %.** La tentation est d'en +faire un invariant dur ; mesurée sur l'archive, elle lève 356 alertes dont +l'immense majorité est légitime — au détachement le prix de référence est +ajusté du dividende, la limite relie deux séances *consécutives* et non +deux blocs séparés d'un mois, et une division du nominal la franchit par +construction. Bloquer là-dessus arrêterait le versement toute la saison +des détachements, de mai à août. Les dépassements sont donc notés dans le +journal ; `brvm qualite` les range par cause probable. + +**Quand il ne verse pas**, il retombe sur son ancien comportement : +émettre l'incrément dans son journal, compressé et empreinté, à reprendre +à la main. Une clé absente, un contrôle en échec ou une poussée refusée +font perdre l'automatisme, jamais la donnée. + +#### Installer la clé du robot + +Une fois, et le versement tourne seul ensuite : -Un runner GitHub n'a pas de clé de signature : tout commit qu'il -produirait resterait non signé, et l'historique du dépôt cesserait d'être -intégralement vérifiable pour la commodité d'un robot. +```bash +ssh-keygen -t ed25519 -C "versement automatique brvm" -f cle_versement -N "" +``` + +1. **Clé publique** (`cle_versement.pub`) → *Settings → SSH and GPG keys → + New SSH key*, type **Signing Key**. Pas « Authentication » : une clé + d'authentification ne signe rien. +2. **Clé privée** (`cle_versement`) → *Settings → Secrets and variables → + Actions → New repository secret*, nom `CLE_VERSEMENT`. +3. **Variable** `COURRIEL_VERSEMENT` (même écran, onglet *Variables*) : + une adresse **vérifiée** du compte. L'adresse `@users.noreply.github.com` + convient. Sans elle, le workflow s'arrête plutôt que de produire un + commit non vérifié. +4. Supprimez `cle_versement` de votre disque. + +#### Verser une séance à la main -`ingestion.yml` et `rapatriement.yml` sont donc en **lecture seule**. Ils -publient un artefact `archive-` contenant `data/`, et le versement -se fait à la main : +Si le robot s'est arrêté, l'incrément est dans le journal de +`versement.yml`, avec son empreinte : + +```bash +# depuis le journal de l'action : la ligne « --- increment base64 gzip --- » +base64 -d increment.b64 | gunzip > increment.csv +sha256sum increment.csv # doit correspondre à l'empreinte annoncée +``` -1. onglet **Actions** → l'exécution → télécharger l'artefact `archive-…` ; -2. décompresser dans `data/` ; -3. `python -m brvm importer`, puis `git add data/ && git commit`. +puis fusionner dans `data/cours.csv` en respectant le tri, et committer. +L'autre voie reste ouverte : télécharger l'artefact `archive-…`, +décompresser dans `data/`, `python -m brvm importer`. -Le prix de ce choix est réel et assumé : **l'archive n'avance pas toute -seule.** C'est précisément ce que `veille.yml` surveille — elle ouvre une -issue GitHub quand la donnée cesse de progresser pendant cinq jours -ouvrés, et son message distingue les deux causes possibles : des -artefacts en attente, ou une collecte cassée. +`veille.yml` surveille l'ensemble : elle ouvre une issue GitHub quand la +donnée cesse de progresser pendant cinq jours ouvrés, et son message +distingue les deux causes possibles — un versement en panne, ou une +collecte cassée. `veille.yml` lance aussi `brvm verifier` **sur la page vivante**, une fois par semaine. La distinction fait tout le dispositif : `tests.yml` lance le diff --git a/data/cours.csv b/data/cours.csv index 4af9066..e81b214 100644 --- a/data/cours.csv +++ b/data/cours.csv @@ -112912,3 +112912,238 @@ date,ticker,ouverture,haut,bas,cloture,volume_titres,volume_fcfa 2026-07-31,TTLS,3650.0,,,3655.0,1779.0,6502245.0 2026-07-31,UNLC,54000.0,,,51000.0,4.0,204000.0 2026-07-31,UNXC,1895.0,,,1945.0,10059.0,19564755.0 +2026-08-03,ABJC,3000.0,,,2990.0,2842.0,8497580.0 +2026-08-03,BICB,7600.0,,,7690.0,22443.0,172586670.0 +2026-08-03,BICC,28675.0,,,29015.0,57.0,1653855.0 +2026-08-03,BNBC,1900.0,,,1930.0,2336.0,4508480.0 +2026-08-03,BOAB,8700.0,,,8695.0,8777.0,76316015.0 +2026-08-03,BOABF,7105.0,,,7200.0,3920.0,28224000.0 +2026-08-03,BOAC,10815.0,,,11350.0,5608.0,63650800.0 +2026-08-03,BOAM,5670.0,,,5625.0,3155.0,17746875.0 +2026-08-03,BOAN,5290.0,,,5200.0,1036.0,5387200.0 +2026-08-03,BOAS,7790.0,,,7685.0,3389.0,26044465.0 +2026-08-03,CABC,3500.0,,,3645.0,6890.0,25114050.0 +2026-08-03,CBIBF,28000.0,,,28490.0,1113.0,31709370.0 +2026-08-03,CFAC,1695.0,,,1695.0,2656.0,4501920.0 +2026-08-03,CIEC,5000.0,,,5000.0,6428.0,32140000.0 +2026-08-03,ECOC,16200.0,,,16100.0,1507.0,24262700.0 +2026-08-03,ETIT,70.0,,,69.0,836211.0,57698559.0 +2026-08-03,FTSC,1970.0,,,1910.0,3008.0,5745280.0 +2026-08-03,LNBB,4835.0,,,4560.0,9055.0,41290800.0 +2026-08-03,NEIC,2280.0,,,2280.0,643.0,1466040.0 +2026-08-03,NSBC,24500.0,,,24020.0,688.0,16525760.0 +2026-08-03,NTLC,16500.0,,,16550.0,1778.0,29425900.0 +2026-08-03,ONTBF,2995.0,,,2990.0,2793.0,8351070.0 +2026-08-03,ORAC,17000.0,,,17000.0,1175.0,19975000.0 +2026-08-03,ORGT,3200.0,,,3130.0,5561.0,17405930.0 +2026-08-03,PALC,9000.0,,,8990.0,831.0,7470690.0 +2026-08-03,PRSC,4515.0,,,4400.0,458.0,2015200.0 +2026-08-03,SAFC,5100.0,,,5000.0,18854.0,94270000.0 +2026-08-03,SCRC,3645.0,,,3700.0,4780.0,17686000.0 +2026-08-03,SDCC,11900.0,,,11900.0,669.0,7961100.0 +2026-08-03,SDSC,2700.0,,,2750.0,5315.0,14616250.0 +2026-08-03,SEMC,1485.0,,,1480.0,2293.0,3393640.0 +2026-08-03,SGBC,38015.0,,,37995.0,5427.0,206198865.0 +2026-08-03,SHEC,2240.0,,,2325.0,5795.0,13473375.0 +2026-08-03,SIBC,8800.0,,,8800.0,4617.0,40629600.0 +2026-08-03,SICC,6400.0,,,6600.0,19.0,125400.0 +2026-08-03,SIVC,2200.0,,,2200.0,3414.0,7510800.0 +2026-08-03,SLBC,37760.0,,,37700.0,935.0,35249500.0 +2026-08-03,SMBC,15500.0,,,15500.0,1972.0,30566000.0 +2026-08-03,SNTS,31000.0,,,31000.0,10204.0,316324000.0 +2026-08-03,SOGC,8400.0,,,8400.0,2652.0,22276800.0 +2026-08-03,SPHC,7595.0,,,7500.0,4030.0,30225000.0 +2026-08-03,STAC,2750.0,,,2820.0,4652.0,13118640.0 +2026-08-03,STBC,23205.0,,,23985.0,2331.0,55909035.0 +2026-08-03,TTLC,2975.0,,,2990.0,7265.0,21722350.0 +2026-08-03,TTLS,3625.0,,,3655.0,2070.0,7565850.0 +2026-08-03,UNLC,51035.0,,,51020.0,37.0,1887740.0 +2026-08-03,UNXC,1950.0,,,1900.0,6968.0,13239200.0 +2026-08-04,ABJC,2990.0,,,3000.0,2257.0,6771000.0 +2026-08-04,BICB,7750.0,,,7690.0,9381.0,72139890.0 +2026-08-04,BICC,29005.0,,,29000.0,919.0,26651000.0 +2026-08-04,BNBC,1930.0,,,1930.0,2393.0,4618490.0 +2026-08-04,BOAB,8700.0,,,8720.0,4589.0,40016080.0 +2026-08-04,BOABF,7200.0,,,7200.0,3170.0,22824000.0 +2026-08-04,BOAC,11350.0,,,11600.0,8263.0,95850800.0 +2026-08-04,BOAM,5625.0,,,5590.0,2883.0,16115970.0 +2026-08-04,BOAN,5200.0,,,5100.0,2490.0,12699000.0 +2026-08-04,BOAS,7685.0,,,7700.0,6650.0,51205000.0 +2026-08-04,CABC,3645.0,,,3475.0,2874.0,9987150.0 +2026-08-04,CBIBF,28000.0,,,28300.0,495.0,14008500.0 +2026-08-04,CFAC,1695.0,,,1690.0,3139.0,5304910.0 +2026-08-04,CIEC,5000.0,,,4995.0,5299.0,26468505.0 +2026-08-04,ECOC,16200.0,,,16200.0,6408.0,103809600.0 +2026-08-04,ETIT,69.0,,,64.0,2726990.0,174527360.0 +2026-08-04,FTSC,1920.0,,,1970.0,3316.0,6532520.0 +2026-08-04,LNBB,4560.0,,,4400.0,4270.0,18788000.0 +2026-08-04,NEIC,2270.0,,,2260.0,891.0,2013660.0 +2026-08-04,NSBC,24020.0,,,23950.0,659.0,15783050.0 +2026-08-04,NTLC,16500.0,,,16700.0,909.0,15180300.0 +2026-08-04,ONTBF,2990.0,,,2940.0,1986.0,5838840.0 +2026-08-04,ORAC,16800.0,,,17000.0,1581.0,26877000.0 +2026-08-04,ORGT,3150.0,,,3150.0,6080.0,19152000.0 +2026-08-04,PALC,8980.0,,,8980.0,586.0,5262280.0 +2026-08-04,PRSC,4200.0,,,4400.0,769.0,3383600.0 +2026-08-04,SAFC,5000.0,,,5100.0,8981.0,45803100.0 +2026-08-04,SCRC,3700.0,,,3655.0,7514.0,27463670.0 +2026-08-04,SDCC,11900.0,,,11900.0,2599.0,30928100.0 +2026-08-04,SDSC,2545.0,,,2545.0,13544.0,34469480.0 +2026-08-04,SEMC,1490.0,,,1480.0,2188.0,3238240.0 +2026-08-04,SGBC,37900.0,,,38000.0,4774.0,181412000.0 +2026-08-04,SHEC,2325.0,,,2325.0,4852.0,11280900.0 +2026-08-04,SIBC,9000.0,,,9300.0,13314.0,123820200.0 +2026-08-04,SICC,7095.0,,,7095.0,319.0,2263305.0 +2026-08-04,SIVC,2200.0,,,2150.0,2266.0,4871900.0 +2026-08-04,SLBC,37675.0,,,37670.0,2793.0,105212310.0 +2026-08-04,SMBC,15500.0,,,15700.0,9908.0,155555600.0 +2026-08-04,SNTS,31010.0,,,31400.0,78663.0,2470018200.0 +2026-08-04,SOGC,8400.0,,,8400.0,1468.0,12331200.0 +2026-08-04,SPHC,7500.0,,,7530.0,1375.0,10353750.0 +2026-08-04,STAC,2800.0,,,2800.0,1382.0,3869600.0 +2026-08-04,STBC,24000.0,,,24110.0,8135.0,196134850.0 +2026-08-04,TTLC,2985.0,,,2905.0,2716.0,7889980.0 +2026-08-04,TTLS,3650.0,,,3500.0,2450.0,8575000.0 +2026-08-04,UNLC,51050.0,,,54700.0,12.0,656400.0 +2026-08-04,UNXC,1860.0,,,1890.0,7041.0,13307490.0 +2026-08-05,ABJC,3015.0,,,2800.0,3530.0,9884000.0 +2026-08-05,BICB,7690.0,,,7500.0,5754.0,43155000.0 +2026-08-05,BICC,29000.0,,,29000.0,709.0,20561000.0 +2026-08-05,BNBC,1925.0,,,1915.0,2121.0,4061715.0 +2026-08-05,BOAB,8720.0,,,8700.0,4658.0,40524600.0 +2026-08-05,BOABF,7195.0,,,7200.0,4831.0,34783200.0 +2026-08-05,BOAC,11600.0,,,11600.0,7512.0,87139200.0 +2026-08-05,BOAM,5590.0,,,5585.0,2790.0,15582150.0 +2026-08-05,BOAN,5100.0,,,5075.0,3151.0,15991325.0 +2026-08-05,BOAS,7685.0,,,7600.0,7230.0,54948000.0 +2026-08-05,CABC,3475.0,,,3500.0,1139.0,3986500.0 +2026-08-05,CBIBF,28300.0,,,28300.0,874.0,24734200.0 +2026-08-05,CFAC,1690.0,,,1690.0,3668.0,6198920.0 +2026-08-05,CIEC,4995.0,,,5000.0,2045.0,10225000.0 +2026-08-05,ECOC,16200.0,,,16000.0,1590.0,25440000.0 +2026-08-05,ETIT,61.0,,,64.0,1365222.0,87374208.0 +2026-08-05,FTSC,1970.0,,,1990.0,6025.0,11989750.0 +2026-08-05,LNBB,4300.0,,,4300.0,1866.0,8023800.0 +2026-08-05,NEIC,2260.0,,,2260.0,1983.0,4481580.0 +2026-08-05,NSBC,23900.0,,,23850.0,688.0,16408800.0 +2026-08-05,NTLC,16700.0,,,16750.0,146.0,2445500.0 +2026-08-05,ONTBF,2940.0,,,2985.0,6058.0,18083130.0 +2026-08-05,ORAC,17000.0,,,17175.0,1307.0,22447725.0 +2026-08-05,ORGT,3180.0,,,3140.0,3696.0,11605440.0 +2026-08-05,PALC,8980.0,,,8980.0,840.0,7543200.0 +2026-08-05,PRSC,4400.0,,,4485.0,283.0,1269255.0 +2026-08-05,SAFC,5100.0,,,5000.0,4879.0,24395000.0 +2026-08-05,SCRC,3655.0,,,3575.0,4921.0,17592575.0 +2026-08-05,SDCC,11900.0,,,11400.0,487.0,5551800.0 +2026-08-05,SDSC,2355.0,,,2355.0,15283.0,35991465.0 +2026-08-05,SEMC,1480.0,,,1450.0,4433.0,6427850.0 +2026-08-05,SGBC,38400.0,,,37905.0,3149.0,119362845.0 +2026-08-05,SHEC,2340.0,,,2300.0,2414.0,5552200.0 +2026-08-05,SIBC,9300.0,,,9250.0,7575.0,70068750.0 +2026-08-05,SICC,6575.0,,,7085.0,78.0,552630.0 +2026-08-05,SIVC,2150.0,,,2075.0,4894.0,10155050.0 +2026-08-05,SLBC,37670.0,,,36900.0,221.0,8154900.0 +2026-08-05,SMBC,15800.0,,,16500.0,807.0,13315500.0 +2026-08-05,SNTS,31400.0,,,30200.0,4338.0,131007600.0 +2026-08-05,SOGC,7405.0,,,8100.0,1331.0,10781100.0 +2026-08-05,SPHC,7520.0,,,7600.0,960.0,7296000.0 +2026-08-05,STAC,2770.0,,,2690.0,4287.0,11532030.0 +2026-08-05,STBC,24500.0,,,25000.0,6152.0,153800000.0 +2026-08-05,TTLC,2905.0,,,2980.0,1345.0,4008100.0 +2026-08-05,TTLS,3500.0,,,3630.0,681.0,2472030.0 +2026-08-05,UNLC,51055.0,,,54595.0,33.0,1801635.0 +2026-08-05,UNXC,1835.0,,,1895.0,5591.0,10594945.0 +2026-08-06,ABJC,3000.0,3010.0,3000.0,3010.0,499.0,1499495.0 +2026-08-06,BICB,7500.0,7595.0,7500.0,7595.0,1303.0,9834392.5 +2026-08-06,BICC,29000.0,29100.0,29000.0,29100.0,148.0,4299400.0 +2026-08-06,BNBC,1915.0,1915.0,1900.0,1900.0,1386.0,2643795.0 +2026-08-06,BOAB,8700.0,8710.0,8700.0,8710.0,4887.0,42541335.0 +2026-08-06,BOABF,7500.0,7500.0,7230.0,7230.0,14488.0,106704120.0 +2026-08-06,BOAC,11600.0,11600.0,11600.0,11600.0,3757.0,43581200.0 +2026-08-06,BOAM,5585.0,5590.0,5585.0,5590.0,849.0,4743787.5 +2026-08-06,BOAN,5075.0,5200.0,5075.0,5200.0,4425.0,22733437.5 +2026-08-06,BOAS,7600.0,7710.0,7600.0,7710.0,1166.0,8925730.0 +2026-08-06,CABC,3600.0,3600.0,3550.0,3550.0,782.0,2795650.0 +2026-08-06,CBIBF,28305.0,28305.0,28305.0,28305.0,523.0,14803515.0 +2026-08-06,CFAC,1690.0,1700.0,1690.0,1700.0,502.0,850890.0 +2026-08-06,CIEC,5000.0,5020.0,5000.0,5020.0,2192.0,10981920.0 +2026-08-06,ECOC,16000.0,16200.0,16000.0,16200.0,562.0,9048200.0 +2026-08-06,ETIT,64.0,65.0,64.0,65.0,428618.0,27645861.0 +2026-08-06,FTSC,1990.0,2000.0,1990.0,2000.0,10905.0,21755475.0 +2026-08-06,LNBB,4300.0,4300.0,4300.0,4300.0,4261.0,18322300.0 +2026-08-06,NEIC,2260.0,2260.0,2115.0,2115.0,878.0,1920625.0 +2026-08-06,NSBC,23550.0,23550.0,23550.0,23550.0,309.0,7276950.0 +2026-08-06,NTLC,16750.0,16750.0,16595.0,16595.0,384.0,6402240.0 +2026-08-06,ONTBF,2940.0,2940.0,2940.0,2940.0,1693.0,4977420.0 +2026-08-06,ORAC,17175.0,17175.0,17175.0,17175.0,773.0,13276275.0 +2026-08-06,ORGT,3140.0,3140.0,3125.0,3125.0,1235.0,3868637.5 +2026-08-06,PALC,8970.0,8980.0,8970.0,8980.0,836.0,7503100.0 +2026-08-06,PRSC,4305.0,4480.0,4305.0,4480.0,464.0,2038120.0 +2026-08-06,SAFC,5000.0,5250.0,5000.0,5250.0,3742.0,19177750.0 +2026-08-06,SCRC,3575.0,3575.0,3550.0,3550.0,4769.0,16989562.5 +2026-08-06,SDCC,11700.0,11900.0,11700.0,11900.0,478.0,5640400.0 +2026-08-06,SDSC,2190.0,2530.0,2190.0,2530.0,63031.0,148753160.0 +2026-08-06,SEMC,1445.0,1445.0,1445.0,1445.0,1026.0,1482570.0 +2026-08-06,SGBC,38000.0,38000.0,38000.0,38000.0,3368.0,127984000.0 +2026-08-06,SHEC,2300.0,2300.0,2290.0,2290.0,2066.0,4741470.0 +2026-08-06,SIBC,9250.0,9300.0,9250.0,9300.0,2688.0,24931200.0 +2026-08-06,SICC,7085.0,7085.0,7085.0,7085.0,9.0,63765.0 +2026-08-06,SIVC,2075.0,2125.0,2075.0,2125.0,4737.0,9947700.0 +2026-08-06,SLBC,37000.0,37000.0,37000.0,37000.0,322.0,11914000.0 +2026-08-06,SMBC,16500.0,16550.0,16500.0,16550.0,628.0,10377700.0 +2026-08-06,SNTS,31500.0,31500.0,31000.0,31000.0,2386.0,74562500.0 +2026-08-06,SOGC,8060.0,8100.0,8060.0,8100.0,771.0,6229680.0 +2026-08-06,SPHC,7650.0,7650.0,7600.0,7600.0,455.0,3469375.0 +2026-08-06,STAC,2700.0,2700.0,2675.0,2675.0,4162.0,11185375.0 +2026-08-06,STBC,24500.0,24980.0,24500.0,24980.0,1907.0,47179180.0 +2026-08-06,TTLC,2980.0,2980.0,2805.0,2805.0,3506.0,10141105.0 +2026-08-06,TTLS,3630.0,3630.0,3500.0,3500.0,1573.0,5607745.0 +2026-08-06,UNLC,57500.0,57500.0,54595.0,54595.0,21.0,1176997.5 +2026-08-06,UNXC,1835.0,1900.0,1835.0,1900.0,5463.0,10202152.5 +2026-08-07,ABJC,3000.0,,,3010.0,499.0,1501990.0 +2026-08-07,BICB,7500.0,,,7595.0,1303.0,9896285.0 +2026-08-07,BICC,29000.0,,,29100.0,148.0,4306800.0 +2026-08-07,BNBC,1915.0,,,1900.0,1386.0,2633400.0 +2026-08-07,BOAB,8700.0,,,8710.0,4887.0,42565770.0 +2026-08-07,BOABF,7500.0,,,7230.0,14488.0,104748240.0 +2026-08-07,BOAC,11600.0,,,11600.0,3757.0,43581200.0 +2026-08-07,BOAM,5585.0,,,5590.0,849.0,4745910.0 +2026-08-07,BOAN,5075.0,,,5200.0,4425.0,23010000.0 +2026-08-07,BOAS,7600.0,,,7710.0,1166.0,8989860.0 +2026-08-07,CABC,3600.0,,,3550.0,782.0,2776100.0 +2026-08-07,CBIBF,28305.0,,,28305.0,523.0,14803515.0 +2026-08-07,CFAC,1690.0,,,1700.0,502.0,853400.0 +2026-08-07,CIEC,5000.0,,,5020.0,2192.0,11003840.0 +2026-08-07,ECOC,16000.0,,,16200.0,562.0,9104400.0 +2026-08-07,ETIT,64.0,,,65.0,428618.0,27860170.0 +2026-08-07,FTSC,1990.0,,,2000.0,10905.0,21810000.0 +2026-08-07,LNBB,4300.0,,,4300.0,4261.0,18322300.0 +2026-08-07,NEIC,2260.0,,,2115.0,878.0,1856970.0 +2026-08-07,NSBC,23550.0,,,23550.0,309.0,7276950.0 +2026-08-07,NTLC,16750.0,,,16595.0,384.0,6372480.0 +2026-08-07,ONTBF,2940.0,,,2940.0,1693.0,4977420.0 +2026-08-07,ORAC,17175.0,,,17175.0,773.0,13276275.0 +2026-08-07,ORGT,3140.0,,,3125.0,1235.0,3859375.0 +2026-08-07,PALC,8970.0,,,8980.0,836.0,7507280.0 +2026-08-07,PRSC,4305.0,,,4480.0,464.0,2078720.0 +2026-08-07,SAFC,5000.0,,,5250.0,3742.0,19645500.0 +2026-08-07,SCRC,3575.0,,,3550.0,4769.0,16929950.0 +2026-08-07,SDCC,11700.0,,,11900.0,478.0,5688200.0 +2026-08-07,SDSC,2190.0,,,2530.0,63031.0,159468430.0 +2026-08-07,SEMC,1445.0,,,1445.0,1026.0,1482570.0 +2026-08-07,SGBC,38000.0,,,38000.0,3368.0,127984000.0 +2026-08-07,SHEC,2300.0,,,2290.0,2066.0,4731140.0 +2026-08-07,SIBC,9250.0,,,9300.0,2688.0,24998400.0 +2026-08-07,SICC,7085.0,,,7085.0,9.0,63765.0 +2026-08-07,SIVC,2075.0,,,2125.0,4737.0,10066125.0 +2026-08-07,SLBC,37000.0,,,37000.0,322.0,11914000.0 +2026-08-07,SMBC,16500.0,,,16550.0,628.0,10393400.0 +2026-08-07,SNTS,31500.0,,,31000.0,2386.0,73966000.0 +2026-08-07,SOGC,8060.0,,,8100.0,771.0,6245100.0 +2026-08-07,SPHC,7650.0,,,7600.0,455.0,3458000.0 +2026-08-07,STAC,2700.0,,,2675.0,4162.0,11133350.0 +2026-08-07,STBC,24500.0,,,24980.0,1907.0,47636860.0 +2026-08-07,TTLC,2980.0,,,2805.0,3506.0,9834330.0 +2026-08-07,TTLS,3630.0,,,3500.0,1573.0,5505500.0 +2026-08-07,UNLC,57500.0,,,54595.0,21.0,1146495.0 +2026-08-07,UNXC,1835.0,,,1900.0,5463.0,10379700.0 diff --git a/src/brvm/cli.py b/src/brvm/cli.py index 2f36aee..8b10602 100644 --- a/src/brvm/cli.py +++ b/src/brvm/cli.py @@ -633,6 +633,25 @@ def _veille(args) -> int: print(f"jours ouvrés écoulés depuis : {ecoules}") print(f"séances au total : {archive['date'].nunique()}") + # LE SECOND ANGLE MORT, ET IL A SERVI. Regarder la dernière séance dit + # si la collecte VIT ; cela ne dit rien de ce qu'elle a manqué en + # chemin. Le 6 août 2026, l'ingestion a été annulée à mi-course : le + # 5 et le 7 sont dans l'archive, le 6 n'y est pas, et cette commande + # répondait « L'archive avance » — ce qui était vrai, et à côté de la + # question. Un trou ne se comble pas tout seul et ne se voit plus + # jamais, alors qu'une séance en retard finit par arriver. + trous = _seances_manquantes(archive) + if trous: + recents = trous[-8:] + print(f"\nTROUS DANS L'ARCHIVE : {len(trous)} jour(s) ouvré(s) sans " + f"séance entre la première et la dernière. " + f"{'Les plus récents' if len(trous) > 8 else 'Ce sont'} : " + + ", ".join(recents) + ".") + print("Un jour férié de la BRVM en est un aussi — le calendrier des " + "fériés n'est pas\nen base, donc tous ne sont pas des pannes. " + "Ce qui se rattrape se rattrape\npar « brvm rapatrier --debut " + "… --fin … ».") + if ecoules > args.tolerance: print( f"\nARCHIVE FIGÉE : {ecoules} jours ouvrés sans nouvelle séance, " @@ -645,10 +664,30 @@ def _veille(args) -> int: ) return 1 - print("\nL'archive avance.") + print("\nL'archive avance." if not trous else + "\nL'archive avance, mais elle est trouée.") return 0 +def _seances_manquantes(archive: pd.DataFrame) -> list[str]: + """Jours ouvrés sans aucune ligne, entre la première et la dernière. + + Bornée à l'intervalle couvert : au-delà de la dernière séance, ce n'est + plus un trou mais du retard, et c'est l'autre moitié de la commande qui + le mesure. + + Les jours fériés de la BRVM y figurent — leur calendrier n'est pas en + base. C'est délibéré : mieux vaut nommer huit dates dont deux sont des + fériés que de taire un trou en croyant l'expliquer. + """ + if archive.empty: + return [] + presentes = set(archive["date"].astype(str)) + ouvres = pd.bdate_range(min(presentes), max(presentes)) + return [j.strftime("%Y-%m-%d") for j in ouvres + if j.strftime("%Y-%m-%d") not in presentes] + + def _limites() -> None: """Les variations hors ±7,5 %, rangées par cause probable. diff --git a/tests/test_qualite.py b/tests/test_qualite.py index a4bda3d..a6350f4 100644 --- a/tests/test_qualite.py +++ b/tests/test_qualite.py @@ -330,3 +330,52 @@ def test_variante_sources_rend_l_original_quand_tout_concorde(): fonda = pd.DataFrame([{"ticker": "AAAA", "date": "2023-12-31", "indicateur": "dividende", "valeur": 100.0}]) pd.testing.assert_frame_equal(qualite.variante_sources(div, fonda), div) + + +def _archive(dates: list[str]) -> pd.DataFrame: + """Une archive minimale : une ligne par séance.""" + return pd.DataFrame([{"date": d, "ticker": "AAAA", "cloture": 100.0} + for d in dates]) + + +def test_un_trou_dans_l_archive_se_voit(): + """LE DÉFAUT QUI A COÛTÉ UNE SÉANCE. `brvm veille` regardait la + dernière date et concluait « l'archive avance » — ce qui était vrai, et + à côté de la question : le 6 août 2026 manquait entre le 5 et le 7, + l'ingestion ayant été annulée à mi-course. + + Un retard finit par se combler tout seul ; un trou, jamais. Il doit + donc se voir, sans quoi il ne se voit plus jamais. + """ + from brvm.cli import _seances_manquantes + + # mercredi, jeudi manquant, vendredi + trous = _seances_manquantes(_archive(["2026-08-05", "2026-08-07"])) + assert trous == ["2026-08-06"] + + +def test_le_week_end_n_est_pas_un_trou(): + """Sans quoi le contrôle signalerait deux jours par semaine et + s'apprendrait à s'ignorer avant la fin du premier mois.""" + from brvm.cli import _seances_manquantes + + # vendredi puis lundi : le samedi et le dimanche ne comptent pas. + assert _seances_manquantes(_archive(["2026-08-07", "2026-08-10"])) == [] + + +def test_le_retard_n_est_pas_un_trou(): + """Un trou est BORNÉ par les séances connues. Au-delà de la dernière, + ce n'est plus une lacune mais du retard, et c'est l'autre moitié de la + commande qui le mesure — les compter deux fois ferait dire à la veille + qu'une archive à jour d'hier est trouée.""" + from brvm.cli import _seances_manquantes + + assert _seances_manquantes(_archive(["2020-01-01", "2020-01-02"])) == [] + + +def test_une_archive_vide_n_a_pas_de_trou(): + """Zéro séance n'est pas une archive trouée : c'est une archive vide, + et le message qui convient n'est pas le même.""" + from brvm.cli import _seances_manquantes + + assert _seances_manquantes(pd.DataFrame()) == []