moqua 0.1.5__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.
- moqua-0.1.5/PKG-INFO +15 -0
- moqua-0.1.5/README.md +88 -0
- moqua-0.1.5/moqua/__init__.py +0 -0
- moqua-0.1.5/moqua/optimization.py +139 -0
- moqua-0.1.5/moqua/outputs.py +271 -0
- moqua-0.1.5/moqua/pricing.py +454 -0
- moqua-0.1.5/moqua/stats.py +847 -0
- moqua-0.1.5/moqua.egg-info/PKG-INFO +15 -0
- moqua-0.1.5/moqua.egg-info/SOURCES.txt +20 -0
- moqua-0.1.5/moqua.egg-info/dependency_links.txt +1 -0
- moqua-0.1.5/moqua.egg-info/not-zip-safe +1 -0
- moqua-0.1.5/moqua.egg-info/requires.txt +7 -0
- moqua-0.1.5/moqua.egg-info/top_level.txt +1 -0
- moqua-0.1.5/pyproject.toml +3 -0
- moqua-0.1.5/setup.cfg +4 -0
- moqua-0.1.5/setup.py +44 -0
- moqua-0.1.5/src/erf_cody.cpp +606 -0
- moqua-0.1.5/src/lets_be_rational.cpp +1752 -0
- moqua-0.1.5/src/normaldistribution.cpp +268 -0
- moqua-0.1.5/src/optimization.cpp +113 -0
- moqua-0.1.5/src/rationalcubic.cpp +114 -0
- moqua-0.1.5/src/rolling_ols.cpp +515 -0
moqua-0.1.5/PKG-INFO
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: moqua
|
|
3
|
+
Version: 0.1.5
|
|
4
|
+
Summary: Quantitative Finance Tools
|
|
5
|
+
Author: M. Drici
|
|
6
|
+
Requires-Dist: numpy
|
|
7
|
+
Requires-Dist: pandas
|
|
8
|
+
Requires-Dist: scipy
|
|
9
|
+
Requires-Dist: matplotlib
|
|
10
|
+
Requires-Dist: seaborn
|
|
11
|
+
Requires-Dist: statsmodels
|
|
12
|
+
Requires-Dist: pybind11>=2.4
|
|
13
|
+
Dynamic: author
|
|
14
|
+
Dynamic: requires-dist
|
|
15
|
+
Dynamic: summary
|
moqua-0.1.5/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Quant Tools Package
|
|
2
|
+
|
|
3
|
+
A lightweight Python package for quantitative finance calculations, including Black-Scholes pricing, statistical analysis (ADF, Kalman Filter), and optimization methods.
|
|
4
|
+
|
|
5
|
+
## 1. Installation (For the Developer)
|
|
6
|
+
|
|
7
|
+
As the author of this package, you will always want to install it from the source code in "editable" mode. This allows you to modify the source files (`pricing.py`, `C++ files`, etc.) and have those changes instantly available across all your projects.
|
|
8
|
+
|
|
9
|
+
### Case A: Working inside the `moqua` directory
|
|
10
|
+
If you are modifying the package itself, open your terminal in this directory and run:
|
|
11
|
+
```bash
|
|
12
|
+
pip install -e .
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### Case B: Using `moqua` in ANOTHER project on your PC
|
|
16
|
+
If you create a new trading algorithm in a completely different folder (e.g., `E:\nouveau_projet_trading`), do NOT copy the files! Instead:
|
|
17
|
+
1. Open your terminal in your **new project's directory**.
|
|
18
|
+
2. Activate your new project's virtual environment (if you use one).
|
|
19
|
+
3. Point `pip` to your master `moqua` directory using the `-e` (editable) flag:
|
|
20
|
+
```bash
|
|
21
|
+
pip install -e E:\m.drici\moqua
|
|
22
|
+
```
|
|
23
|
+
*Result: Your new project can now `import moqua`. Any changes you make to `E:\m.drici\moqua` in the future will automatically update in this new project without needing to reinstall.*
|
|
24
|
+
|
|
25
|
+
## 2. How to Update
|
|
26
|
+
|
|
27
|
+
### Code Changes
|
|
28
|
+
Since the package is installed in editable mode, you **do not** need to run any commands to update the code. simply save your changes in `params.py`, `stats.py`, etc., and they will be live instantly.
|
|
29
|
+
|
|
30
|
+
### Adding New Dependencies
|
|
31
|
+
If you add a new library to `install_requires` in `setup.py`, you must update the installation:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e . --upgrade
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## 3. Usage
|
|
38
|
+
|
|
39
|
+
You can import functions from `moqua` in any Python script on your system, regardless of where the script is located.
|
|
40
|
+
|
|
41
|
+
### Examples
|
|
42
|
+
|
|
43
|
+
**Option Pricing (params.py)**
|
|
44
|
+
```python
|
|
45
|
+
from moqua.pricing import bs_price, bs_vega, EuropeanOption
|
|
46
|
+
|
|
47
|
+
# Calculate Price
|
|
48
|
+
price = bs_price(S=100, K=100, T=1, r=0.05, sigma=0.2, option_type='call')
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Statistical Analysis (stats.py)**
|
|
52
|
+
```python
|
|
53
|
+
from moqua.stats import z_score, adf, kalman_dynamic_beta
|
|
54
|
+
|
|
55
|
+
# Calculate Z-Score
|
|
56
|
+
z = z_score(price_series, window=20)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Optimization (optimization.py)**
|
|
60
|
+
```python
|
|
61
|
+
from moqua.optimization import newton_raphson, dichotomy
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 4. Installation for End Users
|
|
65
|
+
|
|
66
|
+
If you only need to *use* the `moqua` package (without seeing the source code or compiling the C++ modules), you can install the pre-compiled version directly from PyPI.
|
|
67
|
+
|
|
68
|
+
Simply open your terminal and run:
|
|
69
|
+
```bash
|
|
70
|
+
pip install moqua
|
|
71
|
+
```
|
|
72
|
+
This will automatically download and install the pre-compiled binary package matching your operating system.
|
|
73
|
+
|
|
74
|
+
## 5. Developer: Publishing to PyPI
|
|
75
|
+
|
|
76
|
+
The publishing process to PyPI is fully automated via GitHub Actions (`build_wheels.yml`).
|
|
77
|
+
|
|
78
|
+
**Important Note on Automation:**
|
|
79
|
+
J'ai configuré l'automatisation pour qu'elle ne publie pas sur PyPI à chaque petit commit, mais **uniquement quand tu crées une Release / Tag**.
|
|
80
|
+
|
|
81
|
+
To publish a new version:
|
|
82
|
+
1. Update the `version` number in `setup.py`.
|
|
83
|
+
2. Commit and push your changes to the `main` branch.
|
|
84
|
+
3. Go to GitHub -> **Releases** -> **Create a new release**.
|
|
85
|
+
4. Create a new tag (e.g., `v0.1.1`) matching your new version.
|
|
86
|
+
5. Click **Publish release**.
|
|
87
|
+
|
|
88
|
+
GitHub Actions will then automatically compile the C++ wheels for Windows, Mac, and Linux, and upload them securely to PyPI.
|
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
|
|
2
|
+
import numpy as np
|
|
3
|
+
from moqua import pricing
|
|
4
|
+
|
|
5
|
+
def levenberg_marquardt(residual_func, initial_params, args=(), tol=1e-5, max_iter=100, bounds=None):
|
|
6
|
+
params = np.array(initial_params, dtype=float)
|
|
7
|
+
n_params = len(params)
|
|
8
|
+
lam = 1e-3
|
|
9
|
+
v = 2.0
|
|
10
|
+
residuals = residual_func(params, *args)
|
|
11
|
+
current_cost = 0.5 * np.sum(residuals**2)
|
|
12
|
+
for i in range(max_iter):
|
|
13
|
+
J = np.zeros((len(residuals), n_params))
|
|
14
|
+
epsilon = 1e-5
|
|
15
|
+
for j in range(n_params):
|
|
16
|
+
p_perturbed = params.copy()
|
|
17
|
+
p_perturbed[j] += epsilon
|
|
18
|
+
r_perturbed = residual_func(p_perturbed, *args)
|
|
19
|
+
J[:, j] = (r_perturbed - residuals) / epsilon
|
|
20
|
+
JtJ = J.T @ J
|
|
21
|
+
Jtr = J.T @ residuals
|
|
22
|
+
while True:
|
|
23
|
+
H_damped = JtJ + lam * np.eye(n_params)
|
|
24
|
+
try:
|
|
25
|
+
delta = -np.linalg.solve(H_damped, Jtr)
|
|
26
|
+
except np.linalg.LinAlgError:
|
|
27
|
+
delta = None
|
|
28
|
+
if delta is not None:
|
|
29
|
+
new_params = params + delta
|
|
30
|
+
if bounds:
|
|
31
|
+
for k, (low, high) in enumerate(bounds):
|
|
32
|
+
new_params[k] = np.clip(new_params[k], low, high)
|
|
33
|
+
new_residuals = residual_func(new_params, *args)
|
|
34
|
+
new_cost = 0.5 * np.sum(new_residuals**2)
|
|
35
|
+
if new_cost < current_cost:
|
|
36
|
+
params = new_params
|
|
37
|
+
residuals = new_residuals
|
|
38
|
+
current_cost = new_cost
|
|
39
|
+
lam /= v
|
|
40
|
+
break
|
|
41
|
+
else:
|
|
42
|
+
lam *= v
|
|
43
|
+
if lam > 1e9: break
|
|
44
|
+
else:
|
|
45
|
+
lam *= v
|
|
46
|
+
if lam > 1e9: break
|
|
47
|
+
if np.linalg.norm(Jtr) < tol:
|
|
48
|
+
break
|
|
49
|
+
return params
|
|
50
|
+
|
|
51
|
+
def calibrate_heston_lm(strikes, market_prices, S0, T, r):
|
|
52
|
+
def residuals(params, strikes, market_prices, S0, T, r):
|
|
53
|
+
v0, kappa, theta, sigma, rho = params
|
|
54
|
+
model_prices = []
|
|
55
|
+
for K in strikes:
|
|
56
|
+
p = pricing.heston_call_price(S0, K, T, r, v0, kappa, theta, sigma, rho)
|
|
57
|
+
# Relative error
|
|
58
|
+
model_prices.append(p / market_prices[strikes == K][0])
|
|
59
|
+
return np.array(model_prices) - 1.0
|
|
60
|
+
bounds = [
|
|
61
|
+
(0.01, 1.0), (0.01, 20.0), (0.01, 1.0), (0.01, 5.0), (-0.99, 0.99)
|
|
62
|
+
]
|
|
63
|
+
initial_guess = [0.04, 3.0, 0.04, 0.6, -0.7]
|
|
64
|
+
optimized_params = levenberg_marquardt(residuals, initial_guess, args=(strikes, market_prices, S0, T, r), bounds=bounds)
|
|
65
|
+
return optimized_params
|
|
66
|
+
|
|
67
|
+
def differential_evolution(objective_func, bounds, args=(), pop_size=10, max_iter=100, F=0.5, CR=0.7):
|
|
68
|
+
bounds = np.array(bounds)
|
|
69
|
+
min_b = bounds[:, 0]
|
|
70
|
+
max_b = bounds[:, 1]
|
|
71
|
+
diff = max_b - min_b
|
|
72
|
+
dim = len(bounds)
|
|
73
|
+
population = min_b + np.random.rand(pop_size, dim) * diff
|
|
74
|
+
fitness = np.array([objective_func(ind, *args) for ind in population])
|
|
75
|
+
best_idx = np.argmin(fitness)
|
|
76
|
+
best_vector = population[best_idx].copy()
|
|
77
|
+
best_cost = fitness[best_idx]
|
|
78
|
+
for it in range(max_iter):
|
|
79
|
+
for i in range(pop_size):
|
|
80
|
+
idxs = [idx for idx in range(pop_size) if idx != i]
|
|
81
|
+
a, b, c = population[np.random.choice(idxs, 3, replace=False)]
|
|
82
|
+
mutant = a + F * (b - c)
|
|
83
|
+
mutant = np.clip(mutant, min_b, max_b)
|
|
84
|
+
cross_points = np.random.rand(dim) < CR
|
|
85
|
+
if not np.any(cross_points):
|
|
86
|
+
cross_points[np.random.randint(0, dim)] = True
|
|
87
|
+
trial = np.where(cross_points, mutant, population[i])
|
|
88
|
+
trial_cost = objective_func(trial, *args)
|
|
89
|
+
if trial_cost < fitness[i]:
|
|
90
|
+
population[i] = trial
|
|
91
|
+
fitness[i] = trial_cost
|
|
92
|
+
if trial_cost < best_cost:
|
|
93
|
+
best_cost = trial_cost
|
|
94
|
+
best_vector = trial
|
|
95
|
+
return best_vector
|
|
96
|
+
|
|
97
|
+
def calibrate_heston_de(strikes, market_prices, S0, T, r):
|
|
98
|
+
def residuals_sum_sq(params, strikes, market_prices, S0, T, r):
|
|
99
|
+
v0, kappa, theta, sigma, rho = params
|
|
100
|
+
sse = 0.0
|
|
101
|
+
for i, K in enumerate(strikes):
|
|
102
|
+
model_p = pricing.heston_call_price(S0, K, T, r, v0, kappa, theta, sigma, rho)
|
|
103
|
+
sse += ((model_p - market_prices[i]) / market_prices[i])**2
|
|
104
|
+
return sse
|
|
105
|
+
bounds = [
|
|
106
|
+
(0.01, 1.0), (0.01, 20.0), (0.01, 1.0), (0.01, 5.0), (-0.99, 0.99)
|
|
107
|
+
]
|
|
108
|
+
optimized_params = differential_evolution(residuals_sum_sq, bounds, args=(strikes, market_prices, S0, T, r), pop_size=15, max_iter=50)
|
|
109
|
+
return optimized_params
|
|
110
|
+
|
|
111
|
+
def calibrate_heston_hybrid(strikes, market_prices, S0, T, r):
|
|
112
|
+
print(" [Hybrid] Step 1: Differential Evolution (Global search)...")
|
|
113
|
+
def q5_residuals_scalar(params, strikes, market_prices, S0, T, r):
|
|
114
|
+
v0, kappa, theta, sigma, rho = params
|
|
115
|
+
sse = 0.0
|
|
116
|
+
is_T_list = hasattr(T, '__len__')
|
|
117
|
+
for i, K in enumerate(strikes):
|
|
118
|
+
t_val = T[i] if is_T_list else T
|
|
119
|
+
if v0<0 or kappa<0 or theta<0 or sigma<0 or abs(rho)>0.99: return 1e9
|
|
120
|
+
p = pricing.heston_call_price(S0, K, t_val, r, v0, kappa, theta, sigma, rho)
|
|
121
|
+
sse += ((p - market_prices[i]) / market_prices[i])**2
|
|
122
|
+
return sse
|
|
123
|
+
bounds = [
|
|
124
|
+
(0.01, 1.0), (0.01, 20.0), (0.01, 1.0), (0.01, 5.0), (-0.99, 0.99)
|
|
125
|
+
]
|
|
126
|
+
de_params = differential_evolution(q5_residuals_scalar, bounds, args=(strikes, market_prices, S0, T, r), pop_size=20, max_iter=40)
|
|
127
|
+
print(f" [Hybrid] DE Params: {de_params}")
|
|
128
|
+
print(" [Hybrid] Step 2: Levenberg-Marquardt (Refinement)...")
|
|
129
|
+
def q5_residuals_vector(params, strikes, market_prices, S0, T, r):
|
|
130
|
+
v0, kappa, theta, sigma, rho = params
|
|
131
|
+
model_prices = []
|
|
132
|
+
is_T_list = hasattr(T, '__len__')
|
|
133
|
+
for i, K in enumerate(strikes):
|
|
134
|
+
t_val = T[i] if is_T_list else T
|
|
135
|
+
p = pricing.heston_call_price(S0, K, t_val, r, v0, kappa, theta, sigma, rho)
|
|
136
|
+
model_prices.append(p / market_prices[i])
|
|
137
|
+
return np.array(model_prices) - 1.0
|
|
138
|
+
refined_params = levenberg_marquardt(q5_residuals_vector, de_params, args=(strikes, market_prices, S0, T, r), bounds=bounds, max_iter=30)
|
|
139
|
+
return refined_params
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""
|
|
3
|
+
Generic Output and Visualization Tools for Quantitative Strategies
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import numpy as np
|
|
8
|
+
import logging
|
|
9
|
+
import pickle
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import matplotlib.pyplot as plt
|
|
12
|
+
from matplotlib.backends.backend_pdf import PdfPages
|
|
13
|
+
from matplotlib.dates import DateFormatter
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# -----------------------------------------------------------------------------
|
|
17
|
+
# LEGACY FUNCTIONS (Kept for compatibility)
|
|
18
|
+
# -----------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
def create_excel_report(pairs_results: list, filename="pairs_analysis_report.xlsx"):
|
|
21
|
+
"""
|
|
22
|
+
Crée un rapport Excel avec les résultats de l'analyse.
|
|
23
|
+
"""
|
|
24
|
+
if not pairs_results:
|
|
25
|
+
print("Aucun résultat à exporter.")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
df = pd.DataFrame(pairs_results)
|
|
29
|
+
|
|
30
|
+
# Ordonner les colonnes (si présentes)
|
|
31
|
+
cols = ['pair', 'score', 'z_score', 'coint_t', 'coint_pvalue',
|
|
32
|
+
'half_life', 'hurst', 'kpss_stat', 'kpss_pvalue', 'intercept',
|
|
33
|
+
'beta', 'status', 'r_squared']
|
|
34
|
+
|
|
35
|
+
existing_cols = [c for c in cols if c in df.columns]
|
|
36
|
+
other_cols = [c for c in df.columns if c not in cols]
|
|
37
|
+
final_cols = existing_cols + other_cols
|
|
38
|
+
|
|
39
|
+
df = df[final_cols]
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
# Créer le dossier parent si nécessaire
|
|
43
|
+
Path(filename).parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
|
|
45
|
+
with pd.ExcelWriter(filename, engine='openpyxl') as writer:
|
|
46
|
+
df.to_excel(writer, sheet_name='Analysis Results', index=False)
|
|
47
|
+
|
|
48
|
+
# Formatage conditionnel simple
|
|
49
|
+
worksheet = writer.sheets['Analysis Results']
|
|
50
|
+
for idx, col in enumerate(df.columns):
|
|
51
|
+
max_len = max(df[col].astype(str).map(len).max(), len(col)) + 2
|
|
52
|
+
worksheet.column_dimensions[chr(65 + idx)].width = min(max_len, 50)
|
|
53
|
+
|
|
54
|
+
print(f"Rapport Excel généré : {filename}")
|
|
55
|
+
|
|
56
|
+
except Exception as e:
|
|
57
|
+
print(f"Erreur lors de la génération du rapport Excel : {e}")
|
|
58
|
+
|
|
59
|
+
def create_pdf_report(pairs_results: list, price_data: dict, filename="pairs_analysis_report.pdf"):
|
|
60
|
+
"""
|
|
61
|
+
Génère un rapport PDF avec les graphiques des meilleures paires.
|
|
62
|
+
Legacy function: Use PairPlotter class for new implementations.
|
|
63
|
+
"""
|
|
64
|
+
if not pairs_results:
|
|
65
|
+
return
|
|
66
|
+
|
|
67
|
+
# Convertir en DataFrame si nécessaire pour trier
|
|
68
|
+
if isinstance(pairs_results, list):
|
|
69
|
+
df_res = pd.DataFrame(pairs_results)
|
|
70
|
+
else:
|
|
71
|
+
df_res = pairs_results
|
|
72
|
+
|
|
73
|
+
# Filtrer les paires valides
|
|
74
|
+
valid_pairs = df_res[df_res['status'] == 'VALID'].copy()
|
|
75
|
+
|
|
76
|
+
# Trier par score décroissant
|
|
77
|
+
if 'score' in valid_pairs.columns:
|
|
78
|
+
top_pairs = valid_pairs.sort_values('score', ascending=False).head(20)
|
|
79
|
+
else:
|
|
80
|
+
top_pairs = valid_pairs.head(20)
|
|
81
|
+
|
|
82
|
+
if top_pairs.empty:
|
|
83
|
+
print("Aucune paire valide à tracer.")
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
print(f"Génération du PDF ({len(top_pairs)} paires)...")
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
with PdfPages(filename) as pdf:
|
|
90
|
+
for _, row in top_pairs.iterrows():
|
|
91
|
+
try:
|
|
92
|
+
pair = row['pair']
|
|
93
|
+
assets = pair.split('-')
|
|
94
|
+
if len(assets) != 2: continue
|
|
95
|
+
|
|
96
|
+
a, b = assets[0], assets[1]
|
|
97
|
+
|
|
98
|
+
# Récupérer les données
|
|
99
|
+
# Note: price_data structure depend du format passé (dict de df ou autre)
|
|
100
|
+
# Implementation simplifiée pour compatibilité
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
except Exception as e:
|
|
104
|
+
print(f"Erreur plot {pair}: {e}")
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
print(f"PDF généré : {filename}")
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
print(f"Erreur globale PDF : {e}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# -----------------------------------------------------------------------------
|
|
114
|
+
# PAIR PLOTTER BASE CLASS (Strategy Visualization)
|
|
115
|
+
# -----------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
class PairPlotter:
|
|
118
|
+
"""
|
|
119
|
+
Base class for visualizing trading pairs.
|
|
120
|
+
Handles generic plotting logic (PDF generation, price normalization, data loading)
|
|
121
|
+
while allowing strategies to customize the 'spread' or specific chart visualization.
|
|
122
|
+
"""
|
|
123
|
+
def __init__(self, viz_data_path: str = None):
|
|
124
|
+
self.viz_data_path = viz_data_path
|
|
125
|
+
self.equities_df = None
|
|
126
|
+
self.index_df = None
|
|
127
|
+
self.results = pd.DataFrame()
|
|
128
|
+
self.pair_data = {}
|
|
129
|
+
|
|
130
|
+
# Configuration Matplotlib Standard (A4 Landscape / 2:1 Ratio aspect but A4 width)
|
|
131
|
+
# 11.69 x 5.85 inches (Half of A4 Landscape essentially)
|
|
132
|
+
self.figsize = (11.69, 5.85)
|
|
133
|
+
plt.rcParams['font.size'] = 10
|
|
134
|
+
plt.rcParams['figure.figsize'] = self.figsize
|
|
135
|
+
|
|
136
|
+
def load_data(self):
|
|
137
|
+
"""Loads visualization data from PKL"""
|
|
138
|
+
# Determine path if not provided
|
|
139
|
+
if not self.viz_data_path:
|
|
140
|
+
# Try default locations
|
|
141
|
+
possible_paths = [
|
|
142
|
+
Path("visualization_data.pkl"),
|
|
143
|
+
Path(__file__).resolve().parent.parent / "LS EQUITY" / "SELECTION" / "SELECTION MARKET NEUTRAL" / "visualization_data.pkl"
|
|
144
|
+
]
|
|
145
|
+
for p in possible_paths:
|
|
146
|
+
if p.exists():
|
|
147
|
+
self.viz_data_path = p
|
|
148
|
+
break
|
|
149
|
+
|
|
150
|
+
if not self.viz_data_path or not Path(self.viz_data_path).exists():
|
|
151
|
+
logging.error(f"Visualization data not found: {self.viz_data_path}")
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
logging.info(f"Loading visualization data from {self.viz_data_path}...")
|
|
156
|
+
with open(self.viz_data_path, 'rb') as f:
|
|
157
|
+
viz_data = pickle.load(f)
|
|
158
|
+
|
|
159
|
+
price_data = viz_data.get('price_data', {})
|
|
160
|
+
self.equities_df = price_data.get('equities_df')
|
|
161
|
+
self.index_df = price_data.get('index_df')
|
|
162
|
+
self.pair_data = viz_data.get('pair_data', {})
|
|
163
|
+
self.results = viz_data.get('results_summary', pd.DataFrame())
|
|
164
|
+
|
|
165
|
+
# Filter results to match pair_data availability
|
|
166
|
+
if not self.results.empty and self.pair_data:
|
|
167
|
+
available = list(self.pair_data.keys())
|
|
168
|
+
# Check where pair name is stored
|
|
169
|
+
if 'pair' in self.results.columns:
|
|
170
|
+
self.results = self.results[self.results['pair'].isin(available)].copy()
|
|
171
|
+
elif 'pair' not in self.results.columns and self.results.index.name == 'pair':
|
|
172
|
+
self.results = self.results[self.results.index.isin(available)].copy()
|
|
173
|
+
self.results['pair'] = self.results.index
|
|
174
|
+
|
|
175
|
+
logging.info(f"Data Loaded: {len(self.results)} pairs ready.")
|
|
176
|
+
return True
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logging.error(f"Error loading data: {e}")
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
def plot_normalized_prices(self, ax, pair_name):
|
|
182
|
+
"""Generic plot: Normalized Prices (Asset A vs Asset B)"""
|
|
183
|
+
if pair_name not in self.pair_data: return
|
|
184
|
+
|
|
185
|
+
# Basic parsing (assumes "AssetA-AssetB" format)
|
|
186
|
+
try:
|
|
187
|
+
a, b = pair_name.split("-", 1)
|
|
188
|
+
except ValueError:
|
|
189
|
+
ax.text(0.5, 0.5, f"Invalid Pair Name: {pair_name}", ha='center')
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
price_a = self.equities_df[a].dropna() if a in self.equities_df.columns else pd.Series(dtype=float)
|
|
193
|
+
price_b = self.equities_df[b].dropna() if b in self.equities_df.columns else pd.Series(dtype=float)
|
|
194
|
+
|
|
195
|
+
common = price_a.index.intersection(price_b.index)
|
|
196
|
+
|
|
197
|
+
if len(common) > 10:
|
|
198
|
+
p_a = price_a.loc[common]
|
|
199
|
+
p_b = price_b.loc[common]
|
|
200
|
+
|
|
201
|
+
# Rebase to 1.0
|
|
202
|
+
ax.plot(common, p_a / p_a.iloc[0], label=a, linewidth=0.9)
|
|
203
|
+
ax.plot(common, p_b / p_b.iloc[0], label=b, linewidth=0.9)
|
|
204
|
+
|
|
205
|
+
ax.set_title(f'Normalized Prices - {pair_name}', fontweight='bold')
|
|
206
|
+
ax.legend()
|
|
207
|
+
ax.grid(True, alpha=0.3)
|
|
208
|
+
ax.xaxis.set_major_formatter(DateFormatter('%y-%m'))
|
|
209
|
+
ax.set_ylabel('Normalized Price')
|
|
210
|
+
ax.set_xlabel('Date')
|
|
211
|
+
else:
|
|
212
|
+
ax.text(0.5, 0.5, "Insufficient Price Data", ha='center')
|
|
213
|
+
|
|
214
|
+
def plot_spread(self, ax, pair_name, **kwargs):
|
|
215
|
+
"""Abstract method: Must be implemented by strategy subclass"""
|
|
216
|
+
ax.text(0.5, 0.5, "Spread Plot Not Implemented (Base Class)", ha='center')
|
|
217
|
+
|
|
218
|
+
def generate_pdf(self, filename="analysis_results.pdf", top_n=10, asset_filter=None):
|
|
219
|
+
"""Orchestrates PDF generation"""
|
|
220
|
+
if self.results.empty:
|
|
221
|
+
logging.warning("No results to plot.")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
# Filter by asset if requested
|
|
225
|
+
if asset_filter:
|
|
226
|
+
df_plot = self.results[self.results['pair'].str.contains(asset_filter, na=False)].copy()
|
|
227
|
+
else:
|
|
228
|
+
df_plot = self.results.copy()
|
|
229
|
+
|
|
230
|
+
# Deduplicate and slice
|
|
231
|
+
if 'pair' in df_plot.columns:
|
|
232
|
+
top_pairs = df_plot.drop_duplicates(subset=['pair']).head(top_n)
|
|
233
|
+
else:
|
|
234
|
+
top_pairs = df_plot.head(top_n) # Fallback
|
|
235
|
+
|
|
236
|
+
if top_pairs.empty:
|
|
237
|
+
logging.warning(f"No pairs found for filter: {asset_filter}")
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
logging.info(f"Generating PDF: {filename} ({len(top_pairs)} pairs)...")
|
|
241
|
+
|
|
242
|
+
with PdfPages(filename) as pdf:
|
|
243
|
+
for i, (_, row) in enumerate(top_pairs.iterrows()):
|
|
244
|
+
pair_name = row['pair'] if 'pair' in row else row.name
|
|
245
|
+
|
|
246
|
+
# Standard A4 Landscape / 2:1 Ratio Layout
|
|
247
|
+
fig, axes = plt.subplots(1, 2, figsize=self.figsize)
|
|
248
|
+
|
|
249
|
+
# Plot 1: Standard Normalized Prices
|
|
250
|
+
self.plot_normalized_prices(axes[0], pair_name)
|
|
251
|
+
|
|
252
|
+
# Plot 2: Strategy Specific Spread
|
|
253
|
+
self.plot_spread(axes[1], pair_name, row=row)
|
|
254
|
+
|
|
255
|
+
# Title
|
|
256
|
+
score = row.get('score', 0)
|
|
257
|
+
z = row.get('current_z', 0)
|
|
258
|
+
# Handle half-life unit (days or years) display logic if needed upstream
|
|
259
|
+
hl = row.get('half_life_days', row.get('half_life', 0))
|
|
260
|
+
|
|
261
|
+
univ = row.get('universe', '?')
|
|
262
|
+
title = f"Opportunity {i+1}: {pair_name} ({univ}) - Score: {score:.4f} | Z: {z:.2f} | HL: {hl:.1f}d"
|
|
263
|
+
fig.suptitle(title, fontsize=12, fontweight='bold', y=0.98)
|
|
264
|
+
|
|
265
|
+
plt.tight_layout(rect=[0, 0.03, 1, 0.95])
|
|
266
|
+
# USER REQUEST: "que la page soit en fonction du graphique"
|
|
267
|
+
# bbox_inches='tight' forces the page to wrap the content exactly
|
|
268
|
+
pdf.savefig(fig, bbox_inches='tight')
|
|
269
|
+
plt.close(fig)
|
|
270
|
+
|
|
271
|
+
logging.info("PDF Generation Complete.")
|