mainsequence 2.0.0__py3-none-any.whl
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.
- mainsequence/__init__.py +0 -0
- mainsequence/__main__.py +9 -0
- mainsequence/cli/__init__.py +1 -0
- mainsequence/cli/api.py +157 -0
- mainsequence/cli/cli.py +442 -0
- mainsequence/cli/config.py +78 -0
- mainsequence/cli/ssh_utils.py +126 -0
- mainsequence/client/__init__.py +17 -0
- mainsequence/client/base.py +431 -0
- mainsequence/client/data_sources_interfaces/__init__.py +0 -0
- mainsequence/client/data_sources_interfaces/duckdb.py +1468 -0
- mainsequence/client/data_sources_interfaces/timescale.py +479 -0
- mainsequence/client/models_helpers.py +113 -0
- mainsequence/client/models_report_studio.py +412 -0
- mainsequence/client/models_tdag.py +2276 -0
- mainsequence/client/models_vam.py +1983 -0
- mainsequence/client/utils.py +387 -0
- mainsequence/dashboards/__init__.py +0 -0
- mainsequence/dashboards/streamlit/__init__.py +0 -0
- mainsequence/dashboards/streamlit/assets/config.toml +12 -0
- mainsequence/dashboards/streamlit/assets/favicon.png +0 -0
- mainsequence/dashboards/streamlit/assets/logo.png +0 -0
- mainsequence/dashboards/streamlit/core/__init__.py +0 -0
- mainsequence/dashboards/streamlit/core/theme.py +212 -0
- mainsequence/dashboards/streamlit/pages/__init__.py +0 -0
- mainsequence/dashboards/streamlit/scaffold.py +220 -0
- mainsequence/instrumentation/__init__.py +7 -0
- mainsequence/instrumentation/utils.py +101 -0
- mainsequence/instruments/__init__.py +1 -0
- mainsequence/instruments/data_interface/__init__.py +10 -0
- mainsequence/instruments/data_interface/data_interface.py +361 -0
- mainsequence/instruments/instruments/__init__.py +3 -0
- mainsequence/instruments/instruments/base_instrument.py +85 -0
- mainsequence/instruments/instruments/bond.py +447 -0
- mainsequence/instruments/instruments/european_option.py +74 -0
- mainsequence/instruments/instruments/interest_rate_swap.py +217 -0
- mainsequence/instruments/instruments/json_codec.py +585 -0
- mainsequence/instruments/instruments/knockout_fx_option.py +146 -0
- mainsequence/instruments/instruments/position.py +475 -0
- mainsequence/instruments/instruments/ql_fields.py +239 -0
- mainsequence/instruments/instruments/vanilla_fx_option.py +107 -0
- mainsequence/instruments/pricing_models/__init__.py +0 -0
- mainsequence/instruments/pricing_models/black_scholes.py +49 -0
- mainsequence/instruments/pricing_models/bond_pricer.py +182 -0
- mainsequence/instruments/pricing_models/fx_option_pricer.py +90 -0
- mainsequence/instruments/pricing_models/indices.py +350 -0
- mainsequence/instruments/pricing_models/knockout_fx_pricer.py +209 -0
- mainsequence/instruments/pricing_models/swap_pricer.py +502 -0
- mainsequence/instruments/settings.py +175 -0
- mainsequence/instruments/utils.py +29 -0
- mainsequence/logconf.py +284 -0
- mainsequence/reportbuilder/__init__.py +0 -0
- mainsequence/reportbuilder/__main__.py +0 -0
- mainsequence/reportbuilder/examples/ms_template_report.py +706 -0
- mainsequence/reportbuilder/model.py +713 -0
- mainsequence/reportbuilder/slide_templates.py +532 -0
- mainsequence/tdag/__init__.py +8 -0
- mainsequence/tdag/__main__.py +0 -0
- mainsequence/tdag/config.py +129 -0
- mainsequence/tdag/data_nodes/__init__.py +12 -0
- mainsequence/tdag/data_nodes/build_operations.py +751 -0
- mainsequence/tdag/data_nodes/data_nodes.py +1292 -0
- mainsequence/tdag/data_nodes/persist_managers.py +812 -0
- mainsequence/tdag/data_nodes/run_operations.py +543 -0
- mainsequence/tdag/data_nodes/utils.py +24 -0
- mainsequence/tdag/future_registry.py +25 -0
- mainsequence/tdag/utils.py +40 -0
- mainsequence/virtualfundbuilder/__init__.py +45 -0
- mainsequence/virtualfundbuilder/__main__.py +235 -0
- mainsequence/virtualfundbuilder/agent_interface.py +77 -0
- mainsequence/virtualfundbuilder/config_handling.py +86 -0
- mainsequence/virtualfundbuilder/contrib/__init__.py +0 -0
- mainsequence/virtualfundbuilder/contrib/apps/__init__.py +8 -0
- mainsequence/virtualfundbuilder/contrib/apps/etf_replicator_app.py +164 -0
- mainsequence/virtualfundbuilder/contrib/apps/generate_report.py +292 -0
- mainsequence/virtualfundbuilder/contrib/apps/load_external_portfolio.py +107 -0
- mainsequence/virtualfundbuilder/contrib/apps/news_app.py +437 -0
- mainsequence/virtualfundbuilder/contrib/apps/portfolio_report_app.py +91 -0
- mainsequence/virtualfundbuilder/contrib/apps/portfolio_table.py +95 -0
- mainsequence/virtualfundbuilder/contrib/apps/run_named_portfolio.py +45 -0
- mainsequence/virtualfundbuilder/contrib/apps/run_portfolio.py +40 -0
- mainsequence/virtualfundbuilder/contrib/apps/templates/base.html +147 -0
- mainsequence/virtualfundbuilder/contrib/apps/templates/report.html +77 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/__init__.py +5 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/external_weights.py +61 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/intraday_trend.py +149 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/market_cap.py +310 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/mock_signal.py +78 -0
- mainsequence/virtualfundbuilder/contrib/data_nodes/portfolio_replicator.py +269 -0
- mainsequence/virtualfundbuilder/contrib/prices/__init__.py +1 -0
- mainsequence/virtualfundbuilder/contrib/prices/data_nodes.py +810 -0
- mainsequence/virtualfundbuilder/contrib/prices/utils.py +11 -0
- mainsequence/virtualfundbuilder/contrib/rebalance_strategies/__init__.py +1 -0
- mainsequence/virtualfundbuilder/contrib/rebalance_strategies/rebalance_strategies.py +313 -0
- mainsequence/virtualfundbuilder/data_nodes.py +637 -0
- mainsequence/virtualfundbuilder/enums.py +23 -0
- mainsequence/virtualfundbuilder/models.py +282 -0
- mainsequence/virtualfundbuilder/notebook_handling.py +42 -0
- mainsequence/virtualfundbuilder/portfolio_interface.py +272 -0
- mainsequence/virtualfundbuilder/resource_factory/__init__.py +0 -0
- mainsequence/virtualfundbuilder/resource_factory/app_factory.py +170 -0
- mainsequence/virtualfundbuilder/resource_factory/base_factory.py +238 -0
- mainsequence/virtualfundbuilder/resource_factory/rebalance_factory.py +101 -0
- mainsequence/virtualfundbuilder/resource_factory/signal_factory.py +183 -0
- mainsequence/virtualfundbuilder/utils.py +381 -0
- mainsequence-2.0.0.dist-info/METADATA +105 -0
- mainsequence-2.0.0.dist-info/RECORD +110 -0
- mainsequence-2.0.0.dist-info/WHEEL +5 -0
- mainsequence-2.0.0.dist-info/licenses/LICENSE +40 -0
- mainsequence-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,502 @@
|
|
1
|
+
import QuantLib as ql
|
2
|
+
from mainsequence.instruments.data_interface import data_interface
|
3
|
+
from mainsequence.instruments.utils import to_py_date,to_ql_date
|
4
|
+
import datetime
|
5
|
+
from typing import List, Dict, Any, Optional
|
6
|
+
import matplotlib.pyplot as plt
|
7
|
+
|
8
|
+
|
9
|
+
|
10
|
+
|
11
|
+
def _coerce_to_ql_date(x, fallback: ql.Date) -> ql.Date:
|
12
|
+
"""Coerce various date-like inputs to ql.Date, or return fallback."""
|
13
|
+
if x is None:
|
14
|
+
return fallback
|
15
|
+
if isinstance(x, ql.Date):
|
16
|
+
return x
|
17
|
+
if isinstance(x, datetime.date):
|
18
|
+
return to_ql_date(x)
|
19
|
+
if isinstance(x, str):
|
20
|
+
try:
|
21
|
+
return to_ql_date(datetime.date.fromisoformat(x))
|
22
|
+
except Exception:
|
23
|
+
return fallback
|
24
|
+
# pandas.Timestamp / numpy datetime64
|
25
|
+
try:
|
26
|
+
if hasattr(x, "to_pydatetime"):
|
27
|
+
return to_ql_date(x.to_pydatetime().date())
|
28
|
+
except Exception:
|
29
|
+
pass
|
30
|
+
return fallback
|
31
|
+
|
32
|
+
|
33
|
+
|
34
|
+
|
35
|
+
def make_ftiie_index(curve: ql.YieldTermStructureHandle,
|
36
|
+
settlement_days: int = 1) -> ql.OvernightIndex:
|
37
|
+
cal = ql.Mexico() if hasattr(ql, "Mexico") else ql.TARGET()
|
38
|
+
try:
|
39
|
+
ccy = ql.MXNCurrency()
|
40
|
+
except Exception:
|
41
|
+
ccy = ql.USDCurrency() # label only
|
42
|
+
return ql.OvernightIndex("F-TIIE", settlement_days, ccy, cal, ql.Actual360(), curve)
|
43
|
+
|
44
|
+
|
45
|
+
|
46
|
+
def build_yield_curve(calculation_date: ql.Date) -> ql.YieldTermStructureHandle:
|
47
|
+
"""
|
48
|
+
Builds a piecewise yield curve by bootstrapping over a set of market rates.
|
49
|
+
"""
|
50
|
+
print("Building bootstrapped yield curve from market nodes...")
|
51
|
+
|
52
|
+
rate_data = data_interface.get_historical_data("interest_rate_swaps", {"USD_rates": {}})
|
53
|
+
curve_nodes = rate_data['curve_nodes']
|
54
|
+
|
55
|
+
calendar = ql.TARGET()
|
56
|
+
day_counter = ql.Actual365Fixed()
|
57
|
+
|
58
|
+
rate_helpers = []
|
59
|
+
|
60
|
+
swap_fixed_leg_frequency = ql.Annual
|
61
|
+
swap_fixed_leg_convention = ql.Unadjusted
|
62
|
+
swap_fixed_leg_daycounter = ql.Thirty360(ql.Thirty360.USA)
|
63
|
+
yield_curve_handle = ql.YieldTermStructureHandle(ql.FlatForward(calculation_date, 0.05, day_counter))
|
64
|
+
ibor_index = ql.USDLibor(ql.Period('3M'), yield_curve_handle)
|
65
|
+
|
66
|
+
for node in curve_nodes:
|
67
|
+
rate = node['rate']
|
68
|
+
tenor = ql.Period(node['tenor'])
|
69
|
+
quote_handle = ql.QuoteHandle(ql.SimpleQuote(rate))
|
70
|
+
|
71
|
+
if node['type'] == 'deposit':
|
72
|
+
helper = ql.DepositRateHelper(quote_handle, tenor, 2, calendar, ql.ModifiedFollowing, False, day_counter)
|
73
|
+
rate_helpers.append(helper)
|
74
|
+
elif node['type'] == 'swap':
|
75
|
+
helper = ql.SwapRateHelper(quote_handle, tenor, calendar,
|
76
|
+
swap_fixed_leg_frequency,
|
77
|
+
swap_fixed_leg_convention,
|
78
|
+
swap_fixed_leg_daycounter,
|
79
|
+
ibor_index)
|
80
|
+
rate_helpers.append(helper)
|
81
|
+
|
82
|
+
yield_curve = ql.PiecewiseLogCubicDiscount(calculation_date, rate_helpers, day_counter)
|
83
|
+
yield_curve.enableExtrapolation()
|
84
|
+
|
85
|
+
print("Yield curve built successfully.")
|
86
|
+
return ql.YieldTermStructureHandle(yield_curve)
|
87
|
+
|
88
|
+
# src/pricing_models/swap_pricer.py
|
89
|
+
def price_vanilla_swap_with_curve(
|
90
|
+
calculation_date: ql.Date,
|
91
|
+
notional: float,
|
92
|
+
start_date: ql.Date,
|
93
|
+
maturity_date: ql.Date,
|
94
|
+
fixed_rate: float,
|
95
|
+
fixed_leg_tenor: ql.Period,
|
96
|
+
fixed_leg_convention: int,
|
97
|
+
fixed_leg_daycount: ql.DayCounter,
|
98
|
+
float_leg_tenor: ql.Period,
|
99
|
+
float_leg_spread: float,
|
100
|
+
ibor_index: ql.IborIndex,
|
101
|
+
curve: ql.YieldTermStructureHandle,
|
102
|
+
) -> ql.VanillaSwap:
|
103
|
+
# --- evaluation settings ---
|
104
|
+
ql.Settings.instance().evaluationDate = calculation_date
|
105
|
+
ql.Settings.instance().includeReferenceDateEvents = False
|
106
|
+
ql.Settings.instance().enforceTodaysHistoricFixings = False
|
107
|
+
|
108
|
+
# index linked to the provided curve
|
109
|
+
pricing_ibor_index = ibor_index.clone(curve)
|
110
|
+
calendar = pricing_ibor_index.fixingCalendar()
|
111
|
+
|
112
|
+
# --------- EFFECTIVE DATES (spot start safeguard) ----------
|
113
|
+
fixingDate = calendar.adjust(calculation_date, ql.Following)
|
114
|
+
while not pricing_ibor_index.isValidFixingDate(fixingDate):
|
115
|
+
fixingDate = calendar.advance(fixingDate, 1, ql.Days)
|
116
|
+
spot_start = pricing_ibor_index.valueDate(fixingDate)
|
117
|
+
|
118
|
+
eff_start = start_date if start_date > calculation_date else spot_start
|
119
|
+
eff_end = maturity_date
|
120
|
+
if eff_end <= eff_start:
|
121
|
+
eff_end = calendar.advance(eff_start, float_leg_tenor)
|
122
|
+
|
123
|
+
# --------- Schedules ----------
|
124
|
+
fixed_schedule = ql.Schedule(
|
125
|
+
eff_start, eff_end, fixed_leg_tenor, calendar,
|
126
|
+
fixed_leg_convention, fixed_leg_convention,
|
127
|
+
ql.DateGeneration.Forward, False
|
128
|
+
)
|
129
|
+
float_schedule = ql.Schedule(
|
130
|
+
eff_start, eff_end, float_leg_tenor, calendar,
|
131
|
+
pricing_ibor_index.businessDayConvention(), pricing_ibor_index.businessDayConvention(),
|
132
|
+
ql.DateGeneration.Forward, False
|
133
|
+
)
|
134
|
+
|
135
|
+
# --------- Instrument ----------
|
136
|
+
swap = ql.VanillaSwap(
|
137
|
+
ql.VanillaSwap.Payer,
|
138
|
+
notional,
|
139
|
+
fixed_schedule,
|
140
|
+
fixed_rate,
|
141
|
+
fixed_leg_daycount,
|
142
|
+
float_schedule,
|
143
|
+
pricing_ibor_index,
|
144
|
+
float_leg_spread,
|
145
|
+
pricing_ibor_index.dayCounter()
|
146
|
+
)
|
147
|
+
|
148
|
+
# --------- Seed past fixings from the curve (coupon by coupon) ----------
|
149
|
+
# For any coupon whose fixing date <= evaluation date, insert the forward
|
150
|
+
# implied by *this same curve* for that coupon’s accrual period (ACT/360 simple).
|
151
|
+
dc = pricing_ibor_index.dayCounter()
|
152
|
+
for cf in swap.leg(1):
|
153
|
+
cup = ql.as_floating_rate_coupon(cf)
|
154
|
+
fix = cup.fixingDate()
|
155
|
+
if fix <= calculation_date:
|
156
|
+
# If a real fixing already exists, leave it
|
157
|
+
try:
|
158
|
+
_ = pricing_ibor_index.fixing(fix)
|
159
|
+
except RuntimeError:
|
160
|
+
start = cup.accrualStartDate()
|
161
|
+
end = cup.accrualEndDate()
|
162
|
+
tau = dc.yearFraction(start, end)
|
163
|
+
df0 = curve.discount(start)
|
164
|
+
df1 = curve.discount(end)
|
165
|
+
fwd = (df0 / df1 - 1.0) / tau # simple ACT/360
|
166
|
+
pricing_ibor_index.addFixing(fix, fwd)
|
167
|
+
|
168
|
+
swap.setPricingEngine(ql.DiscountingSwapEngine(curve))
|
169
|
+
return swap
|
170
|
+
|
171
|
+
|
172
|
+
|
173
|
+
|
174
|
+
|
175
|
+
def get_swap_cashflows(swap) -> Dict[str, List[Dict[str, Any]]]:
|
176
|
+
"""
|
177
|
+
Analyzes the cashflows of a swap's fixed and floating legs.
|
178
|
+
"""
|
179
|
+
cashflows = {'fixed': [], 'floating': []}
|
180
|
+
|
181
|
+
for cf in swap.leg(0):
|
182
|
+
if not cf.hasOccurred():
|
183
|
+
cashflows['fixed'].append({
|
184
|
+
'payment_date': to_py_date(cf.date()),
|
185
|
+
'amount': cf.amount()
|
186
|
+
})
|
187
|
+
|
188
|
+
for cf in swap.leg(1):
|
189
|
+
if not cf.hasOccurred():
|
190
|
+
coupon = ql.as_floating_rate_coupon(cf)
|
191
|
+
cashflows['floating'].append({
|
192
|
+
'payment_date': to_py_date(coupon.date()),
|
193
|
+
'fixing_date': to_py_date(coupon.fixingDate()),
|
194
|
+
'rate': coupon.rate(),
|
195
|
+
'spread': coupon.spread(),
|
196
|
+
'amount': coupon.amount()
|
197
|
+
})
|
198
|
+
|
199
|
+
return cashflows
|
200
|
+
|
201
|
+
def plot_swap_zero_curve(
|
202
|
+
calculation_date: ql.Date | datetime.date,
|
203
|
+
max_years: int = 30,
|
204
|
+
step_months: int = 3,
|
205
|
+
compounding = ql.Continuous, # QuantLib enums are ints; don't type-hint them
|
206
|
+
frequency = ql.Annual,
|
207
|
+
show: bool = False,
|
208
|
+
ax: Optional[plt.Axes] = None,
|
209
|
+
) -> tuple[list[float], list[float]]:
|
210
|
+
"""
|
211
|
+
Plot the zero-coupon (spot) curve implied by the swap-bootstrapped curve.
|
212
|
+
|
213
|
+
Returns:
|
214
|
+
(tenors_in_years, zero_rates) with zero_rates in decimals (e.g., 0.045).
|
215
|
+
"""
|
216
|
+
# normalize date
|
217
|
+
ql_calc = to_ql_date(calculation_date) if isinstance(calculation_date, datetime.date) else calculation_date
|
218
|
+
ql.Settings.instance().evaluationDate = ql_calc
|
219
|
+
|
220
|
+
# build curve from the mocked swap/deposit nodes
|
221
|
+
ts_handle = build_yield_curve(ql_calc)
|
222
|
+
|
223
|
+
calendar = ql.TARGET()
|
224
|
+
day_count = ql.Actual365Fixed()
|
225
|
+
|
226
|
+
years: list[float] = []
|
227
|
+
zeros: list[float] = []
|
228
|
+
|
229
|
+
months = 1
|
230
|
+
while months <= max_years * 12:
|
231
|
+
d = calendar.advance(ql_calc, ql.Period(months, ql.Months))
|
232
|
+
T = day_count.yearFraction(ql_calc, d)
|
233
|
+
z = ts_handle.zeroRate(d, day_count, compounding, frequency).rate()
|
234
|
+
years.append(T)
|
235
|
+
zeros.append(z)
|
236
|
+
months += step_months
|
237
|
+
|
238
|
+
# plot
|
239
|
+
if ax is None:
|
240
|
+
fig, ax = plt.subplots(figsize=(7, 4))
|
241
|
+
ax.plot(years, [z * 100 for z in zeros])
|
242
|
+
ax.set_xlabel("Maturity (years)")
|
243
|
+
ax.set_ylabel("Zero rate (%)")
|
244
|
+
ax.set_title("Zero-Coupon Yield Curve (Swaps)")
|
245
|
+
ax.grid(True, linestyle="--", alpha=0.4)
|
246
|
+
|
247
|
+
if show:
|
248
|
+
plt.show()
|
249
|
+
|
250
|
+
return years, zeros
|
251
|
+
|
252
|
+
# src/pricing_models/swap_pricer.py
|
253
|
+
import QuantLib as ql
|
254
|
+
|
255
|
+
import QuantLib as ql
|
256
|
+
|
257
|
+
def price_ftiie_ois_with_curve(
|
258
|
+
calculation_date: ql.Date,
|
259
|
+
notional: float,
|
260
|
+
start_date: ql.Date,
|
261
|
+
maturity_date: ql.Date,
|
262
|
+
fixed_rate: float,
|
263
|
+
fixed_leg_tenor: ql.Period, # e.g., 28D
|
264
|
+
fixed_leg_convention: int, # ql.ModifiedFollowing
|
265
|
+
fixed_leg_daycount: ql.DayCounter, # ql.Actual360()
|
266
|
+
on_index: ql.OvernightIndex, # FTIIE overnight index
|
267
|
+
curve: ql.YieldTermStructureHandle,
|
268
|
+
) -> ql.OvernightIndexedSwap:
|
269
|
+
# Consistent evaluation settings (no ‘today’ leakage)
|
270
|
+
ql.Settings.instance().evaluationDate = calculation_date
|
271
|
+
ql.Settings.instance().includeReferenceDateEvents = False
|
272
|
+
ql.Settings.instance().enforceTodaysHistoricFixings = False
|
273
|
+
|
274
|
+
cal = on_index.fixingCalendar()
|
275
|
+
|
276
|
+
# -------- Spot start (T+1 Mexico) with tenor preservation --------
|
277
|
+
fixing = cal.adjust(calculation_date, ql.Following)
|
278
|
+
while not on_index.isValidFixingDate(fixing):
|
279
|
+
fixing = cal.advance(fixing, 1, ql.Days)
|
280
|
+
spot_start = on_index.valueDate(fixing)
|
281
|
+
|
282
|
+
start_shifted = (start_date <= calculation_date)
|
283
|
+
eff_start = start_date if not start_shifted else spot_start
|
284
|
+
|
285
|
+
if start_shifted:
|
286
|
+
# shift end by the same calendar-day offset
|
287
|
+
try:
|
288
|
+
day_offset = int(eff_start - start_date)
|
289
|
+
except Exception:
|
290
|
+
day_offset = eff_start.serialNumber() - start_date.serialNumber()
|
291
|
+
eff_end = cal.advance(maturity_date, int(day_offset), ql.Days)
|
292
|
+
else:
|
293
|
+
eff_end = maturity_date
|
294
|
+
|
295
|
+
if eff_end <= eff_start:
|
296
|
+
eff_end = cal.advance(eff_start, fixed_leg_tenor)
|
297
|
+
|
298
|
+
fixed_sched = ql.Schedule(
|
299
|
+
eff_start, eff_end, fixed_leg_tenor, cal,
|
300
|
+
fixed_leg_convention, fixed_leg_convention,
|
301
|
+
ql.DateGeneration.Forward, False
|
302
|
+
)
|
303
|
+
|
304
|
+
ois = ql.OvernightIndexedSwap(
|
305
|
+
ql.OvernightIndexedSwap.Payer,
|
306
|
+
notional,
|
307
|
+
fixed_sched,
|
308
|
+
fixed_rate,
|
309
|
+
fixed_leg_daycount,
|
310
|
+
on_index
|
311
|
+
)
|
312
|
+
ois.setPricingEngine(ql.DiscountingSwapEngine(curve))
|
313
|
+
return ois
|
314
|
+
|
315
|
+
|
316
|
+
import math
|
317
|
+
|
318
|
+
# --- add in: src/pricing_models/swap_pricer.py ---
|
319
|
+
def debug_swap_coupons(
|
320
|
+
swap: ql.VanillaSwap,
|
321
|
+
curve: ql.YieldTermStructureHandle,
|
322
|
+
ibor_index: ql.IborIndex,
|
323
|
+
header: str = "[SWAP COUPON DEBUG]",
|
324
|
+
show_past: bool = True,
|
325
|
+
max_rows: int | None = None,
|
326
|
+
) -> None:
|
327
|
+
"""
|
328
|
+
Print coupon-by-coupon diagnostics for fixed and floating legs:
|
329
|
+
accrual dates, fixing date, year fractions, forward/used rate, amount, DF and PV.
|
330
|
+
"""
|
331
|
+
print("\n" + header)
|
332
|
+
asof = ql.Settings.instance().evaluationDate
|
333
|
+
print(f"evalDate: {_fmt(asof)} notional: {swap.nominal()}")
|
334
|
+
print(f"index: {ibor_index.name()} tenor: {ibor_index.tenor().length()} {ibor_index.tenor().units()} "
|
335
|
+
f"DC(float)={type(ibor_index.dayCounter()).__name__}")
|
336
|
+
|
337
|
+
# ---- fixed leg ----
|
338
|
+
print("\n[FIXED LEG]")
|
339
|
+
fixed_dc = swap.fixedDayCount()
|
340
|
+
fixed_rate = swap.fixedRate()
|
341
|
+
print(f"fixed rate input: {fixed_rate:.8f} DC(fixed)={type(fixed_dc).__name__}")
|
342
|
+
|
343
|
+
print(f"{'#':>3} {'accrualStart':>12} {'accrualEnd':>12} {'pay':>12} {'tau':>8} "
|
344
|
+
f"{'df':>12} {'amount':>14} {'pv':>14}")
|
345
|
+
print("-" * 96)
|
346
|
+
pv_fixed = 0.0
|
347
|
+
rows = 0
|
348
|
+
for i, cf in enumerate(swap.leg(0)):
|
349
|
+
c: ql.FixedRateCoupon = ql.as_fixed_rate_coupon(cf)
|
350
|
+
if (not show_past) and cf.hasOccurred(asof):
|
351
|
+
continue
|
352
|
+
tau = fixed_dc.yearFraction(c.accrualStartDate(), c.accrualEndDate())
|
353
|
+
df = curve.discount(c.date())
|
354
|
+
amt = c.amount() # already notional * rate * tau
|
355
|
+
pv = amt * df
|
356
|
+
pv_fixed += pv
|
357
|
+
print(f"{i:3d} {_fmt(c.accrualStartDate()):>12} {_fmt(c.accrualEndDate()):>12} {_fmt(c.date()):>12} "
|
358
|
+
f"{tau:8.5f} {df:12.8f} {amt:14.2f} {pv:14.2f}")
|
359
|
+
rows += 1
|
360
|
+
if max_rows and rows >= max_rows:
|
361
|
+
break
|
362
|
+
|
363
|
+
# ---- float leg ----
|
364
|
+
print("\n[FLOAT LEG]")
|
365
|
+
f_dc = ibor_index.dayCounter()
|
366
|
+
print(f"spread: {swap.spread():.8f} DC(float)={type(f_dc).__name__}")
|
367
|
+
print(f"{'#':>3} {'fix':>12} {'accrualStart':>12} {'accrualEnd':>12} {'pay':>12} {'tau':>8} "
|
368
|
+
f"{'hasFix':>6} {'idxUsed':>10} {'fwdCurve':>10} {'rateUsed':>10} {'df':>12} {'amount':>14} {'pv':>14}")
|
369
|
+
print("-" * 144)
|
370
|
+
pv_float = 0.0
|
371
|
+
rows = 0
|
372
|
+
for i, cf in enumerate(swap.leg(1)):
|
373
|
+
c: ql.FloatingRateCoupon = ql.as_floating_rate_coupon(cf)
|
374
|
+
if (not show_past) and cf.hasOccurred(asof):
|
375
|
+
continue
|
376
|
+
tau = f_dc.yearFraction(c.accrualStartDate(), c.accrualEndDate())
|
377
|
+
df = curve.discount(c.date())
|
378
|
+
|
379
|
+
# curve-implied forward over this exact accrual
|
380
|
+
df0 = curve.discount(c.accrualStartDate())
|
381
|
+
df1 = curve.discount(c.accrualEndDate())
|
382
|
+
fwd = (df0/df1 - 1.0) / max(tau, 1e-12)
|
383
|
+
|
384
|
+
# fixing available?
|
385
|
+
has_fix = False
|
386
|
+
idx_used = math.nan
|
387
|
+
try:
|
388
|
+
idx_used = ibor_index.fixing(c.fixingDate())
|
389
|
+
has_fix = True
|
390
|
+
except RuntimeError:
|
391
|
+
pass
|
392
|
+
|
393
|
+
rate_used = c.rate() # will be fixing (if present) else forward + spread
|
394
|
+
amt = c.amount()
|
395
|
+
pv = amt * df
|
396
|
+
pv_float += pv
|
397
|
+
|
398
|
+
print(f"{i:3d} {_fmt(c.fixingDate()):>12} {_fmt(c.accrualStartDate()):>12} {_fmt(c.accrualEndDate()):>12} "
|
399
|
+
f"{_fmt(c.date()):>12} {tau:8.5f} {str(has_fix):>6} "
|
400
|
+
f"{(idx_used if has_fix else float('nan')):10.6f} {fwd:10.6f} {rate_used:10.6f} "
|
401
|
+
f"{df:12.8f} {amt:14.2f} {pv:14.2f}")
|
402
|
+
|
403
|
+
rows += 1
|
404
|
+
if max_rows and rows >= max_rows:
|
405
|
+
break
|
406
|
+
|
407
|
+
# ---- summary / par checks ----
|
408
|
+
print("\n[PV DECOMP]")
|
409
|
+
print(f"PV_fixed : {pv_fixed:,.2f}")
|
410
|
+
print(f"PV_float : {pv_float:,.2f}")
|
411
|
+
print(f"NPV (QL) : {swap.NPV():,.2f}")
|
412
|
+
|
413
|
+
# annuity of the fixed leg (Σ tau_i * df_i on fixed pay dates)
|
414
|
+
annuity = 0.0
|
415
|
+
for cf in swap.leg(0):
|
416
|
+
c = ql.as_fixed_rate_coupon(cf)
|
417
|
+
tau = fixed_dc.yearFraction(c.accrualStartDate(), c.accrualEndDate())
|
418
|
+
df = curve.discount(c.date())
|
419
|
+
annuity += tau * df
|
420
|
+
|
421
|
+
# coupon-by-coupon float PV using curve forwards (no spread)
|
422
|
+
float_pv_curve = 0.0
|
423
|
+
for cf in swap.leg(1):
|
424
|
+
c = ql.as_floating_rate_coupon(cf)
|
425
|
+
tau = f_dc.yearFraction(c.accrualStartDate(), c.accrualEndDate())
|
426
|
+
df = curve.discount(c.date())
|
427
|
+
df0 = curve.discount(c.accrualStartDate())
|
428
|
+
df1 = curve.discount(c.accrualEndDate())
|
429
|
+
fwd = (df0/df1 - 1.0) / max(tau, 1e-12)
|
430
|
+
float_pv_curve += swap.nominal() * (fwd + c.spread()) * tau * df
|
431
|
+
|
432
|
+
# par fixed rate from curve coupons
|
433
|
+
par_from_coupons = float_pv_curve / max(annuity * swap.nominal(), 1e-12)
|
434
|
+
|
435
|
+
# also the classic (1 - DF(T)) / annuity formula (works when float = same curve, no stubs)
|
436
|
+
# approximate float PV = notional*(DF(start) - DF(end))
|
437
|
+
try:
|
438
|
+
start = ql.as_floating_rate_coupon(swap.leg(1)[0]).accrualStartDate()
|
439
|
+
end = ql.as_floating_rate_coupon(swap.leg(1)[-1]).accrualEndDate()
|
440
|
+
par_alt = (curve.discount(start) - curve.discount(end)) / max(annuity, 1e-12)
|
441
|
+
except Exception:
|
442
|
+
par_alt = float('nan')
|
443
|
+
|
444
|
+
print(f"Annuity (fixed) : {annuity:,.8f}")
|
445
|
+
print(f"Par (from curve coupons) : {par_from_coupons:,.8f}")
|
446
|
+
print(f"Par (alt 1-DF/annuity) : {par_alt:,.8f}")
|
447
|
+
print(f"Par (QL fairRate) : {swap.fairRate():,.8f}")
|
448
|
+
print()
|
449
|
+
|
450
|
+
def _fmt(qld: ql.Date) -> str:
|
451
|
+
return f"{qld.year():04d}-{qld.month():02d}-{qld.dayOfMonth():02d}"
|
452
|
+
|
453
|
+
def debug_tiie_zero_curve(
|
454
|
+
calculation_date: ql.Date,
|
455
|
+
curve: ql.YieldTermStructureHandle,
|
456
|
+
cal: ql.Calendar | None = None,
|
457
|
+
day_count: ql.DayCounter | None = None,
|
458
|
+
sample_months: list[int] | None = None,
|
459
|
+
header: str = "[TIIE ZERO CURVE DEBUG]"
|
460
|
+
) -> None:
|
461
|
+
"""
|
462
|
+
Print a readable snapshot of the zero curve: sample maturities, DFs and zero rates.
|
463
|
+
"""
|
464
|
+
print("\n" + header)
|
465
|
+
ql.Settings.instance().evaluationDate = calculation_date
|
466
|
+
cal = cal or (ql.Mexico() if hasattr(ql, "Mexico") else ql.TARGET())
|
467
|
+
dc = day_count or ql.Actual360()
|
468
|
+
|
469
|
+
asof = calculation_date
|
470
|
+
print(f"asof (evalDate): {_fmt(asof)}")
|
471
|
+
try:
|
472
|
+
link = curve.currentLink()
|
473
|
+
print(f"curve link: {type(link).__name__} (extrap={'Yes' if link.allowsExtrapolation() else 'No'})")
|
474
|
+
except Exception:
|
475
|
+
pass
|
476
|
+
|
477
|
+
# sampling grid
|
478
|
+
if sample_months is None:
|
479
|
+
sample_months = [1, 2, 3, 6, 9, 12,
|
480
|
+
18, 24, 36, 48, 60, 84] # up to 7Y
|
481
|
+
|
482
|
+
print(f"{'m (M)':>6} {'date':>12} {'T(yr)':>8} {'DF':>12} {'Zero(%)':>10}")
|
483
|
+
print("-" * 52)
|
484
|
+
for m in sample_months:
|
485
|
+
d = cal.advance(asof, ql.Period(m, ql.Months))
|
486
|
+
T = dc.yearFraction(asof, d)
|
487
|
+
df = curve.discount(d)
|
488
|
+
z = curve.zeroRate(d, dc, ql.Continuous, ql.Annual).rate() * 100.0
|
489
|
+
print(f"{m:6d} {_fmt(d):>12} {T:8.5f} {df:12.8f} {z:10.4f}")
|
490
|
+
print()
|
491
|
+
|
492
|
+
def debug_tiie_trade(
|
493
|
+
valuation_date: ql.Date,
|
494
|
+
swap: ql.VanillaSwap,
|
495
|
+
curve: ql.YieldTermStructureHandle,
|
496
|
+
ibor_index: ql.IborIndex
|
497
|
+
) -> None:
|
498
|
+
"""
|
499
|
+
Convenience wrapper: dump curve snapshot and full coupon drilldown for a TIIE swap.
|
500
|
+
"""
|
501
|
+
debug_tiie_zero_curve(valuation_date, curve)
|
502
|
+
debug_swap_coupons(swap, curve, ibor_index)
|
@@ -0,0 +1,175 @@
|
|
1
|
+
# settings.py
|
2
|
+
from __future__ import annotations
|
3
|
+
|
4
|
+
import json
|
5
|
+
import os
|
6
|
+
import sys
|
7
|
+
from pathlib import Path
|
8
|
+
from types import SimpleNamespace
|
9
|
+
|
10
|
+
# ---------------- App identity & app dirs ----------------
|
11
|
+
APP_VENDOR = "mainsequence"
|
12
|
+
APP_NAME = "instruments"
|
13
|
+
APP_ID = f"{APP_VENDOR}/{APP_NAME}"
|
14
|
+
|
15
|
+
# All environment variables use this prefix now.
|
16
|
+
ENV_PREFIX = "MSI" # e.g., MSI_CONFIG_FILE, MSI_DATA_BACKEND
|
17
|
+
ENV_CONFIG_FILE = f"{ENV_PREFIX}_CONFIG_FILE"
|
18
|
+
|
19
|
+
def _user_config_root() -> Path:
|
20
|
+
if sys.platform == "win32":
|
21
|
+
base = Path(os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"))
|
22
|
+
elif sys.platform == "darwin":
|
23
|
+
base = Path.home() / "Library" / "Application Support"
|
24
|
+
else:
|
25
|
+
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
26
|
+
return (base / APP_VENDOR / APP_NAME).resolve()
|
27
|
+
|
28
|
+
APP_ROOT = _user_config_root()
|
29
|
+
# (No POSITIONS_DIR / BUILDS_DIR / DATA_DIR and no bulk mkdir here.)
|
30
|
+
|
31
|
+
# ---------------- tiny config loader (stdlib only) ----------------
|
32
|
+
def _load_toml(text: str) -> dict:
|
33
|
+
try:
|
34
|
+
import tomllib # py311+
|
35
|
+
return tomllib.loads(text)
|
36
|
+
except Exception:
|
37
|
+
return {}
|
38
|
+
|
39
|
+
def _load_file_config() -> dict:
|
40
|
+
candidates: list[Path] = []
|
41
|
+
# 1) explicit path via MSI_CONFIG_FILE
|
42
|
+
env_cfg = os.getenv(ENV_CONFIG_FILE)
|
43
|
+
if env_cfg:
|
44
|
+
candidates.append(Path(env_cfg).expanduser())
|
45
|
+
|
46
|
+
# 2) project-local
|
47
|
+
candidates += [Path("./instruments.toml"), Path("./instruments.json")]
|
48
|
+
|
49
|
+
# 3) user config root
|
50
|
+
candidates += [APP_ROOT / "config.toml", APP_ROOT / "config.json"]
|
51
|
+
|
52
|
+
for p in candidates:
|
53
|
+
try:
|
54
|
+
if not p.exists():
|
55
|
+
continue
|
56
|
+
s = p.read_text(encoding="utf-8")
|
57
|
+
if p.suffix.lower() == ".toml":
|
58
|
+
return _load_toml(s) or {}
|
59
|
+
if p.suffix.lower() == ".json":
|
60
|
+
return json.loads(s)
|
61
|
+
except Exception:
|
62
|
+
pass
|
63
|
+
return {}
|
64
|
+
|
65
|
+
# ---------------- default TOML (written only if no config exists) ----------------
|
66
|
+
DEFAULT_TOML = """# instruments.toml — defaults for MainSequence Instruments
|
67
|
+
|
68
|
+
DISCOUNT_CURVES_TABLE = "discount_curves"
|
69
|
+
REFERENCE_RATES_FIXING_TABLE = "fixing_rates_1d"
|
70
|
+
|
71
|
+
TIIE_28_ZERO_CURVE = "F_TIIE_28_VALMER"
|
72
|
+
M_BONOS_ZERO_CURVE = "M_BONOS_ZERO_OTR"
|
73
|
+
|
74
|
+
TIIE_28_UID = "TIIE_28"
|
75
|
+
TIIE_91_UID = "TIIE_91"
|
76
|
+
TIIE_182_UID = "TIIE_182"
|
77
|
+
TIIE_OVERNIGHT_UID = "TIIE_OVERNIGHT"
|
78
|
+
|
79
|
+
CETE_28_UID = "CETE_28"
|
80
|
+
CETE_91_UID = "CETE_91"
|
81
|
+
CETE_182_UID = "CETE_182"
|
82
|
+
|
83
|
+
[data]
|
84
|
+
backend = "mock"
|
85
|
+
|
86
|
+
[files]
|
87
|
+
tiie_zero_csv = ""
|
88
|
+
tiie28_fixings_csv = ""
|
89
|
+
"""
|
90
|
+
|
91
|
+
def _existing_config_path() -> Path | None:
|
92
|
+
env_cfg = os.getenv(ENV_CONFIG_FILE)
|
93
|
+
if env_cfg:
|
94
|
+
p = Path(env_cfg).expanduser()
|
95
|
+
if p.exists():
|
96
|
+
return p
|
97
|
+
for p in (
|
98
|
+
Path("./instruments.toml"),
|
99
|
+
Path("./instruments.json"),
|
100
|
+
APP_ROOT / "config.toml",
|
101
|
+
APP_ROOT / "config.json",
|
102
|
+
):
|
103
|
+
if p.exists():
|
104
|
+
return p
|
105
|
+
return None
|
106
|
+
|
107
|
+
def _ensure_default_config_file() -> Path | None:
|
108
|
+
"""If no config exists anywhere, create one. Never overwrites existing."""
|
109
|
+
if _existing_config_path() is not None:
|
110
|
+
return None
|
111
|
+
target = Path(os.getenv(ENV_CONFIG_FILE, APP_ROOT / "config.toml")).expanduser()
|
112
|
+
try:
|
113
|
+
target.parent.mkdir(parents=True, exist_ok=True) # ensure parent dir only
|
114
|
+
if not target.exists():
|
115
|
+
target.write_text(DEFAULT_TOML, encoding="utf-8")
|
116
|
+
except Exception:
|
117
|
+
return None
|
118
|
+
return target
|
119
|
+
|
120
|
+
# Create a default config file if none is present anywhere.
|
121
|
+
_ensure_default_config_file()
|
122
|
+
|
123
|
+
# Now load the config (env still overrides)
|
124
|
+
_CFG = _load_file_config()
|
125
|
+
|
126
|
+
def _get(key: str, default: str) -> str:
|
127
|
+
# Env overrides config file (MSI_<KEY>)
|
128
|
+
v = os.getenv(f"{ENV_PREFIX}_{key}")
|
129
|
+
if v is not None:
|
130
|
+
return v
|
131
|
+
try:
|
132
|
+
section, leaf = key.lower().split(".", 1)
|
133
|
+
return _CFG.get(section, {}).get(leaf, default)
|
134
|
+
except Exception:
|
135
|
+
return _CFG.get(key, default)
|
136
|
+
|
137
|
+
# ---------------- Your existing constants (with overrides) ----------------
|
138
|
+
# Tables
|
139
|
+
DISCOUNT_CURVES_TABLE = _get("DISCOUNT_CURVES_TABLE", "discount_curves")
|
140
|
+
REFERENCE_RATES_FIXING_TABLE = _get("REFERENCE_RATES_FIXING_TABLE", "fixing_rates_1d")
|
141
|
+
|
142
|
+
# Curve identifiers
|
143
|
+
TIIE_28_ZERO_CURVE = _get("TIIE_28_ZERO_CURVE", "F_TIIE_28_VALMER")
|
144
|
+
M_BONOS_ZERO_CURVE = _get("M_BONOS_ZERO_CURVE", "M_BONOS_ZERO_OTR")
|
145
|
+
|
146
|
+
# Index UIDs
|
147
|
+
TIIE_28_UID = _get("TIIE_28_UID", "TIIE_28")
|
148
|
+
TIIE_91_UID = _get("TIIE_91_UID", "TIIE_91")
|
149
|
+
TIIE_182_UID = _get("TIIE_182_UID", "TIIE_182")
|
150
|
+
TIIE_OVERNIGHT_UID = _get("TIIE_OVERNIGHT_UID", "TIIE_OVERNIGHT")
|
151
|
+
|
152
|
+
CETE_28_UID = _get("CETE_28_UID", "CETE_28")
|
153
|
+
CETE_91_UID = _get("CETE_91_UID", "CETE_91")
|
154
|
+
CETE_182_UID = _get("CETE_182_UID", "CETE_182")
|
155
|
+
|
156
|
+
# Optional file locations (let your code decide how to use them)
|
157
|
+
TIIE_ZERO_CSV = (_CFG.get("files", {}) or {}).get("tiie_zero_csv")
|
158
|
+
TIIE28_FIXINGS_CSV = (_CFG.get("files", {}) or {}).get("tiie28_fixings_csv")
|
159
|
+
|
160
|
+
# ---------------- Convenience namespaces for legacy import sites ------------
|
161
|
+
indices = SimpleNamespace(
|
162
|
+
TIIE_28_UID=TIIE_28_UID,
|
163
|
+
TIIE_91_UID=TIIE_91_UID,
|
164
|
+
TIIE_182_UID=TIIE_182_UID,
|
165
|
+
TIIE_OVERNIGHT_UID=TIIE_OVERNIGHT_UID,
|
166
|
+
CETE_28_UID=CETE_28_UID,
|
167
|
+
CETE_91_UID=CETE_91_UID,
|
168
|
+
CETE_182_UID=CETE_182_UID,
|
169
|
+
)
|
170
|
+
curves = SimpleNamespace(
|
171
|
+
TIIE_28_ZERO_CURVE=TIIE_28_ZERO_CURVE,
|
172
|
+
M_BONOS_ZERO_CURVE=M_BONOS_ZERO_CURVE,
|
173
|
+
)
|
174
|
+
DATA_BACKEND = os.getenv(f"{ENV_PREFIX}_DATA_BACKEND", (_CFG.get("data", {}) or {}).get("backend", "mainsequence"))
|
175
|
+
data = SimpleNamespace(backend=DATA_BACKEND)
|
@@ -0,0 +1,29 @@
|
|
1
|
+
import datetime
|
2
|
+
import QuantLib as ql
|
3
|
+
import pytz
|
4
|
+
|
5
|
+
|
6
|
+
def to_ql_date(dt: datetime.date) -> ql.Date:
|
7
|
+
"""
|
8
|
+
Converts a Python datetime.date object to a QuantLib.Date object.
|
9
|
+
|
10
|
+
Args:
|
11
|
+
dt: The datetime.date object to convert.
|
12
|
+
|
13
|
+
Returns:
|
14
|
+
The corresponding QuantLib.Date object.
|
15
|
+
"""
|
16
|
+
return ql.Date(dt.day, dt.month, dt.year)
|
17
|
+
|
18
|
+
def to_py_date(qld: ql.Date) -> datetime.date:
|
19
|
+
"""
|
20
|
+
Converts a QuantLib.Date object to a Python datetime.date object.
|
21
|
+
|
22
|
+
Args:
|
23
|
+
qld: The QuantLib.Date object to convert.
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
The corresponding datetime.date object.
|
27
|
+
"""
|
28
|
+
return datetime.datetime(qld.year(), qld.month(), qld.dayOfMonth(),tzinfo=pytz.utc)
|
29
|
+
|