From 080aa7075b2d70bdc3241c4628905dc77c07c3fe Mon Sep 17 00:00:00 2001 From: Eva ATTAL Date: Tue, 14 Jul 2026 08:50:55 +0000 Subject: [PATCH] =?UTF-8?q?F3=20:=20t=C3=A9l=C3=A9chargeur=20PDF=20des=20b?= =?UTF-8?q?outiques=20+=20contrat=20d'extraction=20Codex?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ordre du recensement (Moneta → Indépendance AM → Amiral → Carmignac → LFDE → Comgest), recettes vérifiées en direct sur chaque site. Le contenu est archivé en base (SourceDocument.contenu) avec sha256 : plusieurs maisons publient à une URL stable réécrite chaque mois, seul le contenu permet de détecter un nouveau reporting. /api/fonds/extraction/en-attente + POST /api/fonds/extraction exposent le contrat piloté par l'agent d'extraction (docs/extraction-fonds.md), avec garde-fous (poids du top 10 plausible, citation obligatoire par thèse). Testé de bout en bout sur les 7 fonds réels : 9 documents collectés, extraction + garde-fous vérifiés via l'API. --- app/collectors/fonds_pdf.py | 209 ++++++++++++++++++++++++++++++++++++ app/config.py | 8 +- app/main.py | 144 ++++++++++++++++++++++++- app/models.py | 4 +- app/scheduler.py | 14 ++- docs/extraction-fonds.md | 103 ++++++++++++++++++ tests/test_fonds_pdf.py | 148 +++++++++++++++++++++++++ 7 files changed, 621 insertions(+), 9 deletions(-) create mode 100644 app/collectors/fonds_pdf.py create mode 100644 docs/extraction-fonds.md create mode 100644 tests/test_fonds_pdf.py diff --git a/app/collectors/fonds_pdf.py b/app/collectors/fonds_pdf.py new file mode 100644 index 0000000..922a467 --- /dev/null +++ b/app/collectors/fonds_pdf.py @@ -0,0 +1,209 @@ +"""Téléchargement et archivage des reportings PDF des boutiques (phase F3). + +Chaque maison a sa recette (```_recipe_*```) : trouver l'URL du reporting du +mois — « fiche » (positions) et « lettre » (le pourquoi, mensuel ou +trimestriel selon la maison). Le contenu est archivé en base (colonne +``SourceDocument.contenu``) avec son sha256 : certaines maisons publient à +une URL stable réécrite chaque mois (Amiral, Carmignac, LFDE), la seule +façon fiable de détecter un nouveau reporting est de comparer le contenu. + +L'extraction (positions + thèses) est un chantier séparé, piloté par un +agent LLM (Codex) via ``/api/fonds/extraction`` — voir ``docs/extraction-fonds.md``. +Ce module ne fait que collecter et archiver, jamais d'extraction. +""" +import datetime +import hashlib +import logging +import re + +import httpx +from sqlalchemy import select +from sqlalchemy.orm import Session + +from ..models import Fund, SourceDocument + +HEADERS = {"User-Agent": "La Grande Machine (outil pedagogique) eva.attal@gmail.com"} +log = logging.getLogger("collecte.fonds_pdf") + + +def _mois_recul(date_ref: datetime.date, n: int) -> datetime.date: + """Le 1er du mois n mois avant date_ref (n=0 : mois courant).""" + month = date_ref.month - n + year = date_ref.year + while month <= 0: + month += 12 + year -= 1 + return datetime.date(year, month, 1) + + +def _recipe_moneta(client: httpx.Client) -> list[dict]: + """URL prévisible ``documents/_AAAA_MM.pdf`` : mois courant puis jusqu'à 2 mois en arrière.""" + today = datetime.date.today() + out = [] + for type_doc, nom in (("fiche", "Fiche_MMC_FR_fr"), ("lettre", "Lettre_MMC_part_C_FR_fr")): + for back in range(3): + mois = _mois_recul(today, back) + url = f"https://www.moneta.fr/documents/{nom}_{mois.strftime('%Y_%m')}.pdf" + resp = client.get(url) + if resp.status_code == 200 and resp.content: + out.append({"type": type_doc, "url": url, "periode": mois.strftime("%Y-%m"), "contenu": resp.content}) + break + return out + + +def _dernier_reporting_independance(html: str) -> tuple[str | None, str | None]: + """Le reporting mensuel le plus récent listé sur la page du fonds. + + Nom de fichier préfixé AAMMJJ (WordPress, suffixe parfois variable) : + ``.../260630-reporting-france-small-mid-x-eur-c2-fr-2-1.pdf``. + """ + liens = re.findall( + r'href="(https://www\.independance-am\.com/wp-content/uploads/\d{4}/\d{2}/' + r'(\d{6})-reporting-france-small-mid[^"]*\.pdf)"', html) + if not liens: + return None, None + url, date_brute = max(liens, key=lambda pair: pair[1]) + periode = f"20{date_brute[:2]}-{date_brute[2:4]}" + return url, periode + + +def _recipe_independance(client: httpx.Client) -> list[dict]: + page = client.get("https://www.independance-am.com/nos-fonds/france-small/") + page.raise_for_status() + url, periode = _dernier_reporting_independance(page.text) + if url is None: + return [] + resp = client.get(url) + if resp.status_code != 200: + return [] + return [{"type": "fiche", "url": url, "periode": periode, "contenu": resp.content}] + + +def _lien_document_amiral(html: str) -> str | None: + """URL stable (champ JSON ``docPermalink``), contenu remplacé chaque mois.""" + m = re.search(r'docPermalink:"([^"]+)"', html) + if m is None: + return None + return m.group(1).replace("\\u002F", "/") + + +def _recipe_amiral(client: httpx.Client, page_slug: str) -> list[dict]: + page = client.get(f"https://www.amiralgestion.com/fr/publications-adminmenu/{page_slug}") + page.raise_for_status() + url = _lien_document_amiral(page.text) + if url is None: + return [] + resp = client.get(url) + if resp.status_code != 200: + return [] + periode = datetime.date.today().strftime("%Y-%m") + return [{"type": "fiche", "url": url, "periode": periode, "contenu": resp.content}] + + +_CARMIGNAC_URLS = { + "fiche": "https://www.carmignac.com/assets/yoda/FLFPRO_CI_FR0010148981_FR_fr/" + "Monthly-Factsheet-PRO_Carmignac-Investissement-A-EUR-Acc_FR0010148981_FR_fr.pdf", + "lettre": "https://www.carmignac.com/assets/yoda/QR_CI_1_FR_fr/" + "Quarterly-Report_Carmignac-Investissement_FR_fr.pdf", +} + + +def _recipe_carmignac(client: httpx.Client) -> list[dict]: + """URL stables (page « documents » du fonds), contenu remplacé à chaque publication.""" + periode = datetime.date.today().strftime("%Y-%m") + out = [] + for type_doc, url in _CARMIGNAC_URLS.items(): + resp = client.get(url) + if resp.status_code == 200 and resp.content: + out.append({"type": type_doc, "url": url, "periode": periode, "contenu": resp.content}) + return out + + +def _recipe_lfde(client: httpx.Client, isin: str) -> list[dict]: + """URL stable par ISIN (``cdn.lfde.com``), écrasée chaque mois sans archive publique.""" + url = f"https://cdn.lfde.com/upload/documents/FACSHT-FR-FR-{isin}.pdf" + resp = client.get(url) + if resp.status_code != 200 or not resp.content: + return [] + periode = datetime.date.today().strftime("%Y-%m") + return [{"type": "fiche", "url": url, "periode": periode, "contenu": resp.content}] + + +def _liens_documents_comgest(html: str) -> dict[str, tuple[str, str]]: + """{"Monthly Report": (url, "AAAA-MM-JJ"), "Quarterly Report": (...)} depuis le bloc « Key Documents ».""" + blocs = re.findall( + r'fund-key-document-type[^>]*>\s*([^<]+?)\s*.*?href="([^"]+\.pdf)"[^>]*data-documentDate="([^"]*)"', + html, re.S) + out = {} + for label, url, date in blocs: + out.setdefault(label.strip(), (url, date)) + return out + + +def _recipe_comgest(client: httpx.Client) -> list[dict]: + page = client.get("https://www.comgest.com/en/fr/private-investor/funds/comgest-growth-europe-eur-acc") + page.raise_for_status() + liens = _liens_documents_comgest(page.text) + out = [] + for label, type_doc in (("Monthly Report", "fiche"), ("Quarterly Report", "lettre")): + candidat = liens.get(label) + if candidat is None: + continue + url, date = candidat + resp = client.get(url) + if resp.status_code != 200: + continue + periode = date[:7] if len(date) >= 7 else datetime.date.today().strftime("%Y-%m") + out.append({"type": type_doc, "url": url, "periode": periode, "contenu": resp.content}) + return out + + +# Une recette par fonds boutique (slug → fonction acceptant le client HTTP en premier argument). +_RECETTES = { + "moneta-multi-caps": lambda client, fund: _recipe_moneta(client), + "independance-france-small": lambda client, fund: _recipe_independance(client), + "sextant-pme": lambda client, fund: _recipe_amiral(client, "sextant-pme-i-mensuel"), + "sextant-grand-large": lambda client, fund: _recipe_amiral(client, "sextant-grand-large-a-mensuel"), + "carmignac-investissement": lambda client, fund: _recipe_carmignac(client), + "echiquier-agressor": lambda client, fund: _recipe_lfde(client, fund.isin), + "comgest-growth-europe": lambda client, fund: _recipe_comgest(client), +} + + +def _archiver(session: Session, fund: Fund, candidat: dict) -> bool: + """Enregistre un nouveau SourceDocument si le contenu diffère du dernier connu (fonds+type). + + Retourne True si un document a été ajouté.""" + sha = hashlib.sha256(candidat["contenu"]).hexdigest() + dernier = session.scalars( + select(SourceDocument) + .where(SourceDocument.fund_id == fund.id, SourceDocument.type == candidat["type"]) + .order_by(SourceDocument.fetched_at.desc()) + ).first() + if dernier is not None and dernier.sha256 == sha: + return False + session.add(SourceDocument( + fund_id=fund.id, type=candidat["type"], periode=candidat["periode"], url=candidat["url"], + sha256=sha, contenu=candidat["contenu"], statut_extraction="en_attente")) + return True + + +def collect_boutiques_pdf(session: Session) -> dict: + """Collecte les reportings PDF de chaque fonds boutique configuré avec une recette.""" + report = {} + with httpx.Client(headers=HEADERS, timeout=60, follow_redirects=True) as client: + for fund_slug, recette in _RECETTES.items(): + fund = session.scalars(select(Fund).where(Fund.slug == fund_slug)).first() + if fund is None: + report[fund_slug] = {"ok": False, "erreur": "fonds absent de la base"} + continue + try: + candidats = recette(client, fund) + ajoutes = sum(1 for c in candidats if _archiver(session, fund, c)) + session.commit() + report[fund_slug] = {"ok": True, "documents_ajoutes": ajoutes, "trouves": len(candidats)} + except Exception as exc: # noqa: BLE001 — une maison en échec ne bloque pas les autres + session.rollback() + log.warning("collecte PDF %s en échec : %s", fund_slug, exc) + report[fund_slug] = {"ok": False, "erreur": str(exc)[:200]} + return report diff --git a/app/config.py b/app/config.py index f1bfc1e..ad25a32 100644 --- a/app/config.py +++ b/app/config.py @@ -110,9 +110,9 @@ {"slug": "amiral-gestion", "nom": "Amiral Gestion", "pays": "France", "type": "boutique", "site_web": "https://www.amiralgestion.com", "blurb": "Value, petites capitalisations, lettres de gestion détaillées.", - "fonds": [{"slug": "sextant-pme", "nom": "Sextant PME", + "fonds": [{"slug": "sextant-pme", "nom": "Sextant PME", "isin": "FR0011171412", "strategie": "PME européennes", "note_source": "Page publication stable par fonds/part"}, - {"slug": "sextant-grand-large", "nom": "Sextant Grand Large", + {"slug": "sextant-grand-large", "nom": "Sextant Grand Large", "isin": "FR0010286013", "strategie": "Flexible international", "note_source": "Page publication stable par fonds/part"}]}, {"slug": "carmignac", "nom": "Carmignac", "pays": "France", "type": "boutique", "site_web": "https://www.carmignac.fr", @@ -122,13 +122,13 @@ {"slug": "lfde", "nom": "La Financière de l'Échiquier", "pays": "France", "type": "boutique", "site_web": "https://www.lfde.com", "blurb": "Maison historique du stock-picking français.", - "fonds": [{"slug": "echiquier-agressor", "nom": "Echiquier Agressor", + "fonds": [{"slug": "echiquier-agressor", "nom": "Echiquier Agressor", "isin": "FR0010321802", "strategie": "Actions européennes opportunistes", "note_source": "Factsheet cdn.lfde.com à URL stable écrasée chaque mois — archivage obligatoire"}]}, {"slug": "comgest", "nom": "Comgest", "pays": "France", "type": "boutique", "site_web": "https://www.comgest.com", "blurb": "Qualité/croissance, discipline célèbre, horizon long.", "fonds": [{"slug": "comgest-growth-europe", "nom": "Comgest Growth Europe", "isin": "IE0004766675", - "strategie": "Actions européennes qualité/croissance", "note_source": "Monthly report derrière porte de profil investisseur"}]}, + "strategie": "Actions européennes qualité/croissance", "note_source": "Monthly + Quarterly Report, page fonds publique (pas de porte constatée en F3)"}]}, ] # Combien de lignes on conserve par snapshot 13F diff --git a/app/main.py b/app/main.py index 203adba..fadbc59 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from pathlib import Path from fastapi import Depends, FastAPI, Header, HTTPException, Request -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import FileResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from sqlalchemy import func, select @@ -13,12 +13,16 @@ from . import scheduler from .collectors.edgar13f import collect_13f +from .collectors.fonds_pdf import collect_boutiques_pdf from .collectors.run import collect_all, seed_gerants, seed_instruments from .config import FAMILIES, PEA_LAB_PRODUCTS from .db import Base, SessionLocal, engine from .engine.moves import compute_moves from .engine.pea_lab import LabSettings, simulate_pea -from .models import AssetManager, Brief, Fund, FundSnapshot, Instrument, Position, PriceDaily +from .models import ( + AssetManager, Brief, Fund, FundSnapshot, Instrument, Position, PriceDaily, + Security, SecurityAlias, SourceDocument, These, +) logging.basicConfig(level=logging.INFO) ROOT = Path(__file__).resolve().parent.parent @@ -103,7 +107,7 @@ def rivieres(): "geant_13f": {"label": "Les géants de la gestion", "blurb": "Ils possèdent un peu de tout le marché — on ne montre que le sommet de l'iceberg (top 10 affiché, top 50 conservé)."}, "boutique": {"label": "Les boutiques françaises de conviction", - "blurb": "Leurs positions mensuelles et le pourquoi de leurs mouvements arrivent en phase F3 (extraction des reportings PDF)."}, + "blurb": "Leurs reportings mensuels sont collectés et archivés ; l'extraction des positions et du pourquoi se fait document par document (pipeline Codex, voir docs/extraction-fonds.md)."}, } @@ -217,6 +221,11 @@ def api_sante(session: Session = Depends(get_session)): .join(FundSnapshot, FundSnapshot.fund_id == Fund.id, isouter=True) .group_by(Fund.id) ).all() + documents_rows = session.execute( + select(Fund.slug, SourceDocument.statut_extraction, func.count(SourceDocument.id)) + .join(SourceDocument, SourceDocument.fund_id == Fund.id) + .group_by(Fund.id, SourceDocument.statut_extraction) + ).all() return { "instruments": [ {"code": code, "source": source, @@ -227,6 +236,10 @@ def api_sante(session: Session = Depends(get_session)): {"fonds": slug, "dernier_portefeuille": last.isoformat() if last else None, "snapshots": count} for slug, last, count in fonds_rows ], + "documents_fonds": [ + {"fonds": slug, "statut": statut, "count": count} + for slug, statut, count in documents_rows + ], } @@ -244,6 +257,131 @@ def api_collecte_fonds(deep: bool = False, session: Session = Depends(get_sessio return {"ok": ok, "echecs": len(report) - ok, "detail": report} +@app.post("/api/collecte-fonds-pdf", dependencies=[Depends(require_token)]) +def api_collecte_fonds_pdf(session: Session = Depends(get_session)): + report = collect_boutiques_pdf(session) + ok = sum(1 for r in report.values() if r["ok"]) + return {"ok": ok, "echecs": len(report) - ok, "detail": report} + + +@app.get("/api/fonds/documents/{document_id}.pdf", name="fonds_document_pdf") +def api_fonds_document_pdf(document_id: int, session: Session = Depends(get_session)): + document = session.get(SourceDocument, document_id) + if document is None or document.contenu is None: + raise HTTPException(status_code=404, detail="document introuvable ou non archivé") + return Response(content=document.contenu, media_type="application/pdf") + + +@app.get("/api/fonds/extraction/en-attente") +def api_fonds_extraction_en_attente(request: Request, session: Session = Depends(get_session)): + """Les documents archivés (reportings boutiques) dont l'extraction reste à faire. + + Voir docs/extraction-fonds.md — c'est le contrat que suit l'agent d'extraction (Codex).""" + documents = session.execute( + select(SourceDocument, Fund) + .join(Fund, Fund.id == SourceDocument.fund_id) + .where(SourceDocument.statut_extraction == "en_attente", SourceDocument.contenu.is_not(None)) + .order_by(SourceDocument.fetched_at) + ).all() + return { + "documents": [ + { + "document_id": doc.id, + "fonds_slug": fund.slug, + "fonds_nom": fund.nom, + "type": doc.type, + "periode": doc.periode, + "url_archive": str(request.url_for("fonds_document_pdf", document_id=doc.id)), + } + for doc, fund in documents + ], + } + + +def _upsert_security_boutique(session: Session, libelle: str, isin: str | None) -> Security: + security = None + if isin: + security = session.scalars(select(Security).where(Security.isin == isin)).first() + if security is None: + security = session.scalars( + select(Security).join(SecurityAlias).where(SecurityAlias.libelle_brut == libelle) + ).first() + if security is None: + security = Security(nom_canonique=libelle, isin=isin or "") + session.add(security) + session.flush() + alias = session.scalars(select(SecurityAlias).where( + SecurityAlias.security_id == security.id, SecurityAlias.libelle_brut == libelle)).first() + if alias is None: + session.add(SecurityAlias(security_id=security.id, libelle_brut=libelle, source="boutique")) + return security + + +@app.post("/api/fonds/extraction", dependencies=[Depends(require_token)]) +def api_fonds_extraction_publier(payload: dict, session: Session = Depends(get_session)): + """Réception du résultat d'extraction d'un document (contrat : docs/extraction-fonds.md).""" + try: + document_id = int(payload["document_id"]) + jour = datetime.date.fromisoformat(payload["date"]) + except (KeyError, ValueError, TypeError) as exc: + raise HTTPException(status_code=422, detail=f"payload invalide : {exc}") + + document = session.get(SourceDocument, document_id) + if document is None: + raise HTTPException(status_code=404, detail="document introuvable") + if document.statut_extraction != "en_attente": + raise HTTPException(status_code=409, detail=f"document déjà {document.statut_extraction}") + + positions = payload.get("positions") or [] + theses = payload.get("theses") or [] + + top10 = [p for p in positions if p.get("rang", 0) <= 10] + if top10: + poids_total = sum(float(p["poids_pct"]) for p in top10) + if not (15 <= poids_total <= 80): + document.statut_extraction = "erreur" + session.commit() + raise HTTPException( + status_code=422, + detail=f"poids du top 10 implausible ({poids_total:.1f} %, attendu entre 15 et 80 %)") + for t in theses: + if not (t.get("citation") or "").strip(): + document.statut_extraction = "erreur" + session.commit() + raise HTTPException(status_code=422, detail="une thèse sans citation source") + + snapshot = session.scalars(select(FundSnapshot).where( + FundSnapshot.fund_id == document.fund_id, FundSnapshot.date == jour)).first() + if snapshot is None: + snapshot = FundSnapshot( + fund_id=document.fund_id, date=jour, source_document_id=document.id, + encours=payload.get("encours"), nb_lignes_publiees=payload.get("nb_lignes_publiees")) + session.add(snapshot) + session.flush() + + for p in positions: + security = _upsert_security_boutique(session, p["libelle"], p.get("isin")) + session.add(Position( + snapshot_id=snapshot.id, security_id=security.id, libelle_brut=p["libelle"], + poids_pct=float(p["poids_pct"]), rang=int(p["rang"]))) + + for t in theses: + security = None + if t.get("valeur"): + security = _upsert_security_boutique(session, t["valeur"], None) + session.add(These( + snapshot_id=snapshot.id, security_id=security.id if security else None, + action=t["action"], texte_fr=t["texte"], citation_source=t["citation"], + confiance=t.get("confiance"))) + + document.statut_extraction = "extrait" + session.commit() + return { + "document_id": document.id, "statut": "extrait", "snapshot_id": snapshot.id, + "positions_inserees": len(positions), "theses_inserees": len(theses), + } + + @app.post("/api/brief", dependencies=[Depends(require_token)]) def api_brief_publier(payload: dict, session: Session = Depends(get_session)): """Publication du brief du matin (utilisé par la routine rédactrice — phase 2).""" diff --git a/app/models.py b/app/models.py index 497a771..98fab6b 100644 --- a/app/models.py +++ b/app/models.py @@ -1,6 +1,6 @@ import datetime -from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, JSON, String, Text, UniqueConstraint +from sqlalchemy import Date, DateTime, Float, ForeignKey, Integer, JSON, LargeBinary, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from .db import Base @@ -78,6 +78,8 @@ class SourceDocument(Base): fetched_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) # en_attente | extrait | erreur — les 13F arrivent déjà structurés : « extrait » statut_extraction: Mapped[str] = mapped_column(String(16), default="en_attente") + # copie archivée du PDF (les URL des boutiques ne sont pas toutes pérennes) — nullable pour les 13F + contenu: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True) class FundSnapshot(Base): diff --git a/app/scheduler.py b/app/scheduler.py index e6d256e..57095ef 100644 --- a/app/scheduler.py +++ b/app/scheduler.py @@ -43,6 +43,16 @@ def _collecte_fonds(): log.info("collecte 13F planifiée : %s ok, %s échecs", ok, len(report) - ok) +def _collecte_fonds_pdf(): + from .collectors.fonds_pdf import collect_boutiques_pdf + from .db import SessionLocal + + with SessionLocal() as session: + report = collect_boutiques_pdf(session) + ok = sum(1 for r in report.values() if r["ok"]) + log.info("collecte PDF boutiques planifiée : %s ok, %s échecs", ok, len(report) - ok) + + def start(): global _scheduler if os.environ.get("RUN_SCHEDULER") != "1": @@ -53,9 +63,11 @@ def start(): _scheduler.add_job(_collecte, CronTrigger(hour=6, minute=15)) # 13F : dépôts EDGAR possibles tous les jours ouvrés, vérification quotidienne _scheduler.add_job(_collecte_fonds, CronTrigger(hour=5, minute=45)) + # Boutiques : les fiches/lettres tombent entre le 5 et le 15 du mois, vérification quotidienne + _scheduler.add_job(_collecte_fonds_pdf, CronTrigger(hour=5, minute=50)) _scheduler.add_job(_bootstrap_pea, next_run_time=datetime.now() + timedelta(seconds=5)) _scheduler.start() - log.info("scheduler démarré (23h05, 06h15 et 05h45 pour les fonds, Europe/Paris)") + log.info("scheduler démarré (23h05, 06h15, 05h45 pour les 13F et 05h50 pour les boutiques, Europe/Paris)") def stop(): diff --git a/docs/extraction-fonds.md b/docs/extraction-fonds.md new file mode 100644 index 0000000..f6939c9 --- /dev/null +++ b/docs/extraction-fonds.md @@ -0,0 +1,103 @@ +# Le contrat d'extraction des fonds — phase F3/F4 + +> Destiné à n'importe quel agent LLM (Codex en premier, éventuellement une +> routine Claude en secours — voir [SPEC-FONDS.md](SPEC-FONDS.md) §5). +> L'app ne fait que collecter et archiver les PDF (`app/collectors/fonds_pdf.py`) ; +> **lire le PDF et en extraire positions/thèses est un chantier séparé, exécuté +> par cet agent, hors de l'app**. + +## 1. Trouver du travail + +``` +GET /api/fonds/extraction/en-attente +``` + +Retourne les documents archivés dont l'extraction n'a pas encore eu lieu : + +```json +{ + "documents": [ + { + "document_id": 42, + "fonds_slug": "moneta-multi-caps", + "fonds_nom": "Moneta Multi Caps", + "type": "fiche", + "periode": "2026-06", + "url_archive": "https:///api/fonds/documents/42.pdf" + } + ] +} +``` + +`type` vaut `fiche` (document avec le tableau de positions) ou `lettre` +(commentaire de gestion — le « pourquoi »). `url_archive` sert **notre** +copie archivée (pas l'URL d'origine, qui peut avoir été écrasée depuis). + +## 2. Lire le PDF + +Télécharger `url_archive` (PDF brut). Extraire : +- pour un `fiche` : les positions listées (nom brut tel qu'écrit dans le + document, poids en %, rang) — le top 10 suffit en v1. +- pour une `lettre` : les passages qui expliquent un achat, un renforcement, + un allègement ou une vente d'une valeur nommée, **avec la citation exacte**. + +## 3. Poster le résultat + +``` +POST /api/fonds/extraction +X-Token: +``` + +```json +{ + "document_id": 42, + "date": "2026-06-30", + "encours": 850000000, + "nb_lignes_publiees": 45, + "positions": [ + {"rang": 1, "libelle": "LVMH", "poids_pct": 5.6, "isin": "FR0000121014"}, + {"rang": 2, "libelle": "TotalEnergies", "poids_pct": 5.1} + ], + "theses": [ + {"valeur": "LVMH", "action": "renforcement", + "texte": "Renforcement sur LVMH après la publication trimestrielle.", + "citation": "Nous avons renforcé notre position sur LVMH ce mois-ci.", + "confiance": 0.85} + ] +} +``` + +Champs : +- `document_id` (obligatoire) — celui reçu à l'étape 1. +- `date` (obligatoire) — la date d'arrêté du portefeuille **telle qu'écrite + dans le document** (pas la date de collecte). +- `encours`, `nb_lignes_publiees` — optionnels. +- `positions` — omis pour un document `lettre` sans tableau de positions. + `isin` optionnel ; `libelle` est le nom brut du document (le rapprochement + vers `securities` se fait côté serveur). +- `theses` — `action` ∈ `achat|renforcement|allegement|vente|commentaire` ; + `citation` **obligatoire et non vide** pour chaque thèse (extrait exact du + document, vérifiable) ; `confiance` optionnel (0–1). + +## 4. Garde-fous (appliqués côté serveur, rejet en 422 sans écriture partielle) + +- Si `positions` est fourni : la somme des poids des positions de rang ≤ 10 + doit être plausible, entre **15 % et 80 %**. Hors de cette plage, le + document reste `en_attente` (probable erreur de lecture — pourcentages en + points au lieu de fraction, tableau tronqué, etc.). +- Chaque thèse doit avoir une `citation` non vide. +- Le document doit être en statut `en_attente` (déjà extrait ou en erreur : + rejeté, republier via une nouvelle collecte si besoin de ré-extraire). + +En cas de succès : le document passe à `statut_extraction = extrait`, un +`FundSnapshot` est créé (ou réutilisé s'il existe déjà à cette date), les +positions et thèses sont insérées, `securities`/`security_aliases` sont +enrichies au fil de l'eau (rapprochement par libellé exact, sans essai de +fuzzy-matching automatique — un libellé jamais vu crée une nouvelle valeur). + +## 5. Fraîcheur et visibilité + +Les échecs et les documents en attente restent visibles sur `/api/sante` +(compteurs par fonds et par statut). Rien n'est jamais republié +silencieusement : un document `erreur` doit être corrigé et reposté +explicitement. diff --git a/tests/test_fonds_pdf.py b/tests/test_fonds_pdf.py new file mode 100644 index 0000000..3042a29 --- /dev/null +++ b/tests/test_fonds_pdf.py @@ -0,0 +1,148 @@ +import unittest +from unittest.mock import patch + +from sqlalchemy import create_engine, select +from sqlalchemy.orm import sessionmaker + +from app.collectors import fonds_pdf +from app.collectors.run import seed_gerants +from app.db import Base +from app.models import AssetManager, Fund, SourceDocument + + +class ParsingTests(unittest.TestCase): + def test_dernier_reporting_independance_pick_latest_by_date_prefix(self): + html = """ + Nov + Juin + Dec + """ + url, periode = fonds_pdf._dernier_reporting_independance(html) + self.assertTrue(url.endswith("260630-reporting-france-small-mid-x-eur-c2-fr-2-1.pdf")) + self.assertEqual(periode, "2026-06") + + def test_dernier_reporting_independance_absent(self): + self.assertEqual(fonds_pdf._dernier_reporting_independance(""), (None, None)) + + def test_lien_document_amiral(self): + html = ( + 'foo docPermalink:"https:\\u002F\\u002Fcommandr-impress-amiral.nx.digital' + '\\u002Fwebsite\\u002Fsextant_pme_FR0011171412_monthly_fr.pdf",url:bc' + ) + url = fonds_pdf._lien_document_amiral(html) + self.assertEqual( + url, "https://commandr-impress-amiral.nx.digital/website/sextant_pme_FR0011171412_monthly_fr.pdf") + + def test_lien_document_amiral_absent(self): + self.assertIsNone(fonds_pdf._lien_document_amiral("")) + + def test_liens_documents_comgest(self): + html = """ + + Monthly Report + + + + """ + liens = fonds_pdf._liens_documents_comgest(html) + self.assertEqual(liens["Monthly Report"], ("https://www.comgest.com/-/media/x/463b.pdf", "2026-06-30")) + + def test_mois_recul(self): + import datetime + + self.assertEqual(fonds_pdf._mois_recul(datetime.date(2026, 1, 15), 0), datetime.date(2026, 1, 1)) + self.assertEqual(fonds_pdf._mois_recul(datetime.date(2026, 1, 15), 1), datetime.date(2025, 12, 1)) + self.assertEqual(fonds_pdf._mois_recul(datetime.date(2026, 1, 15), 2), datetime.date(2025, 11, 1)) + + +class ArchiverDedupTests(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine)() + manager = AssetManager(slug="moneta", nom="Moneta AM", type="boutique") + self.session.add(manager) + self.session.flush() + self.fund = Fund(manager_id=manager.id, slug="moneta-multi-caps", nom="Moneta Multi Caps") + self.session.add(self.fund) + self.session.commit() + + def tearDown(self): + self.session.close() + self.engine.dispose() + + def test_archiver_creates_document_for_new_content(self): + candidat = {"type": "fiche", "url": "https://example.test/a.pdf", "periode": "2026-06", "contenu": b"v1"} + ajoute = fonds_pdf._archiver(self.session, self.fund, candidat) + self.session.commit() + self.assertTrue(ajoute) + docs = self.session.scalars(select(SourceDocument)).all() + self.assertEqual(len(docs), 1) + self.assertEqual(docs[0].statut_extraction, "en_attente") + self.assertEqual(docs[0].contenu, b"v1") + + def test_archiver_skips_unchanged_content(self): + candidat = {"type": "fiche", "url": "https://example.test/a.pdf", "periode": "2026-06", "contenu": b"identique"} + fonds_pdf._archiver(self.session, self.fund, candidat) + self.session.commit() + ajoute = fonds_pdf._archiver(self.session, self.fund, {**candidat, "periode": "2026-07"}) + self.session.commit() + self.assertFalse(ajoute) + self.assertEqual(len(self.session.scalars(select(SourceDocument)).all()), 1) + + def test_archiver_adds_new_document_when_content_changes(self): + fonds_pdf._archiver(self.session, self.fund, {"type": "fiche", "url": "u", "periode": "2026-06", "contenu": b"v1"}) + self.session.commit() + ajoute = fonds_pdf._archiver(self.session, self.fund, {"type": "fiche", "url": "u", "periode": "2026-07", "contenu": b"v2"}) + self.session.commit() + self.assertTrue(ajoute) + self.assertEqual(len(self.session.scalars(select(SourceDocument)).all()), 2) + + +class CollectBoutiquesPdfTests(unittest.TestCase): + def setUp(self): + self.engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(self.engine) + self.session = sessionmaker(bind=self.engine)() + seed_gerants(self.session) + + def tearDown(self): + self.session.close() + self.engine.dispose() + + @patch("app.collectors.fonds_pdf._recipe_comgest", return_value=[]) + @patch("app.collectors.fonds_pdf._recipe_lfde", return_value=[]) + @patch("app.collectors.fonds_pdf._recipe_carmignac", return_value=[]) + @patch("app.collectors.fonds_pdf._recipe_amiral", return_value=[]) + @patch("app.collectors.fonds_pdf._recipe_independance", return_value=[]) + @patch("app.collectors.fonds_pdf._recipe_moneta") + def test_collect_archives_a_new_document_per_fund(self, moneta, *_others): + moneta.return_value = [ + {"type": "fiche", "url": "https://moneta.test/a.pdf", "periode": "2026-06", "contenu": b"xyz"}] + report = fonds_pdf.collect_boutiques_pdf(self.session) + self.assertTrue(report["moneta-multi-caps"]["ok"]) + self.assertEqual(report["moneta-multi-caps"]["documents_ajoutes"], 1) + fund = self.session.scalars(select(Fund).where(Fund.slug == "moneta-multi-caps")).first() + docs = self.session.scalars(select(SourceDocument).where(SourceDocument.fund_id == fund.id)).all() + self.assertEqual(len(docs), 1) + + @patch("app.collectors.fonds_pdf._recipe_comgest", side_effect=RuntimeError("gate HTTP 403")) + @patch("app.collectors.fonds_pdf._recipe_lfde", return_value=[]) + @patch("app.collectors.fonds_pdf._recipe_carmignac", return_value=[]) + @patch("app.collectors.fonds_pdf._recipe_amiral", return_value=[]) + @patch("app.collectors.fonds_pdf._recipe_independance", return_value=[]) + @patch("app.collectors.fonds_pdf._recipe_moneta", return_value=[]) + def test_one_maison_failure_does_not_block_others(self, *_mocks): + report = fonds_pdf.collect_boutiques_pdf(self.session) + self.assertFalse(report["comgest-growth-europe"]["ok"]) + self.assertTrue(report["moneta-multi-caps"]["ok"]) + + +if __name__ == "__main__": + unittest.main()