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 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.")