brvm 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- brvm-0.1.0/PKG-INFO +135 -0
- brvm-0.1.0/README.md +113 -0
- brvm-0.1.0/brvm/__init__.py +31 -0
- brvm-0.1.0/brvm/cache.py +223 -0
- brvm-0.1.0/brvm/client.py +134 -0
- brvm-0.1.0/brvm/core.py +244 -0
- brvm-0.1.0/brvm/exceptions.py +14 -0
- brvm-0.1.0/brvm/scrapers/__init__.py +1 -0
- brvm-0.1.0/brvm/scrapers/boc.py +75 -0
- brvm-0.1.0/brvm/scrapers/brvm_official.py +141 -0
- brvm-0.1.0/brvm/scrapers/dividends.py +67 -0
- brvm-0.1.0/brvm/scrapers/news.py +54 -0
- brvm-0.1.0/brvm/scrapers/richbourse.py +141 -0
- brvm-0.1.0/brvm/scrapers/sika.py +272 -0
- brvm-0.1.0/brvm/scrapers/umoatitres.py +185 -0
- brvm-0.1.0/brvm.egg-info/PKG-INFO +135 -0
- brvm-0.1.0/brvm.egg-info/SOURCES.txt +31 -0
- brvm-0.1.0/brvm.egg-info/dependency_links.txt +1 -0
- brvm-0.1.0/brvm.egg-info/requires.txt +14 -0
- brvm-0.1.0/brvm.egg-info/top_level.txt +2 -0
- brvm-0.1.0/brvm_env/bin/dumppdf.py +468 -0
- brvm-0.1.0/brvm_env/bin/pdf2txt.py +324 -0
- brvm-0.1.0/pyproject.toml +55 -0
- brvm-0.1.0/setup.cfg +4 -0
- brvm-0.1.0/tests/test_boc.py +39 -0
- brvm-0.1.0/tests/test_brvm_official.py +44 -0
- brvm-0.1.0/tests/test_client.py +56 -0
- brvm-0.1.0/tests/test_core.py +109 -0
- brvm-0.1.0/tests/test_dividends.py +42 -0
- brvm-0.1.0/tests/test_news.py +54 -0
- brvm-0.1.0/tests/test_richbourse.py +55 -0
- brvm-0.1.0/tests/test_sika.py +82 -0
- brvm-0.1.0/tests/test_umoatitres.py +151 -0
brvm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: brvm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Bibliothèque Python open-source pour scraper et analyser les données de la BRVM
|
|
5
|
+
Author-email: Naofal <votre.email@example.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: httpx>=0.25.0
|
|
10
|
+
Requires-Dist: beautifulsoup4>=4.12.0
|
|
11
|
+
Requires-Dist: lxml>=5.0.0
|
|
12
|
+
Requires-Dist: html5lib>=1.1
|
|
13
|
+
Requires-Dist: pydantic>=2.4.0
|
|
14
|
+
Requires-Dist: pandas>=2.0.0
|
|
15
|
+
Requires-Dist: tenacity>=8.2.0
|
|
16
|
+
Requires-Dist: pdfplumber>=0.11.0
|
|
17
|
+
Provides-Extra: dev
|
|
18
|
+
Requires-Dist: pytest>=7.4.0; extra == "dev"
|
|
19
|
+
Requires-Dist: pytest-cov>=4.1.0; extra == "dev"
|
|
20
|
+
Requires-Dist: mypy>=1.8.0; extra == "dev"
|
|
21
|
+
Requires-Dist: ruff>=0.3.0; extra == "dev"
|
|
22
|
+
|
|
23
|
+
# BRVM Python Package 📈��
|
|
24
|
+
|
|
25
|
+
[](https://www.python.org/)
|
|
26
|
+
[](https://opensource.org/licenses/MIT)
|
|
27
|
+
|
|
28
|
+
Une bibliothèque Python open-source robuste pour scraper et analyser les données de la **Bourse Régionale des Valeurs Mobilières (BRVM)**.
|
|
29
|
+
Conçue pour les analystes financiers, les développeurs et les data scientists, elle s'inspire du format `yfinance` en renvoyant directement des objets `pandas.DataFrame` propres et standardisés.
|
|
30
|
+
|
|
31
|
+
Les données sont extraites et croisées à partir de multiples sources fiables (Site officiel BRVM, Sika Finance, RichBourse, UMOA-Titres) avec un système de fallback automatique en cas d'indisponibilité d'une source.
|
|
32
|
+
|
|
33
|
+
## 🔥 Fonctionnalités & Univers Couvert
|
|
34
|
+
|
|
35
|
+
- **Actions (Equities) & Indices** : Cotations en temps réel (EOD), historiques complets OHLCV, liste des tickers validés.
|
|
36
|
+
- **Obligations (Bonds)** : Prise en charge des obligations cotées à la BRVM.
|
|
37
|
+
- **Profils & Fondamentaux** : Informations d'entreprises, ratios financiers annuels (PER, ROE, Rendement de Dividende, Capitalisation).
|
|
38
|
+
- **Corporate Actions** : Historiques des dividendes, actualités des entreprises (Sika Finance), extraction du Bulletin Officiel de la Cote (BOC).
|
|
39
|
+
- **Marché Primaire (UMOA-Titres)** : Courbe des taux souverains, historique et calendrier des adjudications (Bons et Obligations du Trésor).
|
|
40
|
+
- **⚡ Cache SQLite Local** : Accélération des requêtes redondantes via une base de données locale respectueuse des API distantes.
|
|
41
|
+
|
|
42
|
+
## 📦 Installation
|
|
43
|
+
|
|
44
|
+
Pour installer la version de développement directement depuis GitHub :
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git clone https://github.com/Naofal03/brvm.git
|
|
48
|
+
cd brvm
|
|
49
|
+
pip install -e "."
|
|
50
|
+
```
|
|
51
|
+
*(Le package sera bientôt disponible sur PyPI via `pip install brvm`)*
|
|
52
|
+
|
|
53
|
+
## 🚀 Utilisation Courante (API Complète)
|
|
54
|
+
|
|
55
|
+
Toutes les méthodes exposées renvoient soit des dictionnaires Python, soit des `pandas.DataFrame` prêts à l'analyse.
|
|
56
|
+
|
|
57
|
+
### 1. Actions, Indices et Obligations
|
|
58
|
+
```python
|
|
59
|
+
import brvm
|
|
60
|
+
|
|
61
|
+
# a. Lister tous les instruments (Actions, Indices, Obligations)
|
|
62
|
+
df_tickers = brvm.get_tickers()
|
|
63
|
+
print(df_tickers.head())
|
|
64
|
+
|
|
65
|
+
# b. Obtenir la dernière cotation officielle détaillée EOD d'une action
|
|
66
|
+
cours = brvm.get_quote("SNTS")
|
|
67
|
+
print(f"Sonatel - Clôture : {cours['close']} XOF, Volume : {cours['volume']}")
|
|
68
|
+
|
|
69
|
+
# c. Récupérer l'historique complet OHLCV (Open, High, Low, Close, Volume)
|
|
70
|
+
df_historique = brvm.get_history("SNTS", start="2023-01-01", end="2024-01-01")
|
|
71
|
+
print(df_historique)
|
|
72
|
+
|
|
73
|
+
# d. Récupérer l'historique d'un indice (ex: BRVM Composite)
|
|
74
|
+
df_index = brvm.get_index_history("BRVM-COMP")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 2. Fondamentaux & Corporate Actions
|
|
78
|
+
```python
|
|
79
|
+
# a. Profil de l'entreprise (Secteur, activité, capitalisation...)
|
|
80
|
+
profil = brvm.get_company_info("SNTS")
|
|
81
|
+
|
|
82
|
+
# b. Historique des ratios financiers (PER, ROE, Rendements)
|
|
83
|
+
df_fondamentaux = brvm.get_fundamentals_history("SNTS")
|
|
84
|
+
|
|
85
|
+
# c. Historique complet des dividendes
|
|
86
|
+
df_div = brvm.get_dividends("SNTS")
|
|
87
|
+
|
|
88
|
+
# d. Actualités et articles de presse (via Sika Finance)
|
|
89
|
+
news = brvm.get_news("SNTS")
|
|
90
|
+
print(news[0]['title'], "-", news[0]['url'])
|
|
91
|
+
|
|
92
|
+
# e. Récupérer le Bulletin Officiel de la Cote (BOC) du jour
|
|
93
|
+
df_boc = brvm.get_latest_boc()
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 3. Marché Primaire (UMOA-Titres)
|
|
97
|
+
```python
|
|
98
|
+
# a. Récupérer la dernière Courbe des Taux (matrice des taux Zéro-Coupon)
|
|
99
|
+
courbe_taux = brvm.get_yield_curve()
|
|
100
|
+
|
|
101
|
+
# b. Consulter les résultats récents d'adjudications du marché primaire
|
|
102
|
+
historique_emissions = brvm.get_adjudications_history(limit=10)
|
|
103
|
+
|
|
104
|
+
# c. Consulter le calendrier des prochaines émissions de l'UEMOA
|
|
105
|
+
calendrier = brvm.get_adjudications_calendar(limit=5)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## 🛠️ Format des Données Temporelles (Standard OHLCV)
|
|
109
|
+
La méthode `get_history` obéit à la standardisation suivante :
|
|
110
|
+
- **Absence de Nulls intempestifs** : Les NaN pour Open/High/Low n'existent que pour les jours où le `trading_status` est "NC" (Non Coté).
|
|
111
|
+
- **Forward Fill** : Les jours sans transaction voient leur cours précédent reporté sur la valeur de clôture (`close`), le volume tombant à `0`.
|
|
112
|
+
- Colonnes fixes : `[date, open, high, low, close, adj_close, volume, value, trading_status]`.
|
|
113
|
+
|
|
114
|
+
## ⚙️ Gestion du Cache Local
|
|
115
|
+
Par défaut, le package utilise une base de données cache `~/.brvm_cache.db` pour éviter d'inonder les serveurs de la BRVM.
|
|
116
|
+
- Les séries temporelles (EOD) expirent après **12 heures**.
|
|
117
|
+
- Les données structurelles (Tickers, Fondamentaux) expirent après **7 jours**.
|
|
118
|
+
|
|
119
|
+
Si vous souhaitez forcer un appel réseau frais :
|
|
120
|
+
```python
|
|
121
|
+
brvm.disable_cache()
|
|
122
|
+
brvm.get_history("SNTS") # Requête réseau obligée
|
|
123
|
+
brvm.enable_cache() # Réactivation du système
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## 🧪 Tests et Contribution
|
|
127
|
+
Le package demande une stricte couverture de tests (>80%). Pour lancer les tests en environnement de dev :
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
pip install -e ".[dev]"
|
|
131
|
+
pytest --cov=brvm
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## 📄 Licence
|
|
135
|
+
Ce projet est mis à disposition sous licence Open-Source MIT.
|
brvm-0.1.0/README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# BRVM Python Package 📈��
|
|
2
|
+
|
|
3
|
+
[](https://www.python.org/)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
|
|
6
|
+
Une bibliothèque Python open-source robuste pour scraper et analyser les données de la **Bourse Régionale des Valeurs Mobilières (BRVM)**.
|
|
7
|
+
Conçue pour les analystes financiers, les développeurs et les data scientists, elle s'inspire du format `yfinance` en renvoyant directement des objets `pandas.DataFrame` propres et standardisés.
|
|
8
|
+
|
|
9
|
+
Les données sont extraites et croisées à partir de multiples sources fiables (Site officiel BRVM, Sika Finance, RichBourse, UMOA-Titres) avec un système de fallback automatique en cas d'indisponibilité d'une source.
|
|
10
|
+
|
|
11
|
+
## 🔥 Fonctionnalités & Univers Couvert
|
|
12
|
+
|
|
13
|
+
- **Actions (Equities) & Indices** : Cotations en temps réel (EOD), historiques complets OHLCV, liste des tickers validés.
|
|
14
|
+
- **Obligations (Bonds)** : Prise en charge des obligations cotées à la BRVM.
|
|
15
|
+
- **Profils & Fondamentaux** : Informations d'entreprises, ratios financiers annuels (PER, ROE, Rendement de Dividende, Capitalisation).
|
|
16
|
+
- **Corporate Actions** : Historiques des dividendes, actualités des entreprises (Sika Finance), extraction du Bulletin Officiel de la Cote (BOC).
|
|
17
|
+
- **Marché Primaire (UMOA-Titres)** : Courbe des taux souverains, historique et calendrier des adjudications (Bons et Obligations du Trésor).
|
|
18
|
+
- **⚡ Cache SQLite Local** : Accélération des requêtes redondantes via une base de données locale respectueuse des API distantes.
|
|
19
|
+
|
|
20
|
+
## 📦 Installation
|
|
21
|
+
|
|
22
|
+
Pour installer la version de développement directement depuis GitHub :
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/Naofal03/brvm.git
|
|
26
|
+
cd brvm
|
|
27
|
+
pip install -e "."
|
|
28
|
+
```
|
|
29
|
+
*(Le package sera bientôt disponible sur PyPI via `pip install brvm`)*
|
|
30
|
+
|
|
31
|
+
## 🚀 Utilisation Courante (API Complète)
|
|
32
|
+
|
|
33
|
+
Toutes les méthodes exposées renvoient soit des dictionnaires Python, soit des `pandas.DataFrame` prêts à l'analyse.
|
|
34
|
+
|
|
35
|
+
### 1. Actions, Indices et Obligations
|
|
36
|
+
```python
|
|
37
|
+
import brvm
|
|
38
|
+
|
|
39
|
+
# a. Lister tous les instruments (Actions, Indices, Obligations)
|
|
40
|
+
df_tickers = brvm.get_tickers()
|
|
41
|
+
print(df_tickers.head())
|
|
42
|
+
|
|
43
|
+
# b. Obtenir la dernière cotation officielle détaillée EOD d'une action
|
|
44
|
+
cours = brvm.get_quote("SNTS")
|
|
45
|
+
print(f"Sonatel - Clôture : {cours['close']} XOF, Volume : {cours['volume']}")
|
|
46
|
+
|
|
47
|
+
# c. Récupérer l'historique complet OHLCV (Open, High, Low, Close, Volume)
|
|
48
|
+
df_historique = brvm.get_history("SNTS", start="2023-01-01", end="2024-01-01")
|
|
49
|
+
print(df_historique)
|
|
50
|
+
|
|
51
|
+
# d. Récupérer l'historique d'un indice (ex: BRVM Composite)
|
|
52
|
+
df_index = brvm.get_index_history("BRVM-COMP")
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. Fondamentaux & Corporate Actions
|
|
56
|
+
```python
|
|
57
|
+
# a. Profil de l'entreprise (Secteur, activité, capitalisation...)
|
|
58
|
+
profil = brvm.get_company_info("SNTS")
|
|
59
|
+
|
|
60
|
+
# b. Historique des ratios financiers (PER, ROE, Rendements)
|
|
61
|
+
df_fondamentaux = brvm.get_fundamentals_history("SNTS")
|
|
62
|
+
|
|
63
|
+
# c. Historique complet des dividendes
|
|
64
|
+
df_div = brvm.get_dividends("SNTS")
|
|
65
|
+
|
|
66
|
+
# d. Actualités et articles de presse (via Sika Finance)
|
|
67
|
+
news = brvm.get_news("SNTS")
|
|
68
|
+
print(news[0]['title'], "-", news[0]['url'])
|
|
69
|
+
|
|
70
|
+
# e. Récupérer le Bulletin Officiel de la Cote (BOC) du jour
|
|
71
|
+
df_boc = brvm.get_latest_boc()
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### 3. Marché Primaire (UMOA-Titres)
|
|
75
|
+
```python
|
|
76
|
+
# a. Récupérer la dernière Courbe des Taux (matrice des taux Zéro-Coupon)
|
|
77
|
+
courbe_taux = brvm.get_yield_curve()
|
|
78
|
+
|
|
79
|
+
# b. Consulter les résultats récents d'adjudications du marché primaire
|
|
80
|
+
historique_emissions = brvm.get_adjudications_history(limit=10)
|
|
81
|
+
|
|
82
|
+
# c. Consulter le calendrier des prochaines émissions de l'UEMOA
|
|
83
|
+
calendrier = brvm.get_adjudications_calendar(limit=5)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## 🛠️ Format des Données Temporelles (Standard OHLCV)
|
|
87
|
+
La méthode `get_history` obéit à la standardisation suivante :
|
|
88
|
+
- **Absence de Nulls intempestifs** : Les NaN pour Open/High/Low n'existent que pour les jours où le `trading_status` est "NC" (Non Coté).
|
|
89
|
+
- **Forward Fill** : Les jours sans transaction voient leur cours précédent reporté sur la valeur de clôture (`close`), le volume tombant à `0`.
|
|
90
|
+
- Colonnes fixes : `[date, open, high, low, close, adj_close, volume, value, trading_status]`.
|
|
91
|
+
|
|
92
|
+
## ⚙️ Gestion du Cache Local
|
|
93
|
+
Par défaut, le package utilise une base de données cache `~/.brvm_cache.db` pour éviter d'inonder les serveurs de la BRVM.
|
|
94
|
+
- Les séries temporelles (EOD) expirent après **12 heures**.
|
|
95
|
+
- Les données structurelles (Tickers, Fondamentaux) expirent après **7 jours**.
|
|
96
|
+
|
|
97
|
+
Si vous souhaitez forcer un appel réseau frais :
|
|
98
|
+
```python
|
|
99
|
+
brvm.disable_cache()
|
|
100
|
+
brvm.get_history("SNTS") # Requête réseau obligée
|
|
101
|
+
brvm.enable_cache() # Réactivation du système
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 🧪 Tests et Contribution
|
|
105
|
+
Le package demande une stricte couverture de tests (>80%). Pour lancer les tests en environnement de dev :
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
pip install -e ".[dev]"
|
|
109
|
+
pytest --cov=brvm
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## 📄 Licence
|
|
113
|
+
Ce projet est mis à disposition sous licence Open-Source MIT.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Public package exports for the BRVM Python library."""
|
|
2
|
+
|
|
3
|
+
from .core import (
|
|
4
|
+
get_company_info,
|
|
5
|
+
get_dividends,
|
|
6
|
+
get_fundamentals_history,
|
|
7
|
+
get_history,
|
|
8
|
+
get_index_history,
|
|
9
|
+
get_quote,
|
|
10
|
+
get_tickers,
|
|
11
|
+
)
|
|
12
|
+
from .exceptions import (
|
|
13
|
+
BRVMError,
|
|
14
|
+
DataValidationError,
|
|
15
|
+
InvalidSymbolError,
|
|
16
|
+
SourceUnavailableError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"BRVMError",
|
|
21
|
+
"DataValidationError",
|
|
22
|
+
"InvalidSymbolError",
|
|
23
|
+
"SourceUnavailableError",
|
|
24
|
+
"get_company_info",
|
|
25
|
+
"get_dividends",
|
|
26
|
+
"get_fundamentals_history",
|
|
27
|
+
"get_history",
|
|
28
|
+
"get_index_history",
|
|
29
|
+
"get_quote",
|
|
30
|
+
"get_tickers",
|
|
31
|
+
]
|
brvm-0.1.0/brvm/cache.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sqlite3
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Options par défaut
|
|
12
|
+
DEFAULT_CACHE_DIR = Path.home() / ".brvm_cache.db"
|
|
13
|
+
TTL_EOD = timedelta(hours=12)
|
|
14
|
+
TTL_STATIC = timedelta(days=7)
|
|
15
|
+
|
|
16
|
+
_USE_CACHE = True
|
|
17
|
+
|
|
18
|
+
def disable_cache():
|
|
19
|
+
"""Désactive le cache de manière globale."""
|
|
20
|
+
global _USE_CACHE
|
|
21
|
+
_USE_CACHE = False
|
|
22
|
+
logger.info("BRVM cache disabled.")
|
|
23
|
+
|
|
24
|
+
def enable_cache():
|
|
25
|
+
"""Réactive le cache de manière globale."""
|
|
26
|
+
global _USE_CACHE
|
|
27
|
+
_USE_CACHE = True
|
|
28
|
+
logger.info("BRVM cache enabled.")
|
|
29
|
+
|
|
30
|
+
def is_cache_enabled():
|
|
31
|
+
"""Vérifie si le cache est actif."""
|
|
32
|
+
return _USE_CACHE
|
|
33
|
+
|
|
34
|
+
def get_db_path() -> Path:
|
|
35
|
+
"""Récupère le chemin du cache."""
|
|
36
|
+
# Override via var env
|
|
37
|
+
env_path = os.environ.get("BRVM_CACHE_DIR")
|
|
38
|
+
if env_path:
|
|
39
|
+
return Path(env_path)
|
|
40
|
+
return DEFAULT_CACHE_DIR
|
|
41
|
+
|
|
42
|
+
def _get_connection() -> sqlite3.Connection:
|
|
43
|
+
"""Ouvre et gère la connexion locale."""
|
|
44
|
+
path = get_db_path()
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
conn = sqlite3.connect(path, check_same_thread=False)
|
|
47
|
+
# Active la vérification des foreign keys
|
|
48
|
+
conn.execute("PRAGMA foreign_keys = 1")
|
|
49
|
+
return conn
|
|
50
|
+
|
|
51
|
+
def init_db():
|
|
52
|
+
conn = _get_connection()
|
|
53
|
+
c = conn.cursor()
|
|
54
|
+
# Métadonnées cache pour gérer le TTL
|
|
55
|
+
c.execute('''
|
|
56
|
+
CREATE TABLE IF NOT EXISTS cache_meta (
|
|
57
|
+
key TEXT PRIMARY KEY,
|
|
58
|
+
updated_at TIMESTAMP NOT NULL
|
|
59
|
+
)
|
|
60
|
+
''')
|
|
61
|
+
# Tickers
|
|
62
|
+
c.execute('''
|
|
63
|
+
CREATE TABLE IF NOT EXISTS tickers (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
nom_entreprise TEXT,
|
|
66
|
+
secteur_activite TEXT,
|
|
67
|
+
type_instrument TEXT
|
|
68
|
+
)
|
|
69
|
+
''')
|
|
70
|
+
# Historique de cotations
|
|
71
|
+
c.execute('''
|
|
72
|
+
CREATE TABLE IF NOT EXISTS daily_quotes (
|
|
73
|
+
date TIMESTAMP,
|
|
74
|
+
ticker_id TEXT,
|
|
75
|
+
open REAL,
|
|
76
|
+
high REAL,
|
|
77
|
+
low REAL,
|
|
78
|
+
close REAL,
|
|
79
|
+
adj_close REAL,
|
|
80
|
+
volume INTEGER CHECK(volume >= 0),
|
|
81
|
+
value REAL,
|
|
82
|
+
trading_status TEXT,
|
|
83
|
+
PRIMARY KEY (date, ticker_id),
|
|
84
|
+
FOREIGN KEY (ticker_id) REFERENCES tickers(id) ON DELETE CASCADE
|
|
85
|
+
)
|
|
86
|
+
''')
|
|
87
|
+
# Fondamentaux
|
|
88
|
+
c.execute('''
|
|
89
|
+
CREATE TABLE IF NOT EXISTS fundamentals (
|
|
90
|
+
ticker_id TEXT,
|
|
91
|
+
annee_fiscale INTEGER,
|
|
92
|
+
per REAL,
|
|
93
|
+
rendement_dividende REAL,
|
|
94
|
+
capitalisation REAL,
|
|
95
|
+
roe REAL,
|
|
96
|
+
PRIMARY KEY (ticker_id, annee_fiscale),
|
|
97
|
+
FOREIGN KEY (ticker_id) REFERENCES tickers(id) ON DELETE CASCADE
|
|
98
|
+
)
|
|
99
|
+
''')
|
|
100
|
+
# Dividendes
|
|
101
|
+
c.execute('''
|
|
102
|
+
CREATE TABLE IF NOT EXISTS dividends (
|
|
103
|
+
ticker_id TEXT,
|
|
104
|
+
date_detachement TIMESTAMP,
|
|
105
|
+
montant_net REAL,
|
|
106
|
+
PRIMARY KEY (ticker_id, date_detachement),
|
|
107
|
+
FOREIGN KEY (ticker_id) REFERENCES tickers(id) ON DELETE CASCADE
|
|
108
|
+
)
|
|
109
|
+
''')
|
|
110
|
+
conn.commit()
|
|
111
|
+
conn.close()
|
|
112
|
+
|
|
113
|
+
def _set_updated(key: str):
|
|
114
|
+
conn = _get_connection()
|
|
115
|
+
c = conn.cursor()
|
|
116
|
+
c.execute(
|
|
117
|
+
"INSERT OR REPLACE INTO cache_meta (key, updated_at) VALUES (?, ?)",
|
|
118
|
+
(key, datetime.now())
|
|
119
|
+
)
|
|
120
|
+
conn.commit()
|
|
121
|
+
conn.close()
|
|
122
|
+
|
|
123
|
+
def _check_valid(key: str, ttl: timedelta) -> bool:
|
|
124
|
+
if not is_cache_enabled():
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
conn = _get_connection()
|
|
128
|
+
c = conn.cursor()
|
|
129
|
+
c.execute("SELECT updated_at FROM cache_meta WHERE key = ?", (key,))
|
|
130
|
+
row = c.fetchone()
|
|
131
|
+
conn.close()
|
|
132
|
+
|
|
133
|
+
if not row:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
updated_at = datetime.fromisoformat(row[0] if isinstance(row[0], str) else str(row[0]))
|
|
138
|
+
return datetime.now() - updated_at < ttl
|
|
139
|
+
except Exception:
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
# ----- Operations sur le Tickers Cache -----
|
|
143
|
+
def cache_set_tickers(df: pd.DataFrame):
|
|
144
|
+
if not is_cache_enabled(): return
|
|
145
|
+
conn = _get_connection()
|
|
146
|
+
lock_path = str(get_db_path()) + ".lock"
|
|
147
|
+
# Simple to_sql save, ignoring FK constraints temporarily for initial load or just parsing properly
|
|
148
|
+
# A plus sûr: utiliser to_sql avec if_exists="replace" si c'est pour l'API. Or, la table est définie custom.
|
|
149
|
+
# Pour garder la structure du CC:
|
|
150
|
+
try:
|
|
151
|
+
df_copy = df.copy()
|
|
152
|
+
if 'symbol' in df_copy.columns:
|
|
153
|
+
df_copy = df_copy.rename(columns={'symbol': 'id'})
|
|
154
|
+
|
|
155
|
+
# On ne stocke que les colonnes prévues (simplification).
|
|
156
|
+
# Normalement on utilise REPLACE pour gérer les doublons SQLite
|
|
157
|
+
# pandas to_sql doesn't support REPLACE directly on all sqlite versions natively without extra dialect args.
|
|
158
|
+
# So we delete and insert.
|
|
159
|
+
c = conn.cursor()
|
|
160
|
+
c.execute("DELETE FROM tickers")
|
|
161
|
+
df_copy.to_sql("tickers", conn, if_exists="append", index=False)
|
|
162
|
+
_set_updated("tickers")
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.warning(f"Failed to cache tickers: {e}")
|
|
165
|
+
finally:
|
|
166
|
+
conn.close()
|
|
167
|
+
|
|
168
|
+
def cache_get_tickers() -> pd.DataFrame:
|
|
169
|
+
if not _check_valid("tickers", TTL_STATIC):
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
conn = _get_connection()
|
|
173
|
+
df = pd.read_sql("SELECT * FROM tickers", conn)
|
|
174
|
+
conn.close()
|
|
175
|
+
# Remapper id -> symbol si convention interne
|
|
176
|
+
df = df.rename(columns={'id': 'symbol'})
|
|
177
|
+
return df if not df.empty else None
|
|
178
|
+
except Exception:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
# ----- Operations sur le Quotes Cache -----
|
|
182
|
+
def cache_set_history(symbol: str, df: pd.DataFrame):
|
|
183
|
+
if not is_cache_enabled() or df.empty: return
|
|
184
|
+
conn = _get_connection()
|
|
185
|
+
try:
|
|
186
|
+
c = conn.cursor()
|
|
187
|
+
c.execute("DELETE FROM daily_quotes WHERE ticker_id = ?", (symbol,))
|
|
188
|
+
|
|
189
|
+
df_copy = df.copy()
|
|
190
|
+
df_copy['ticker_id'] = symbol
|
|
191
|
+
# Ensure date format as string or datetime
|
|
192
|
+
df_copy['date'] = pd.to_datetime(df_copy['date'])
|
|
193
|
+
|
|
194
|
+
# Safe insert
|
|
195
|
+
df_copy.to_sql("daily_quotes", conn, if_exists="append", index=False)
|
|
196
|
+
_set_updated(f"history_{symbol}")
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.warning(f"Failed to cache history for {symbol}: {e}")
|
|
199
|
+
finally:
|
|
200
|
+
conn.close()
|
|
201
|
+
|
|
202
|
+
def cache_get_history(symbol: str) -> pd.DataFrame:
|
|
203
|
+
if not _check_valid(f"history_{symbol}", TTL_EOD):
|
|
204
|
+
return None
|
|
205
|
+
try:
|
|
206
|
+
conn = _get_connection()
|
|
207
|
+
df = pd.read_sql("SELECT * FROM daily_quotes WHERE ticker_id = ? ORDER BY date ASC", conn, params=(symbol,))
|
|
208
|
+
conn.close()
|
|
209
|
+
|
|
210
|
+
if df.empty:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
df['date'] = pd.to_datetime(df['date']).dt.date
|
|
214
|
+
df = df.drop(columns=['ticker_id'])
|
|
215
|
+
return df
|
|
216
|
+
except Exception:
|
|
217
|
+
return None
|
|
218
|
+
|
|
219
|
+
# Initialisation
|
|
220
|
+
try:
|
|
221
|
+
init_db()
|
|
222
|
+
except Exception as e:
|
|
223
|
+
logger.warning(f"Cache DB init failed: {e}")
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
import certifi
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential
|
|
11
|
+
except ModuleNotFoundError: # pragma: no cover - fallback for bare environments
|
|
12
|
+
def retry(*args: Any, **kwargs: Any): # type: ignore[override]
|
|
13
|
+
def decorator(func: Any) -> Any:
|
|
14
|
+
return func
|
|
15
|
+
|
|
16
|
+
return decorator
|
|
17
|
+
|
|
18
|
+
def retry_if_exception_type(*args: Any, **kwargs: Any) -> None:
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
def stop_after_attempt(*args: Any, **kwargs: Any) -> None:
|
|
22
|
+
return None
|
|
23
|
+
|
|
24
|
+
def wait_exponential(*args: Any, **kwargs: Any) -> None:
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
import httpx
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
DEFAULT_HEADERS = {
|
|
34
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
|
35
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
36
|
+
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
_client_singleton: "BRVMClient | None" = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _load_httpx() -> Any:
|
|
43
|
+
import httpx
|
|
44
|
+
|
|
45
|
+
return httpx
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class BRVMClient:
|
|
49
|
+
"""HTTP client shared by source adapters."""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
*,
|
|
54
|
+
timeout: float = 20.0,
|
|
55
|
+
headers: Mapping[str, str] | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
httpx = _load_httpx()
|
|
58
|
+
merged_headers = {**DEFAULT_HEADERS, **dict(headers or {})}
|
|
59
|
+
self._httpx = httpx
|
|
60
|
+
self._session = httpx.Client(
|
|
61
|
+
headers=merged_headers,
|
|
62
|
+
timeout=timeout,
|
|
63
|
+
verify=certifi.where(),
|
|
64
|
+
)
|
|
65
|
+
self._insecure_session = httpx.Client(
|
|
66
|
+
headers=merged_headers,
|
|
67
|
+
timeout=timeout,
|
|
68
|
+
verify=False,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@retry(
|
|
72
|
+
stop=stop_after_attempt(3),
|
|
73
|
+
wait=wait_exponential(multiplier=1, min=1, max=8),
|
|
74
|
+
retry=retry_if_exception_type(Exception),
|
|
75
|
+
reraise=True,
|
|
76
|
+
)
|
|
77
|
+
def get(
|
|
78
|
+
self, url: str, *, params: Mapping[str, Any] | None = None
|
|
79
|
+
) -> "httpx.Response":
|
|
80
|
+
logger.debug("GET %s params=%s", url, params)
|
|
81
|
+
response = self._request_with_tls_fallback("GET", url, params=params)
|
|
82
|
+
response.raise_for_status()
|
|
83
|
+
return response
|
|
84
|
+
|
|
85
|
+
@retry(
|
|
86
|
+
stop=stop_after_attempt(3),
|
|
87
|
+
wait=wait_exponential(multiplier=1, min=1, max=8),
|
|
88
|
+
retry=retry_if_exception_type(Exception),
|
|
89
|
+
reraise=True,
|
|
90
|
+
)
|
|
91
|
+
def post(
|
|
92
|
+
self, url: str, *, data: Mapping[str, Any] | None = None
|
|
93
|
+
) -> "httpx.Response":
|
|
94
|
+
logger.debug("POST %s", url)
|
|
95
|
+
response = self._request_with_tls_fallback("POST", url, data=data)
|
|
96
|
+
response.raise_for_status()
|
|
97
|
+
return response
|
|
98
|
+
|
|
99
|
+
def close(self) -> None:
|
|
100
|
+
self._session.close()
|
|
101
|
+
self._insecure_session.close()
|
|
102
|
+
|
|
103
|
+
def __enter__(self) -> "BRVMClient":
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
def __exit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
107
|
+
self.close()
|
|
108
|
+
|
|
109
|
+
def _request_with_tls_fallback(
|
|
110
|
+
self,
|
|
111
|
+
method: str,
|
|
112
|
+
url: str,
|
|
113
|
+
*,
|
|
114
|
+
params: Mapping[str, Any] | None = None,
|
|
115
|
+
data: Mapping[str, Any] | None = None,
|
|
116
|
+
) -> "httpx.Response":
|
|
117
|
+
try:
|
|
118
|
+
return self._session.request(method, url, params=params, data=data)
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
if "CERTIFICATE_VERIFY_FAILED" not in str(exc):
|
|
121
|
+
raise
|
|
122
|
+
|
|
123
|
+
logger.warning(
|
|
124
|
+
"TLS verification failed for %s; retrying with certificate checks disabled.",
|
|
125
|
+
url,
|
|
126
|
+
)
|
|
127
|
+
return self._insecure_session.request(method, url, params=params, data=data)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_brvm_session() -> BRVMClient:
|
|
131
|
+
global _client_singleton
|
|
132
|
+
if _client_singleton is None:
|
|
133
|
+
_client_singleton = BRVMClient()
|
|
134
|
+
return _client_singleton
|