Spaces:
Sleeping
Sleeping
feat(prospection): public holdings extraction gated behind human confirmation
Browse files- .gitignore +1 -0
- prospection/sources/geco_funds.py +14 -0
- prospection/sources/holdings.py +88 -0
- prospection/sources/ticker_aliases.py +24 -0
- tests/fixtures/reporting_mensuel.pdf +76 -0
- tests/test_prospection_holdings.py +73 -0
.gitignore
CHANGED
|
@@ -6,6 +6,7 @@ __pycache__/
|
|
| 6 |
.venv/
|
| 7 |
*.mp4
|
| 8 |
*.pdf
|
|
|
|
| 9 |
.langgraph_api/
|
| 10 |
.worktrees/
|
| 11 |
.superpowers/sdd/
|
|
|
|
| 6 |
.venv/
|
| 7 |
*.mp4
|
| 8 |
*.pdf
|
| 9 |
+
!tests/fixtures/*.pdf
|
| 10 |
.langgraph_api/
|
| 11 |
.worktrees/
|
| 12 |
.superpowers/sdd/
|
prospection/sources/geco_funds.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""prospection/sources/geco_funds.py — exposition actions déclarée.
|
| 2 |
+
|
| 3 |
+
Repli documenté : l'export GECO par SGP n'étant pas confirmé comme
|
| 4 |
+
automatisable, la poche actions se lit dans `libelle_type_activite` du CSV
|
| 5 |
+
AMF, déjà en base.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
_EQUITY_LABELS = ("action", "diversifi", "internationa", "monde", "amérique", "amerique")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def has_equity_funds(account: dict) -> bool:
|
| 13 |
+
label = (account.get("type_activite") or "").lower()
|
| 14 |
+
return any(marker in label for marker in _EQUITY_LABELS)
|
prospection/sources/holdings.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""prospection/sources/holdings.py — positions publiées et niveau de preuve.
|
| 2 |
+
|
| 3 |
+
Rien ne passe en `verified` sans confirmation humaine explicite : le
|
| 4 |
+
validateur de rédaction ne voit jamais une ligne non confirmée.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
from prospection.sources.ticker_aliases import ALIASES
|
| 13 |
+
from prospection.store import prospects_db as db
|
| 14 |
+
|
| 15 |
+
REPORT_PATHS = ("/reporting", "/nos-fonds", "/documentation", "/fonds", "/publications")
|
| 16 |
+
|
| 17 |
+
_LINE_RE = re.compile(r"^(?P<label>[A-Za-zÀ-ÿ0-9 .&'\-]{3,60}?)\s+(?P<weight>\d{1,2}[.,]\d)\s*%$")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def discover_report_urls(account: dict, client=None) -> list[str]:
|
| 21 |
+
site = (account.get("site") or "").rstrip("/")
|
| 22 |
+
if not site:
|
| 23 |
+
return []
|
| 24 |
+
if client is None:
|
| 25 |
+
import httpx
|
| 26 |
+
|
| 27 |
+
client = httpx.Client(timeout=20.0)
|
| 28 |
+
|
| 29 |
+
found = []
|
| 30 |
+
for path in REPORT_PATHS:
|
| 31 |
+
url = f"{site}{path}"
|
| 32 |
+
try:
|
| 33 |
+
response = client.get(url, headers={"User-Agent": "amplegest-prospection/1.0"},
|
| 34 |
+
follow_redirects=True)
|
| 35 |
+
response.raise_for_status()
|
| 36 |
+
except Exception:
|
| 37 |
+
continue
|
| 38 |
+
found.append(url)
|
| 39 |
+
return found
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def extract_holdings(pdf_path: Path, source_url: str) -> list[dict]:
|
| 43 |
+
import pdfplumber
|
| 44 |
+
|
| 45 |
+
rows: list[dict] = []
|
| 46 |
+
with pdfplumber.open(str(pdf_path)) as pdf:
|
| 47 |
+
lines = [l.strip() for page in pdf.pages
|
| 48 |
+
for l in (page.extract_text() or "").splitlines()]
|
| 49 |
+
|
| 50 |
+
for line in lines:
|
| 51 |
+
match = _LINE_RE.match(line)
|
| 52 |
+
if not match:
|
| 53 |
+
continue
|
| 54 |
+
label = match.group("label").strip()
|
| 55 |
+
rows.append({
|
| 56 |
+
"fund_name": None,
|
| 57 |
+
"raw_label": label,
|
| 58 |
+
"ticker": resolve_ticker(label),
|
| 59 |
+
"weight_pct": float(match.group("weight").replace(",", ".")),
|
| 60 |
+
"as_of": None,
|
| 61 |
+
"source_url": source_url,
|
| 62 |
+
"confidence": "probable",
|
| 63 |
+
"retrieved_at": datetime.utcnow().isoformat(timespec="seconds"),
|
| 64 |
+
"confirmed_at": None,
|
| 65 |
+
})
|
| 66 |
+
return rows
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def resolve_ticker(label: str) -> str | None:
|
| 70 |
+
lowered = label.lower()
|
| 71 |
+
for alias, ticker in ALIASES.items():
|
| 72 |
+
if alias in lowered:
|
| 73 |
+
return ticker
|
| 74 |
+
return None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def evidence_level(account_id: int) -> str:
|
| 78 |
+
confirmed = [h for h in db.confirmed_holdings(account_id) if h.get("ticker")]
|
| 79 |
+
if confirmed:
|
| 80 |
+
return "holdings_verified"
|
| 81 |
+
account = next((a for a in db.list_accounts() if a["id"] == account_id), None)
|
| 82 |
+
with db.connect() as conn:
|
| 83 |
+
any_holding = conn.execute(
|
| 84 |
+
"SELECT 1 FROM holdings WHERE account_id=? LIMIT 1", (account_id,)
|
| 85 |
+
).fetchone()
|
| 86 |
+
if any_holding or (account and account.get("tier") in (1, 2)):
|
| 87 |
+
return "universe_inferred"
|
| 88 |
+
return "no_evidence"
|
prospection/sources/ticker_aliases.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""prospection/sources/ticker_aliases.py — table d'alias curée, libellé -> ticker EDGAR.
|
| 2 |
+
|
| 3 |
+
Volontairement petite et manuelle. Une correspondance devinée produirait une
|
| 4 |
+
personnalisation fausse dans un email : mieux vaut ne rien dire.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
ALIASES: dict[str, str] = {
|
| 9 |
+
"apple": "AAPL",
|
| 10 |
+
"advanced micro devices": "AMD",
|
| 11 |
+
"alphabet": "GOOGL",
|
| 12 |
+
"google": "GOOGL",
|
| 13 |
+
"microsoft": "MSFT",
|
| 14 |
+
"nvidia": "NVDA",
|
| 15 |
+
"tesla": "TSLA",
|
| 16 |
+
"amazon": "AMZN",
|
| 17 |
+
"meta platforms": "META",
|
| 18 |
+
"broadcom": "AVGO",
|
| 19 |
+
"eli lilly": "LLY",
|
| 20 |
+
"visa": "V",
|
| 21 |
+
"netflix": "NFLX",
|
| 22 |
+
"salesforce": "CRM",
|
| 23 |
+
"adobe": "ADBE",
|
| 24 |
+
}
|
tests/fixtures/reporting_mensuel.pdf
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
%PDF-1.3
|
| 2 |
+
%���
|
| 3 |
+
1 0 obj
|
| 4 |
+
<<
|
| 5 |
+
/Count 1
|
| 6 |
+
/Kids [3 0 R]
|
| 7 |
+
/MediaBox [0 0 595.28 841.89]
|
| 8 |
+
/Type /Pages
|
| 9 |
+
>>
|
| 10 |
+
endobj
|
| 11 |
+
2 0 obj
|
| 12 |
+
<<
|
| 13 |
+
/OpenAction [3 0 R /FitH null]
|
| 14 |
+
/PageLayout /OneColumn
|
| 15 |
+
/Pages 1 0 R
|
| 16 |
+
/Type /Catalog
|
| 17 |
+
>>
|
| 18 |
+
endobj
|
| 19 |
+
3 0 obj
|
| 20 |
+
<<
|
| 21 |
+
/Contents 4 0 R
|
| 22 |
+
/Parent 1 0 R
|
| 23 |
+
/Resources 6 0 R
|
| 24 |
+
/Type /Page
|
| 25 |
+
>>
|
| 26 |
+
endobj
|
| 27 |
+
4 0 obj
|
| 28 |
+
<<
|
| 29 |
+
/Filter /FlateDecode
|
| 30 |
+
/Length 217
|
| 31 |
+
>>
|
| 32 |
+
stream
|
| 33 |
+
x�m�Ak�@��{~�w)�`�Y]us�$,�ĥ�װa�V#���
|
| 34 |
+
%=
|
| 35 |
+
q��7v)S�
|
| 36 |
+
endstream
|
| 37 |
+
endobj
|
| 38 |
+
5 0 obj
|
| 39 |
+
<<
|
| 40 |
+
/BaseFont /Helvetica
|
| 41 |
+
/Encoding /WinAnsiEncoding
|
| 42 |
+
/Subtype /Type1
|
| 43 |
+
/Type /Font
|
| 44 |
+
>>
|
| 45 |
+
endobj
|
| 46 |
+
6 0 obj
|
| 47 |
+
<<
|
| 48 |
+
/Font <</F1 5 0 R>>
|
| 49 |
+
/ProcSet [/PDF /Text /ImageB /ImageC /ImageI]
|
| 50 |
+
>>
|
| 51 |
+
endobj
|
| 52 |
+
7 0 obj
|
| 53 |
+
<<
|
| 54 |
+
/CreationDate (D:20260829102135Z)
|
| 55 |
+
>>
|
| 56 |
+
endobj
|
| 57 |
+
xref
|
| 58 |
+
0 8
|
| 59 |
+
0000000000 65535 f
|
| 60 |
+
0000000015 00000 n
|
| 61 |
+
0000000102 00000 n
|
| 62 |
+
0000000205 00000 n
|
| 63 |
+
0000000285 00000 n
|
| 64 |
+
0000000574 00000 n
|
| 65 |
+
0000000671 00000 n
|
| 66 |
+
0000000758 00000 n
|
| 67 |
+
trailer
|
| 68 |
+
<<
|
| 69 |
+
/Size 8
|
| 70 |
+
/Root 2 0 R
|
| 71 |
+
/Info 7 0 R
|
| 72 |
+
/ID [<C91A12C767E0F45E98F95EC734815254><C91A12C767E0F45E98F95EC734815254>]
|
| 73 |
+
>>
|
| 74 |
+
startxref
|
| 75 |
+
813
|
| 76 |
+
%%EOF
|
tests/test_prospection_holdings.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""tests/test_prospection_holdings.py — positions publiques et niveau de preuve."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from prospection.sources import geco_funds, holdings
|
| 9 |
+
from prospection.store import prospects_db as db
|
| 10 |
+
|
| 11 |
+
FIXTURE = Path(__file__).parent / "fixtures" / "reporting_mensuel.pdf"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@pytest.fixture(autouse=True)
|
| 15 |
+
def tmp_db(tmp_path, monkeypatch):
|
| 16 |
+
monkeypatch.setattr(db, "DB_PATH", tmp_path / "prospects.db")
|
| 17 |
+
monkeypatch.setattr(holdings, "DB_PATH", tmp_path / "prospects.db", raising=False)
|
| 18 |
+
db.init_db()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _account_id() -> int:
|
| 22 |
+
return db.upsert_account({"no_amf": "GP-19990001", "nom": "Boutique Gestion SAS",
|
| 23 |
+
"site": "https://boutique-gestion.fr", "statut": "vivant",
|
| 24 |
+
"independante": 1})
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def test_has_equity_funds_reads_the_amf_activity_label():
|
| 28 |
+
assert geco_funds.has_equity_funds({"type_activite": "Actions"}) is True
|
| 29 |
+
assert geco_funds.has_equity_funds({"type_activite": "Monétaire"}) is False
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_extract_holdings_reads_lines_and_weights():
|
| 33 |
+
rows = holdings.extract_holdings(FIXTURE, "https://x.fr/r.pdf")
|
| 34 |
+
labels = {r["raw_label"]: r["weight_pct"] for r in rows}
|
| 35 |
+
assert labels["Microsoft Corp"] == 5.4
|
| 36 |
+
assert labels["NVIDIA Corp"] == 4.8
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def test_extract_holdings_never_marks_a_row_verified():
|
| 40 |
+
rows = holdings.extract_holdings(FIXTURE, "https://x.fr/r.pdf")
|
| 41 |
+
assert rows
|
| 42 |
+
assert all(r["confidence"] == "probable" for r in rows)
|
| 43 |
+
assert all(r["confirmed_at"] is None for r in rows)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_resolve_ticker_maps_known_labels_and_gives_up_otherwise():
|
| 47 |
+
assert holdings.resolve_ticker("Microsoft Corp") == "MSFT"
|
| 48 |
+
assert holdings.resolve_ticker("Air Liquide SA") is None
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_evidence_level_requires_a_confirmed_edgar_holding():
|
| 52 |
+
account_id = _account_id()
|
| 53 |
+
holding_id = db.upsert_holding({"account_id": account_id, "fund_name": "Fonds A",
|
| 54 |
+
"raw_label": "Microsoft Corp", "ticker": "MSFT",
|
| 55 |
+
"weight_pct": 5.4, "as_of": "2026-07-31",
|
| 56 |
+
"source_url": "https://x.fr/r.pdf",
|
| 57 |
+
"confidence": "probable",
|
| 58 |
+
"retrieved_at": "2026-08-01T00:00:00"})
|
| 59 |
+
assert holdings.evidence_level(account_id) == "universe_inferred"
|
| 60 |
+
db.confirm_holding(holding_id, "2026-08-29T09:00:00")
|
| 61 |
+
assert holdings.evidence_level(account_id) == "holdings_verified"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_evidence_level_is_no_evidence_without_any_holding():
|
| 65 |
+
assert holdings.evidence_level(_account_id()) == "no_evidence"
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_discover_report_urls_returns_empty_when_every_path_is_missing():
|
| 69 |
+
class _Client:
|
| 70 |
+
def get(self, url, headers=None, follow_redirects=True):
|
| 71 |
+
raise RuntimeError("404")
|
| 72 |
+
|
| 73 |
+
assert holdings.discover_report_urls({"site": "https://x.fr"}, client=_Client()) == []
|