From 4c9e2c2658d4395cc4cf5fb8415bfe301ae59537 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 21:41:22 +0000 Subject: [PATCH 1/6] =?UTF-8?q?Verser=20les=20quatre=20s=C3=A9ances=20en?= =?UTF-8?q?=20attente,=20du=203=20au=207=20ao=C3=BBt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ingestion.yml` ne commite pas — un runner ne sait pas signer — et l'archive s'était arrêtée au 31 juillet pendant que les artefacts s'empilaient. C'est le retard qui obligeait à cliquer « Actualiser » pour voir le cours du jour. Quatre séances, et non trois : celle du 7 août est arrivée pendant le versement. 188 lignes, 47 sociétés cotant chacun des quatre jours. VERSÉ PAR `versement.yml`, PAS PAR L'ARTEFACT. Le stockage d'artefacts est injoignable depuis cet environnement — le CONNECT échoue au proxy, ce que cette action prévoit explicitement. Le runner a donc réémis l'incrément dans son journal, compressé et empreinté, et l'empreinte a été vérifiée avant écriture : sha256 7223a8765dc5571e, 189 lignes. Le versement se vérifie au lieu de se supposer. Le garde-fou du workflow a servi une fois de plus : 38 lignes de 2015-2017 ont été ÉCARTÉES car antérieures à l'archive versée. Ce sont les séances fantômes retirées le 29 juillet, que l'artefact du 28 porte encore ; les verser les aurait ressuscitées sans que rien ne le dise. Contrôles avant écriture : en-têtes identiques, aucune clé (date, ticker) déjà présente, aucune ligne antérieure à la dernière séance, tri préservé. Le diff est un ajout pur — 188 insertions, zéro suppression. Contrôles après : les 188 variations tiennent dans la limite réglementaire de ±7,5 %, deux exactement au plafond, aucune clôture nulle. `brvm veille` répond « L'archive avance ». 248 tests passent. CE QUI N'EST PAS VERSÉ, ET POURQUOI. Le référentiel accompagnait l'incrément dans le journal, mais sa ligne base64 est arrivée corrompue — gzip rejette le flux, alors que l'incrément cinq fois plus long est passé intact. Deux transcriptions indépendantes donnent le même résultat : ce n'est pas une erreur de lecture. `data/referentiel.csv` reste donc au 31 juillet pour `derniere_vue`. C'est une trace de provenance, lue nulle part ailleurs que dans `db.py` — aucun calcul n'en dépend. La déduire des cours aurait été possible, les 47 sociétés cotant chaque jour, mais cela aurait raté un renommage ou un reclassement sectoriel publié entre-temps : précisément ce que `fusionner_referentiel` existe pour signaler. Mieux vaut une date en retard qu'un reclassement effacé en silence. Le prochain versement dont le journal se relit entier la corrigera. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mu7CkW6727S9ZyRbiYyRk9 --- data/cours.csv | 188 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/data/cours.csv b/data/cours.csv index 4af9066..f2b3b93 100644 --- a/data/cours.csv +++ b/data/cours.csv @@ -112912,3 +112912,191 @@ 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-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 From c6c6dcf6762b3ab5307e54e2f265cc8d840e9c78 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 13:39:00 +0000 Subject: [PATCH 2/6] =?UTF-8?q?Verser=20automatiquement,=20et=20signer=20c?= =?UTF-8?q?e=20que=20le=20robot=20=C3=A9crit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'archive n'avançait pas toute seule : `ingestion.yml` déposait un artefact, et quelqu'un devait penser à le verser. Personne n'y a pensé pendant cinq séances, et c'est ce retard qui obligeait à cliquer « Actualiser » pour voir le cours du jour. `versement.yml` tourne désormais chaque jour ouvré à 16 h 45 UTC, trois quarts d'heure après la collecte, et écrit lui-même. POURQUOI UNE CLÉ POUR UN ROBOT. 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 — « versement automatique » — dit qui a agi. La contrepartie, dite franchement dans le README plutôt que passée sous silence : une signature ne prouve plus qu'une personne tenait sa clé, seulement que le commit vient de ce dépôt. La distinction reste lisible, elle change de nature. Une autre voie a été essayée avant celle-ci et écartée sur mesure : un commit créé par l'API GitHub revient `unsigned`. Testé, pas supposé. LES MÊMES CONTRÔLES QU'À LA MAIN, PARCE QUE PERSONNE NE REGARDERA. En-têtes identiques, aucune ligne déjà présente, aucune séance antérieure à l'archive, tri préservé, et la limite de ±7,5 % par séance — un cours qui la franchit n'est pas une forte séance mais une lecture fausse ou une réduction du nominal, et dans les deux cas un humain doit voir. Un seul contrôle qui tombe annule le versement. Dix cas vérifiés en dehors du runner, dont les subtils : +7,5 % pile est accepté puisque c'est légal, deux séances consécutives sous la limite le sont aussi puisque le contrôle est par séance et non cumulé, et une clôture vide — valeur non cotée ce jour-là — ne déclenche rien. LE JOURNAL DEVIENT LE FILET. Il ne s'écrit plus que si rien n'a été poussé : secret absent, contrôle en échec, poussée refusée trois fois. C'est exactement ce qui a servi à rattraper les séances du 3 au 7 août quand le stockage d'artefacts était injoignable, et cette voie reste ouverte. Trois défauts corrigés avant d'être laissés en place : le référentiel était DÉPLACÉ vers data/, ce qui privait le filet de la moitié de ce qu'il doit émettre — il est copié ; le message de commit passait par la ligne de commande, où un accent grave dans un nom de société aurait été interprété par le shell — il passe par un fichier ; et la poussée n'était pas retentée si `main` avait bougé pendant le téléchargement des artefacts. L'installation de la clé demande quatre gestes, documentés dans le README. Sans eux le workflow ne pousse rien et retombe sur le journal : la donnée n'est jamais perdue, seulement l'automatisme. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mu7CkW6727S9ZyRbiYyRk9 --- .github/workflows/versement.yml | 222 +++++++++++++++++++++++++++++++- README.md | 84 +++++++++--- 2 files changed, 287 insertions(+), 19 deletions(-) diff --git a/.github/workflows/versement.yml b/.github/workflows/versement.yml index e40edb8..a57fdbc 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,10 +30,25 @@ 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: @@ -180,18 +201,213 @@ 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") + + derniere = max((d for d, _ in connues), default="") + anciennes = [l for l in incr if cle(l)[0] <= derniere] + if anciennes: + refus.append(f"{len(anciennes)} lignes pas postérieures " + f"à {derniere}") + + # LA LIMITE DE ±7,5 % EST UNE RÈGLE DU MARCHÉ, PAS UNE TOLÉRANCE. + # Un cours qui la franchit n'est pas une forte séance : c'est une + # lecture fausse, ou une réduction du nominal que l'archive doit + # traiter à part. Dans les deux cas, un humain doit voir. + 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 + veille = derniers.get(ticker) + valeur = float(cloture) + if veille: + ecart = valeur / veille - 1 + if abs(ecart) > 0.0751: + hors.append(f"{date} {ticker} {ecart:+.2%}") + derniers[ticker] = valeur + if hors: + refus.append(f"{len(hors)} variations hors ±7,5 % : " + + ", ".join(hors[:5])) + + 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) + + fusion = sorted(archive + incr, key=cle) + assert fusion == archive + sorted(incr, key=cle), \ + "l'incrément ne s'ajoute pas en queue" + 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' + && secrets.CLE_VERSEMENT != '' }} + 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..a7068e4 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,77 @@ 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 -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. +`ingestion.yml` et `rapatriement.yml` sont en **lecture seule**. Ils +publient un artefact `archive-` contenant `data/` et n'écrivent +rien : ce sont des collecteurs. -`ingestion.yml` et `rapatriement.yml` sont donc en **lecture seule**. Ils -publient un artefact `archive-` contenant `data/`, et le versement -se fait à la main : +`versement.yml` écrit. Il tourne chaque jour ouvré à 16 h 45 UTC, relit +les artefacts des ingestions récentes, contrôle ce qu'il y trouve, et +verse dans `data/` — **en signant**. -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`. +**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. -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. +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 **la limite de ±7,5 % +par séance**. Un seul contrôle qui tombe annule le versement. + +**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 : + +```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 + +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 +``` + +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`. + +`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 From 7a52fa703e06ccca8353770ec30eefa5abbfd332 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 14:01:49 +0000 Subject: [PATCH 3/6] =?UTF-8?q?Ne=20pas=20refuser=20sur=20la=20limite=20de?= =?UTF-8?q?=20s=C3=A9ance,=20et=20voir=20les=20trous=20de=20l'archive?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEUX CORRECTIONS, ET LA PREMIÈRE EST CELLE D'UNE ERREUR QUE JE VENAIS D'INTRODUIRE. Le versement automatique refusait d'écrire quand une variation franchit ±7,5 %. C'était en contradiction directe avec ce que le projet a mesuré et écrit dans `qualite.limites` : 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 et non deux blocs séparés d'un mois ; une division du nominal la franchit par construction. Pire : la saison des détachements va de mai à août. Le robot aurait donc refusé de verser dès sa première semaine de service, sur des données correctes — exactement le « contrôle qui crie sur des données correctes » que le projet refuse, parce qu'on apprend à l'ignorer et que le jour où il a raison personne ne l'écoute. Les dépassements sont désormais NOTÉS dans le journal, jamais opposés. `pics_isoles` serait la bonne signature, sans ambiguïté ; elle demande la séance SUIVANTE et ne s'applique donc pas à une séance fraîche. Le refus se limite à ce qui est certain : en-têtes, doublons, antériorité, tri, et clôture nulle ou négative — celle-là ne connaît pas d'exception. Un seuil de complétude a été envisagé puis écarté sur mesure : l'archive contient des séances à une seule valeur, héritées du rapatriement, et le seuil aurait crié dessus. LE SECOND ANGLE MORT DE LA VEILLE. `brvm veille` regardait la dernière séance et concluait « L'archive avance » — ce qui était vrai, et à côté de la question. Le 6 août 2026, l'ingestion a été annulée à mi-course : le 5 et le 7 sont en archive, le 6 n'y est pas, et rien ne l'a dit. Un retard finit par se combler ; un trou, jamais. La commande liste maintenant les jours ouvrés sans séance entre la première et la dernière : 22 sur onze ans et demi. Le message dit franchement que les fériés de la BRVM en font partie — leur calendrier n'est pas en base — plutôt que de taire un trou en croyant l'expliquer. Bornée à l'intervalle couvert : au-delà de la dernière séance c'est du retard, que l'autre moitié de la commande mesure déjà, et les compter deux fois ferait dire à la veille qu'une archive à jour d'hier est trouée. Douze cas de contrôle vérifiés hors du runner, dont les quatre qui changent de verdict : détachement à −9 % et division du nominal à −50 % sont désormais ACCEPTÉS, clôture nulle et négative refusées. Quatre tests pour la détection de trous, dont les négatifs qui comptent le plus : un week-end n'est pas un trou, du retard non plus. 252 tests passent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mu7CkW6727S9ZyRbiYyRk9 --- .github/workflows/versement.yml | 37 ++++++++++++++++++++----- README.md | 13 +++++++-- src/brvm/cli.py | 41 ++++++++++++++++++++++++++- tests/test_qualite.py | 49 +++++++++++++++++++++++++++++++++ 4 files changed, 130 insertions(+), 10 deletions(-) diff --git a/.github/workflows/versement.yml b/.github/workflows/versement.yml index a57fdbc..4892f48 100644 --- a/.github/workflows/versement.yml +++ b/.github/workflows/versement.yml @@ -256,10 +256,10 @@ jobs: refus.append(f"{len(anciennes)} lignes pas postérieures " f"à {derniere}") - # LA LIMITE DE ±7,5 % EST UNE RÈGLE DU MARCHÉ, PAS UNE TOLÉRANCE. - # Un cours qui la franchit n'est pas une forte séance : c'est une - # lecture fausse, ou une réduction du nominal que l'archive doit - # traiter à part. Dans les deux cas, un humain doit voir. + # 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(",") @@ -271,16 +271,39 @@ jobs: date, ticker, cloture = champs[0], champs[1], champs[5] if not cloture: continue - veille = derniers.get(ticker) 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: - refus.append(f"{len(hors)} variations hors ±7,5 % : " - + ", ".join(hors[:5])) + 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É :") diff --git a/README.md b/README.md index a7068e4..fabc9c9 100644 --- a/README.md +++ b/README.md @@ -444,8 +444,17 @@ 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 **la limite de ±7,5 % -par séance**. Un seul contrôle qui tombe annule le versement. +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 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()) == [] From ca5be722689fff4bd8555ea515d27b5516584af4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 19:09:08 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Combler=20les=20trous=20de=20l'archive,=20s?= =?UTF-8?q?ans=20ressusciter=20ce=20qu'on=20en=20a=20retir=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le versement ne retenait que les séances POSTÉRIEURES à l'archive. Ce garde-fou empêche de ressusciter les 38 lignes fantômes retirées le 29 juillet — il a servi dès le premier passage — 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, et il a fallu le vérifier avant de le croire. Une ligne retirée l'a été d'une date qui existe toujours : le 09/02/2015 garde 22 lignes, le 25/12/2017 en garde 30, le 04/01/2016 en garde 23. Un trou, lui, est une date ENTIÈREMENT absente — le 06/08/2026 en compte zéro. 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. DEUX SOURCES D'ARTEFACTS. Le versement relit désormais aussi les exécutions de `rapatriement.yml`, qui dépose le même `archive-`. C'est ce qui rend un trou rattrapable sans intervention : `brvm rapatrier` relit la période, et le versement suivant la reprend. Le référentiel, lui, ne se prend QUE de l'ingestion : le rapatriement lit un historique valeur par valeur et ne rafraîchit pas la liste des cotées. Retenir le sien ferait reculer `derniere_vue` pour tout le monde. UNE GARANTIE CHANGE DE NATURE. « L'incrément s'ajoute en queue » cesse d'être vraie dès qu'on insère au milieu. Ce qui doit rester vrai, et qui est désormais vérifié : la fusion est triée, elle ne perd aucune ligne de l'archive, et elle en compte exactement autant qu'avant plus l'incrément — donc elle n'en invente ni n'en écrase. Treize cas vérifiés hors du runner, dont les deux qui décident : une date entièrement absente se comble, une ligne antérieure sur une date présente reste refusée. Le harnais a été réécrit plutôt que rapiécé — un premier remplacement en gros avait réécrit les fixtures elles-mêmes et fait rougir huit cas pour rien. 252 tests passent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mu7CkW6727S9ZyRbiYyRk9 --- .github/workflows/versement.yml | 113 ++++++++++++++++++++++++-------- README.md | 14 +++- 2 files changed, 97 insertions(+), 30 deletions(-) diff --git a/.github/workflows/versement.yml b/.github/workflows/versement.yml index 4892f48..1770155 100644 --- a/.github/workflows/versement.yml +++ b/.github/workflows/versement.yml @@ -116,16 +116,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: @@ -147,9 +161,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")) @@ -157,31 +178,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) @@ -250,11 +289,22 @@ jobs: 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="") - anciennes = [l for l in incr if cle(l)[0] <= derniere] - if anciennes: - refus.append(f"{len(anciennes)} lignes pas postérieures " - f"à {derniere}") + 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 @@ -314,9 +364,16 @@ jobs: 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 == archive + sorted(incr, key=cle), \ - "l'incrément ne s'ajoute pas en queue" + 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") diff --git a/README.md b/README.md index fabc9c9..0b9349f 100644 --- a/README.md +++ b/README.md @@ -426,8 +426,18 @@ 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 récentes, contrôle ce qu'il y trouve, et -verse dans `data/` — **en signant**. +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é From b138ae9c11ca93d44edda1012ce8234d12443470 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 19:12:36 +0000 Subject: [PATCH 5/6] =?UTF-8?q?Lire=20la=20pr=C3=A9sence=20de=20la=20cl?= =?UTF-8?q?=C3=A9=20l=C3=A0=20o=C3=B9=20le=20contexte=20l'autorise?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `secrets` n'existe pas dans un `if` d'étape. GitHub ne rejette pas l'étape : il refuse le FICHIER, et le workflow ne démarre jamais. Tel que commité, le versement automatique n'aurait pas tourné une seule fois — le défaut est apparu au premier lancement réel, pas à la relecture ni au contrôle YAML, qui tous deux le trouvaient valide. La présence de la clé se lit désormais dans l'`env` du job, où le contexte est autorisé, et voyage ensuite comme une variable ordinaire. Le booléen y arrive en chaîne : c'est « true » qu'on compare. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mu7CkW6727S9ZyRbiYyRk9 --- .github/workflows/versement.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/versement.yml b/.github/workflows/versement.yml index 1770155..d9bd91d 100644 --- a/.github/workflows/versement.yml +++ b/.github/workflows/versement.yml @@ -54,6 +54,13 @@ permissions: 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 @@ -406,7 +413,7 @@ jobs: - name: Signer et verser id: verser if: ${{ steps.appliquer.outputs.applique == 'true' - && secrets.CLE_VERSEMENT != '' }} + && env.CLE_PRESENTE == 'true' }} env: CLE: ${{ secrets.CLE_VERSEMENT }} COURRIEL: ${{ vars.COURRIEL_VERSEMENT }} From 0c9e1ca8d7683646783ce161784e14b230ac2762 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 19:16:16 +0000 Subject: [PATCH 6/6] =?UTF-8?q?Combler=20le=20trou=20du=206=20ao=C3=BBt,?= =?UTF-8?q?=20par=20la=20voie=20qu'on=20vient=20d'ouvrir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La séance manquait depuis que l'ingestion du 6 août a été annulée à mi-course. 47 valeurs, insérées entre le 5 et le 7 — première écriture au MILIEU de l'archive, et non en queue. RÉCUPÉRÉE DE BOUT EN BOUT PAR LA CHAÎNE ELLE-MÊME, ce qui la teste autant qu'elle la répare : `rapatriement.yml` a relu la séance chez la source et déposé son artefact ; `versement.yml`, lancé depuis la branche, l'a reconnue comme un trou — date entièrement absente — et l'a versée dans son espace de travail ; faute de clé, il a sauté la poussée et émis l'incrément dans le journal, exactement comme le filet doit le faire. Empreinte vérifiée avant écriture : sha256 2153ca4fef8e5ab2, 48 lignes. Les mêmes contrôles qu'à la main : en-têtes identiques, aucune ligne déjà présente, aucune ligne antérieure sur une séance connue, clôtures positives, fusion triée sans perte ni doublon. Les 47 variations tiennent dans la limite du marché — de −6,42 % à +7,50 % — et `brvm veille` ne compte plus que 21 trous au lieu de 22, tous antérieurs à 2026 et vraisemblablement des fériés. Le rapatriement renseigne `haut` et `bas`, que l'ingestion laisse vides : l'API d'historique les publie, la page de cote non. Une séance rapatriée est donc un peu plus riche que ses voisines, jamais plus pauvre. 252 tests passent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Mu7CkW6727S9ZyRbiYyRk9 --- data/cours.csv | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/data/cours.csv b/data/cours.csv index f2b3b93..e81b214 100644 --- a/data/cours.csv +++ b/data/cours.csv @@ -113053,6 +113053,53 @@ date,ticker,ouverture,haut,bas,cloture,volume_titres,volume_fcfa 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