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.
Files changed (110) hide show
  1. mainsequence/__init__.py +0 -0
  2. mainsequence/__main__.py +9 -0
  3. mainsequence/cli/__init__.py +1 -0
  4. mainsequence/cli/api.py +157 -0
  5. mainsequence/cli/cli.py +442 -0
  6. mainsequence/cli/config.py +78 -0
  7. mainsequence/cli/ssh_utils.py +126 -0
  8. mainsequence/client/__init__.py +17 -0
  9. mainsequence/client/base.py +431 -0
  10. mainsequence/client/data_sources_interfaces/__init__.py +0 -0
  11. mainsequence/client/data_sources_interfaces/duckdb.py +1468 -0
  12. mainsequence/client/data_sources_interfaces/timescale.py +479 -0
  13. mainsequence/client/models_helpers.py +113 -0
  14. mainsequence/client/models_report_studio.py +412 -0
  15. mainsequence/client/models_tdag.py +2276 -0
  16. mainsequence/client/models_vam.py +1983 -0
  17. mainsequence/client/utils.py +387 -0
  18. mainsequence/dashboards/__init__.py +0 -0
  19. mainsequence/dashboards/streamlit/__init__.py +0 -0
  20. mainsequence/dashboards/streamlit/assets/config.toml +12 -0
  21. mainsequence/dashboards/streamlit/assets/favicon.png +0 -0
  22. mainsequence/dashboards/streamlit/assets/logo.png +0 -0
  23. mainsequence/dashboards/streamlit/core/__init__.py +0 -0
  24. mainsequence/dashboards/streamlit/core/theme.py +212 -0
  25. mainsequence/dashboards/streamlit/pages/__init__.py +0 -0
  26. mainsequence/dashboards/streamlit/scaffold.py +220 -0
  27. mainsequence/instrumentation/__init__.py +7 -0
  28. mainsequence/instrumentation/utils.py +101 -0
  29. mainsequence/instruments/__init__.py +1 -0
  30. mainsequence/instruments/data_interface/__init__.py +10 -0
  31. mainsequence/instruments/data_interface/data_interface.py +361 -0
  32. mainsequence/instruments/instruments/__init__.py +3 -0
  33. mainsequence/instruments/instruments/base_instrument.py +85 -0
  34. mainsequence/instruments/instruments/bond.py +447 -0
  35. mainsequence/instruments/instruments/european_option.py +74 -0
  36. mainsequence/instruments/instruments/interest_rate_swap.py +217 -0
  37. mainsequence/instruments/instruments/json_codec.py +585 -0
  38. mainsequence/instruments/instruments/knockout_fx_option.py +146 -0
  39. mainsequence/instruments/instruments/position.py +475 -0
  40. mainsequence/instruments/instruments/ql_fields.py +239 -0
  41. mainsequence/instruments/instruments/vanilla_fx_option.py +107 -0
  42. mainsequence/instruments/pricing_models/__init__.py +0 -0
  43. mainsequence/instruments/pricing_models/black_scholes.py +49 -0
  44. mainsequence/instruments/pricing_models/bond_pricer.py +182 -0
  45. mainsequence/instruments/pricing_models/fx_option_pricer.py +90 -0
  46. mainsequence/instruments/pricing_models/indices.py +350 -0
  47. mainsequence/instruments/pricing_models/knockout_fx_pricer.py +209 -0
  48. mainsequence/instruments/pricing_models/swap_pricer.py +502 -0
  49. mainsequence/instruments/settings.py +175 -0
  50. mainsequence/instruments/utils.py +29 -0
  51. mainsequence/logconf.py +284 -0
  52. mainsequence/reportbuilder/__init__.py +0 -0
  53. mainsequence/reportbuilder/__main__.py +0 -0
  54. mainsequence/reportbuilder/examples/ms_template_report.py +706 -0
  55. mainsequence/reportbuilder/model.py +713 -0
  56. mainsequence/reportbuilder/slide_templates.py +532 -0
  57. mainsequence/tdag/__init__.py +8 -0
  58. mainsequence/tdag/__main__.py +0 -0
  59. mainsequence/tdag/config.py +129 -0
  60. mainsequence/tdag/data_nodes/__init__.py +12 -0
  61. mainsequence/tdag/data_nodes/build_operations.py +751 -0
  62. mainsequence/tdag/data_nodes/data_nodes.py +1292 -0
  63. mainsequence/tdag/data_nodes/persist_managers.py +812 -0
  64. mainsequence/tdag/data_nodes/run_operations.py +543 -0
  65. mainsequence/tdag/data_nodes/utils.py +24 -0
  66. mainsequence/tdag/future_registry.py +25 -0
  67. mainsequence/tdag/utils.py +40 -0
  68. mainsequence/virtualfundbuilder/__init__.py +45 -0
  69. mainsequence/virtualfundbuilder/__main__.py +235 -0
  70. mainsequence/virtualfundbuilder/agent_interface.py +77 -0
  71. mainsequence/virtualfundbuilder/config_handling.py +86 -0
  72. mainsequence/virtualfundbuilder/contrib/__init__.py +0 -0
  73. mainsequence/virtualfundbuilder/contrib/apps/__init__.py +8 -0
  74. mainsequence/virtualfundbuilder/contrib/apps/etf_replicator_app.py +164 -0
  75. mainsequence/virtualfundbuilder/contrib/apps/generate_report.py +292 -0
  76. mainsequence/virtualfundbuilder/contrib/apps/load_external_portfolio.py +107 -0
  77. mainsequence/virtualfundbuilder/contrib/apps/news_app.py +437 -0
  78. mainsequence/virtualfundbuilder/contrib/apps/portfolio_report_app.py +91 -0
  79. mainsequence/virtualfundbuilder/contrib/apps/portfolio_table.py +95 -0
  80. mainsequence/virtualfundbuilder/contrib/apps/run_named_portfolio.py +45 -0
  81. mainsequence/virtualfundbuilder/contrib/apps/run_portfolio.py +40 -0
  82. mainsequence/virtualfundbuilder/contrib/apps/templates/base.html +147 -0
  83. mainsequence/virtualfundbuilder/contrib/apps/templates/report.html +77 -0
  84. mainsequence/virtualfundbuilder/contrib/data_nodes/__init__.py +5 -0
  85. mainsequence/virtualfundbuilder/contrib/data_nodes/external_weights.py +61 -0
  86. mainsequence/virtualfundbuilder/contrib/data_nodes/intraday_trend.py +149 -0
  87. mainsequence/virtualfundbuilder/contrib/data_nodes/market_cap.py +310 -0
  88. mainsequence/virtualfundbuilder/contrib/data_nodes/mock_signal.py +78 -0
  89. mainsequence/virtualfundbuilder/contrib/data_nodes/portfolio_replicator.py +269 -0
  90. mainsequence/virtualfundbuilder/contrib/prices/__init__.py +1 -0
  91. mainsequence/virtualfundbuilder/contrib/prices/data_nodes.py +810 -0
  92. mainsequence/virtualfundbuilder/contrib/prices/utils.py +11 -0
  93. mainsequence/virtualfundbuilder/contrib/rebalance_strategies/__init__.py +1 -0
  94. mainsequence/virtualfundbuilder/contrib/rebalance_strategies/rebalance_strategies.py +313 -0
  95. mainsequence/virtualfundbuilder/data_nodes.py +637 -0
  96. mainsequence/virtualfundbuilder/enums.py +23 -0
  97. mainsequence/virtualfundbuilder/models.py +282 -0
  98. mainsequence/virtualfundbuilder/notebook_handling.py +42 -0
  99. mainsequence/virtualfundbuilder/portfolio_interface.py +272 -0
  100. mainsequence/virtualfundbuilder/resource_factory/__init__.py +0 -0
  101. mainsequence/virtualfundbuilder/resource_factory/app_factory.py +170 -0
  102. mainsequence/virtualfundbuilder/resource_factory/base_factory.py +238 -0
  103. mainsequence/virtualfundbuilder/resource_factory/rebalance_factory.py +101 -0
  104. mainsequence/virtualfundbuilder/resource_factory/signal_factory.py +183 -0
  105. mainsequence/virtualfundbuilder/utils.py +381 -0
  106. mainsequence-2.0.0.dist-info/METADATA +105 -0
  107. mainsequence-2.0.0.dist-info/RECORD +110 -0
  108. mainsequence-2.0.0.dist-info/WHEEL +5 -0
  109. mainsequence-2.0.0.dist-info/licenses/LICENSE +40 -0
  110. mainsequence-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,310 @@
1
+ from mainsequence.tdag.data_nodes import DataNode, APIDataNode, WrapperDataNode
2
+ from mainsequence.client import CONSTANTS, Asset, AssetTranslationTable, AssetTranslationRule, AssetFilter, DoesNotExist
3
+
4
+ from datetime import datetime, timedelta, tzinfo
5
+ from typing import Optional, List, Union, Dict
6
+
7
+ import pandas as pd
8
+ import pytz
9
+
10
+ from mainsequence.tdag.data_nodes import DataNode
11
+ from mainsequence.client import CONSTANTS, Asset, AssetCategory
12
+
13
+ from mainsequence.virtualfundbuilder.models import VFBConfigBaseModel
14
+ from mainsequence.virtualfundbuilder.resource_factory.signal_factory import WeightsBase, register_signal_class
15
+ from mainsequence.virtualfundbuilder.utils import TIMEDELTA
16
+ import numpy as np
17
+ from pydantic import BaseModel
18
+
19
+ class SymbolWeight(VFBConfigBaseModel):
20
+ execution_venue_symbol: str = CONSTANTS.ALPACA_EV_SYMBOL
21
+ symbol: str
22
+ weight: float
23
+
24
+ @register_signal_class(register_in_agent=True)
25
+ class FixedWeights(WeightsBase, DataNode):
26
+
27
+ def __init__(self, asset_symbol_weights: List[SymbolWeight], *args, **kwargs):
28
+ """
29
+ Args:
30
+ asset_symbol_weights (List[SymbolWeight]): List of SymbolWeights that map asset symbols to weights
31
+ """
32
+ super().__init__(*args, **kwargs)
33
+ self.asset_symbol_weights = asset_symbol_weights
34
+
35
+ def maximum_forward_fill(self):
36
+ return timedelta(days=200 * 365) # Always forward-fill to avoid filling the DB
37
+
38
+ def get_explanation(self):
39
+ max_rows = 10
40
+ symbols = [w.symbol for w in self.asset_symbol_weights]
41
+ weights = [w.weight for w in self.asset_symbol_weights]
42
+ info = f"<p>{self.__class__.__name__}: Signal uses fixed weights with the following weights:</p><div style='display: flex;'>"
43
+
44
+ for i in range(0, len(symbols), max_rows):
45
+ info += "<table border='1' style='border-collapse: collapse; margin-right: 20px;'><tr>"
46
+ info += ''.join(f"<th>{sym}</th>" for sym in symbols[i:i + max_rows])
47
+ info += "</tr><tr>"
48
+ info += ''.join(f"<td>{wgt}</td>" for wgt in weights[i:i + max_rows])
49
+ info += "</tr></table>"
50
+
51
+ info += "</div>"
52
+ return info
53
+
54
+ def update(self, latest_value: Union[datetime, None], *args, **kwargs) -> pd.DataFrame:
55
+ if latest_value is not None:
56
+ return pd.DataFrame() # No need to store more than one constant weight
57
+ latest_value = latest_value or datetime(1985, 1, 1).replace(tzinfo=pytz.utc)
58
+
59
+ df = pd.DataFrame([m.model_dump() for m in self.asset_symbol_weights]).rename(columns={'symbol': 'asset_symbol',
60
+ 'weight': 'signal_weight'})
61
+ df = df.set_index(['asset_symbol', 'execution_venue_symbol'])
62
+
63
+ signals_weights = pd.concat(
64
+ [df],
65
+ axis=0,
66
+ keys=[latest_value]
67
+ ).rename_axis(["time_index", "asset_symbol", "execution_venue_symbol"])
68
+
69
+ signals_weights = signals_weights.dropna()
70
+ return signals_weights
71
+
72
+
73
+ class AssetMistMatch(Exception):
74
+ ...
75
+
76
+ class VolatilityControlConfiguration(BaseModel):
77
+ target_volatility: float = 0.1
78
+ ewm_span: int = 21
79
+ ann_factor: int = 252
80
+
81
+
82
+ @register_signal_class(register_in_agent=True)
83
+ class MarketCap(WeightsBase, DataNode):
84
+ def __init__(self,
85
+ volatility_control_configuration: Optional[VolatilityControlConfiguration],
86
+ minimum_atvr_ratio: float = .1,
87
+ rolling_atvr_volume_windows: List[int] = [60, 360],
88
+ frequency_trading_percent: float = .9,
89
+ source_frequency: str = "1d",
90
+ min_number_of_assets: int = 3,
91
+ num_top_assets: Optional[int] = None, *args, **kwargs):
92
+ """
93
+ Signal Weights using weighting by Market Capitalization or Equal Weights
94
+
95
+ Args:
96
+ source_frequency (str): Frequency of market cap source.
97
+ num_top_assets (Optional[int]): Number of largest assets by market cap to use for signals. Leave empty to include all assets.
98
+ """
99
+ super().__init__(*args, **kwargs)
100
+ self.source_frequency = source_frequency
101
+ self.num_top_assets = num_top_assets or 50000
102
+ self.minimum_atvr_ratio = minimum_atvr_ratio
103
+ self.rolling_atvr_volume_windows = rolling_atvr_volume_windows
104
+ self.frequency_trading_percent = frequency_trading_percent
105
+ self.min_number_of_assets = min_number_of_assets
106
+
107
+ translation_table = "marketcap_translation_table"
108
+ try:
109
+ # 1) fetch from server
110
+ translation_table = AssetTranslationTable.get(unique_identifier=translation_table)
111
+ except DoesNotExist:
112
+ self.logger.error(f"Translation table {translation_table} does not exist")
113
+
114
+ self.historical_market_cap_ts = WrapperDataNode(translation_table=translation_table)
115
+ self.volatility_control_configuration = volatility_control_configuration
116
+
117
+ def maximum_forward_fill(self):
118
+ return timedelta(days=1) - TIMEDELTA
119
+
120
+ def dependencies(self) -> Dict[str, Union["DataNode", "APIDataNode"]]:
121
+ return {"historical_market_cap_ts": self.historical_market_cap_ts}
122
+
123
+ def get_explanation(self):
124
+ # Convert the asset universe filter (assumed to be stored in self.asset_universe.asset_filter)
125
+ # to a formatted JSON string for display.
126
+ import json
127
+ windows_str = ", ".join(str(window) for window in self.rolling_atvr_volume_windows)
128
+ if self.volatility_control_configuration is not None:
129
+ volatility_details = self.volatility_control_configuration
130
+ vol_message = f"The strategy uses the following volatility target configuration:\n{volatility_details}\n"
131
+ else:
132
+ vol_message = "The strategy does not use volatility control.\n"
133
+
134
+ explanation = (
135
+ "### 1. Dynamic Asset Universe Selection\n\n"
136
+ f"This strategy dynamically selects assets using a predefined category {self.assets_configuration.assets_category_unique_id} :\n\n"
137
+
138
+ "### 2. Market Capitalization Filtering\n\n"
139
+ f"The strategy retrieves historical market capitalization data and restricts the universe to the top **{self.num_top_assets}** assets. "
140
+ "This ensures that only the largest and most influential market participants are considered.\n\n"
141
+
142
+ "### 3. Liquidity Filtering via Annualized Traded Value Ratio (ATVR)\n\n"
143
+ f"Liquidity is assessed using the Annualized Traded Value Ratio (ATVR), which compares an asset's annualized median trading volume to its market capitalization. "
144
+ f"To obtain a robust measure of liquidity, ATVR is computed over multiple rolling windows: **[{windows_str}]** days. "
145
+ f"An asset must achieve an ATVR of at least **{self.minimum_atvr_ratio:.2f}** in each of these windows to be considered liquid enough.\n\n"
146
+
147
+ "### 4. Trading Frequency Filter\n\n"
148
+ f"In addition, the strategy applies a trading frequency filter over the longest period defined by the rolling windows. "
149
+ f"Only assets with trading activity on at least **{self.frequency_trading_percent:.2f}** of the days (i.e., {self.frequency_trading_percent * 100:.1f}%) in the longest window are retained.\n\n"
150
+
151
+ "### 5. Portfolio Weight Construction\n\n"
152
+ "After filtering based on market capitalization, liquidity, and trading frequency, the market capitalizations of the remaining assets are normalized on a daily basis. "
153
+ "This normalization converts raw market values into portfolio weights, which serve as the signal for trading decisions.\n\n"
154
+
155
+ "### 6. Data Source Frequency\n\n"
156
+ f"The strategy uses market data that is updated at a **'{self.source_frequency}'** frequency. This ensures that the signals are generated using the most recent market conditions.\n\n"
157
+
158
+ "### 7. Volatility Target\n\n"
159
+ f"{vol_message}\n\n"
160
+ "**Summary:**\n"
161
+ f"This strategy dynamically selects assets using a specific filter, focuses on the top {self.num_top_assets} assets by market capitalization, and evaluates liquidity using ATVR computed over multiple rolling windows ({self.rolling_atvr_volume_windows}). "
162
+ f"Assets must achieve a minimum ATVR of {self.minimum_atvr_ratio:.2f} in each window and meet a trading frequency requirement of at least {self.frequency_trading_percent * 100:.1f}%. "
163
+ f"Finally, the market capitalizations of the filtered assets are normalized into portfolio weights, with market data refreshed at a '{self.source_frequency}' frequency."
164
+ )
165
+
166
+ return explanation
167
+
168
+ def get_asset_list(self) -> Union[None, list]:
169
+ asset_category = AssetCategory.get(unique_identifier=self.assets_configuration.assets_category_unique_id)
170
+
171
+ asset_list = Asset.filter(id__in=asset_category.assets)
172
+ return asset_list
173
+
174
+ def update(self):
175
+ """
176
+ Args:
177
+ latest_value (Union[datetime, None]): The timestamp of the most recent data point.
178
+
179
+ Returns:
180
+ DataFrame: A DataFrame containing updated signal weights, indexed by time and asset symbol.
181
+ """
182
+ asset_list = self.update_statistics.asset_list
183
+ if len(asset_list) < self.min_number_of_assets:
184
+ raise AssetMistMatch(f"only {len(asset_list)} in asset_list minum are {self.min_number_of_assets} ")
185
+
186
+ unique_identifier_range_market_cap_map = {
187
+ a.unique_identifier: {
188
+ "start_date": self.update_statistics[a.unique_identifier],
189
+ "start_date_operand": ">"
190
+ }
191
+ for a in asset_list
192
+ }
193
+ # Start Loop on unique identifier
194
+
195
+ ms_asset_list = Asset.filter_with_asset_class(exchange_code=None,
196
+ asset_ticker_group_id__in=[
197
+ a.asset_ticker_group_id
198
+ for a in
199
+ self.update_statistics.asset_list
200
+ ])
201
+
202
+ ms_asset_list = {a.asset_ticker_group_id:a for a in ms_asset_list}
203
+ asset_list_to_share_class = {a.asset_ticker_group_id:a for a in self.update_statistics.asset_list}
204
+
205
+ market_cap_uid_range_map = {
206
+ ms_asset.get_spot_reference_asset_unique_identifier(): unique_identifier_range_market_cap_map[asset_list_to_share_class[ms_share_class].unique_identifier]
207
+ for ms_share_class, ms_asset in ms_asset_list.items()
208
+ }
209
+
210
+ market_cap_uid_to_asset_uid = {
211
+ ms_asset.get_spot_reference_asset_unique_identifier(): asset_list_to_share_class[ms_share_class].unique_identifier
212
+ for ms_share_class, ms_asset in ms_asset_list.items()
213
+ }
214
+
215
+ mc = self.historical_market_cap_ts.get_df_between_dates(
216
+ unique_identifier_range_map=market_cap_uid_range_map,
217
+ great_or_equal=False,
218
+ )
219
+ mc = mc[~mc.index.duplicated(keep='first')]
220
+
221
+ if mc.shape[0] == 0:
222
+ self.logger.info("No data in Market Cap historical market cap")
223
+ return pd.DataFrame()
224
+
225
+ mc = mc.reset_index("unique_identifier")
226
+ mc["unique_identifier"] = mc["unique_identifier"].map(market_cap_uid_to_asset_uid)
227
+ mc = mc.set_index("unique_identifier", append=True)
228
+ # ends loop on unique identifier
229
+ unique_in_mc = mc.index.get_level_values("unique_identifier").unique().shape[0]
230
+
231
+ if unique_in_mc != len(asset_list):
232
+ self.logger.warning("Market Cap and asset_list does not match missing assets will be set to 0")
233
+
234
+ # If there is no market cap data, return an empty DataFrame.
235
+ if mc.shape[0] == 0:
236
+ return pd.DataFrame()
237
+
238
+ # 3. Pivot the market cap data to get a DataFrame with a datetime index and one column per asset.
239
+ mc_raw = mc.pivot_table(columns="unique_identifier", index="time_index")
240
+ mc_raw = mc_raw.ffill().bfill()
241
+
242
+ # 4. Using the prices dataframe, compute a rolling statistic on volume.
243
+ # We assume the "volume" column represents the traded volume.
244
+ # First, pivot prices so that rows are dates and columns are assets.
245
+ dollar_volume_df = mc_raw["volume"] * mc_raw["price"]
246
+
247
+ # 5. Compute the rolling ATVR for each window specified in self.rolling_atv_volume_windows.
248
+ # For each window, compute the median traded volume, annualize it and divide by market cap.
249
+ atvr_dict = {}
250
+ for window in self.rolling_atvr_volume_windows:
251
+ # Compute the rolling median of volume over the window.
252
+ rolling_median = dollar_volume_df.rolling(window=window, min_periods=1).median()
253
+
254
+ # Annualize: assume 252 trading days per year.
255
+ annual_factor = 252 # todo fix when prices are not daily
256
+ annualized_traded_value = rolling_median * annual_factor
257
+ # Align with market cap dates.
258
+ annualized_traded_value = annualized_traded_value.reindex(mc_raw.index).ffill().bfill()
259
+ # Compute the ATVR.
260
+ atvr_dict[window] = annualized_traded_value.div(mc_raw["market_cap"])
261
+
262
+ # 6. Create a liquidity mask that requires the ATVR to be above the minimum threshold
263
+ # for every rolling window.
264
+ atvr_masks = [atvr_dict[window] >= self.minimum_atvr_ratio for window in self.rolling_atvr_volume_windows]
265
+ # Combine the masks elementwise and re-wrap the result as a DataFrame with the same index/columns as mc_raw.
266
+ combined_atvr_mask = pd.DataFrame(
267
+ np.logical_and.reduce([mask.values for mask in atvr_masks]),
268
+ index=mc_raw.index,
269
+ columns=mc_raw.volume.columns
270
+ )
271
+
272
+ # 7. Compute the trading frequency mask.
273
+ # For frequency we assume that an asset "traded" on a day if its volume is > 0.
274
+ # We use the longest rolling window (e.g. 360 days) for the frequency computation.
275
+ freq_window = max(self.rolling_atvr_volume_windows)
276
+ trading_flag = dollar_volume_df.fillna(0) > 0
277
+ trading_frequency = trading_flag.rolling(window=freq_window, min_periods=1).mean()
278
+
279
+ frequency_mask = trading_frequency >= self.frequency_trading_percent
280
+
281
+ # 8. Combine the ATVR and frequency masks.
282
+ liquidity_mask = combined_atvr_mask & frequency_mask
283
+
284
+ # 9. (Optional) Select the top assets by market cap.
285
+ # For each date, rank assets by market cap and flag those outside the top 'self.num_top_assets'.
286
+ assets_excluded = mc_raw["market_cap"].rank(axis=1, ascending=False) > self.num_top_assets
287
+
288
+ # 10. Apply both the market cap ranking filter and the liquidity filter.
289
+ filtered_mc = mc_raw["market_cap"].copy()
290
+ filtered_mc[assets_excluded] = 0 # Exclude assets not in the top by market cap.
291
+ filtered_mc[~liquidity_mask] = 0 # Exclude assets that do not meet the liquidity criteria.
292
+
293
+ # 11. Compute the final weights by normalizing the surviving market caps.
294
+ weights = filtered_mc.div(filtered_mc.sum(axis=1), axis=0)
295
+ weights = weights.fillna(0)
296
+
297
+ if self.volatility_control_configuration is not None:
298
+ log_returns = (np.log(mc_raw["price"])).diff()
299
+
300
+ ewm_vol = (log_returns * weights).sum(axis=1).ewm(span=self.volatility_control_configuration.ewm_span,
301
+ adjust=False).std() * np.sqrt(self.volatility_control_configuration.ann_factor)
302
+
303
+ scaling_factor = self.volatility_control_configuration.target_volatility / ewm_vol
304
+ scaling_factor = scaling_factor.clip(upper=1.0)
305
+ weights = weights.mul(scaling_factor, axis=0)
306
+
307
+ # 12. Reshape the weights to a long-form DataFrame if desired.
308
+ signal_weights = weights.stack().rename("signal_weight").to_frame()
309
+
310
+ return signal_weights
@@ -0,0 +1,78 @@
1
+ from mainsequence.tdag.data_nodes import DataNode
2
+ from datetime import datetime
3
+ import pytz
4
+ import pandas as pd
5
+ from typing import Union
6
+ from mainsequence.virtualfundbuilder.resource_factory.signal_factory import WeightsBase, register_signal_class
7
+
8
+ @register_signal_class(register_in_agent=True)
9
+ class MockSignal(WeightsBase, DataNode):
10
+ """
11
+ Mock Signal to test strategies. Creates a signal with long/short of ETH and BTC in frequency.
12
+ """
13
+ def __init__(self, source_frequency: str = "30min", *args, **kwargs):
14
+ super().__init__(*args, **kwargs)
15
+
16
+ asset_mapping = {}
17
+ for tmp_asset_universe in self.asset_universe:
18
+ execution_venue = tmp_asset_universe.execution_venue_symbol
19
+ asset_list = tmp_asset_universe.asset_list
20
+ ev = execution_venue.value
21
+ asset_mapping[ev] = {
22
+ a.get_spot_reference_asset_symbol(): a.unique_identifier for a in asset_list
23
+ }
24
+ self.asset_1 = asset_list[0]
25
+ self.asset_2 = asset_list[1]
26
+ self.asset_mapping = asset_mapping
27
+ self.source_frequency = source_frequency
28
+
29
+ def get_explanation(self):
30
+ return f"The signal will switch between {self.asset_1.symbol} and {self.asset_2.symbol} randomly every 30 minutes"
31
+
32
+ def maximum_forward_fill(self):
33
+ return self.source_frequency
34
+
35
+ def update(self, latest_value: Union[datetime, None], *args, **kwargs) -> pd.DataFrame:
36
+ """
37
+ Args:
38
+ latest_value (Union[datetime, None]): The timestamp of the most recent data point.
39
+
40
+ Returns:
41
+ DataFrame: A DataFrame containing updated signal weights, indexed by time and asset symbol.
42
+ """
43
+ if latest_value is None:
44
+ latest_value = datetime(year=2017, month=1, day=1).replace(tzinfo=pytz.utc)
45
+
46
+ current_time = datetime.now(pytz.utc)
47
+ if (current_time - latest_value) < pd.Timedelta(self.source_frequency):
48
+ return pd.DataFrame()
49
+
50
+ signal_index = pd.date_range(
51
+ start=latest_value + pd.Timedelta(self.source_frequency),
52
+ end=current_time,
53
+ freq=self.source_frequency
54
+ )
55
+ signal_weights = []
56
+ for ev, asset_map in self.asset_mapping.items():
57
+ tmp_signal = pd.DataFrame(index=signal_index, columns=self.asset_mapping[ev].values())
58
+ tmp_signal = pd.concat([tmp_signal], axis=1, keys=[ev])
59
+ signal_weights.append(tmp_signal)
60
+ signal_weights = pd.concat(signal_weights, axis=1)
61
+
62
+ last_observation = self.get_last_observation()
63
+ if last_observation is not None:
64
+ asset_1_weight = -last_observation.query(f"asset_symbol == '{self.asset_1.symbol}'")["signal_weight"].iloc[0]
65
+ else:
66
+ asset_1_weight = 1.0
67
+
68
+ signal_weights.loc[:, (self.asset_1.execution_venue.symbol, self.asset_1.symbol)] = [
69
+ asset_1_weight if i % 2 == 0 else -asset_1_weight for i in range(len(signal_weights))
70
+ ]
71
+ signal_weights.loc[:, (self.asset_2.execution_venue.symbol, self.asset_2.symbol)] = -signal_weights.loc[
72
+ :, (self.asset_1.execution_venue.symbol, self.asset_1.symbol)
73
+ ]
74
+
75
+ signal_weights = signal_weights.stack().stack().to_frame(name='signal_weight').astype(float)
76
+ signal_weights.index.set_names(["time_index", "asset_symbol", "execution_venue_symbol"], inplace=True)
77
+
78
+ return signal_weights
@@ -0,0 +1,269 @@
1
+ import copy
2
+ from datetime import datetime
3
+ from typing import Union, Dict
4
+ from enum import Enum
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+ import pytz
9
+ from sklearn.linear_model import ElasticNet, Lasso, LinearRegression
10
+ from tqdm import tqdm
11
+
12
+ from mainsequence.client import AssetCategory, Asset, MARKETS_CONSTANTS
13
+ from mainsequence.virtualfundbuilder import TIMEDELTA
14
+ from mainsequence.virtualfundbuilder.contrib.prices.data_nodes import get_interpolated_prices_timeseries
15
+ from mainsequence.virtualfundbuilder.resource_factory.signal_factory import WeightsBase, register_signal_class
16
+ from mainsequence.virtualfundbuilder.models import VFBConfigBaseModel
17
+ from mainsequence.tdag.data_nodes import DataNode
18
+
19
+
20
+ class TrackingStrategy(Enum):
21
+ ELASTIC_NET = "elastic_net"
22
+ LASSO = "lasso"
23
+
24
+ class TrackingStrategyConfiguration(VFBConfigBaseModel):
25
+ configuration: Dict = {"alpha": 0, "l1_ratio": 0}
26
+
27
+ def rolling_pca_betas(X, window, n_components=5, *args, **kwargs):
28
+ """
29
+ Perform rolling PCA and return the betas (normalized principal component weights).
30
+
31
+ Parameters:
32
+ X (pd.DataFrame): DataFrame of stock returns or feature data (rows are time, columns are assets).
33
+ window (int): The size of the rolling window.
34
+ n_components (int, optional): The number of principal components to extract. Defaults to 5.
35
+
36
+ Returns:
37
+ np.ndarray: An array of normalized PCA weights for each rolling window.
38
+ """
39
+ from sklearn.decomposition import PCA
40
+
41
+ betas = []
42
+
43
+ # Loop over each rolling window
44
+ for i in tqdm(range(window, len(X)), desc="Performing rolling PCA"):
45
+ X_window = X.iloc[i - window:i]
46
+
47
+ # Perform PCA on the windowed data
48
+ pca = PCA(n_components=n_components)
49
+ try:
50
+ pca.fit(X_window)
51
+ except Exception as e:
52
+ raise e
53
+
54
+ # Get the eigenvectors (principal components)
55
+ eigenvectors = pca.components_ # Shape: (n_components, n_assets)
56
+
57
+ # Transpose to align weights with assets
58
+ eigenvectors_transposed = eigenvectors.T # Shape: (n_assets, n_components)
59
+
60
+ # Normalize the eigenvectors so that sum of absolute values = 1 for each component
61
+ weights_normalized = eigenvectors_transposed / np.sum(np.abs(eigenvectors_transposed), axis=0)
62
+
63
+ # Append the normalized weights (betas) for this window
64
+ betas.append(weights_normalized)
65
+
66
+ return np.array(betas) # Shape: (num_windows, n_assets, n_components)
67
+
68
+
69
+ def rolling_lasso_regression(y, X, window, alpha=1.0, *args, **kwargs):
70
+ """
71
+ Perform rolling Lasso regression and return the coefficients.
72
+
73
+ Parameters:
74
+ y (pd.Series): Target variable.
75
+ X (pd.DataFrame): Feature variables.
76
+ window (int): Size of the rolling window.
77
+ alpha (float, optional): Regularization strength. Defaults to 1.0.
78
+
79
+ Returns:
80
+ list: List of DataFrames containing the coefficients for each rolling window.
81
+ """
82
+ betas = []
83
+ if alpha == 0:
84
+ lasso = LinearRegression(fit_intercept=False, positive=True)
85
+ else:
86
+ lasso = Lasso(alpha=alpha, fit_intercept=False, positive=True)
87
+
88
+ for i in tqdm(range(window, len(y)), desc="Building Lasso regression"):
89
+ null_xs = X.isnull().sum()
90
+ null_xs = null_xs[null_xs > 0]
91
+ symbols_to_zero = None
92
+ X_window = X.iloc[i - window:i]
93
+ if null_xs.shape[0] > 0:
94
+ symbols_to_zero = null_xs.index.to_list()
95
+ X_window = X_window[[c for c in X_window.columns if c not in symbols_to_zero]]
96
+ y_window = y.iloc[i - window:i]
97
+
98
+ # Fit the Lasso model
99
+ try:
100
+ lasso.fit(X_window, y_window)
101
+ except Exception as e:
102
+ raise e
103
+
104
+ round_betas = pd.DataFrame(
105
+ lasso.coef_.reshape(1, -1),
106
+ columns=X_window.columns,
107
+ index=[X_window.index[-1]],
108
+ )
109
+ if symbols_to_zero is not None:
110
+ round_betas.loc[:, symbols_to_zero] = 0.0
111
+ # Append the coefficients
112
+ betas.append(round_betas)
113
+ return betas
114
+
115
+
116
+ def rolling_elastic_net(y, X, window, alpha=1.0, l1_ratio=0.5):
117
+ """
118
+ Perform rolling Elastic Net regression and return the coefficients.
119
+
120
+ Parameters:
121
+ y (pd.Series): Target variable.
122
+ X (pd.DataFrame): Feature variables.
123
+ window (int): Size of the rolling window.
124
+ alpha (float, optional): Regularization strength. Defaults to 1.0.
125
+ l1_ratio (float, optional): The ElasticNet mixing parameter. Defaults to 0.5.
126
+
127
+ Returns:
128
+ np.ndarray: Array of coefficients for each rolling window.
129
+ """
130
+ betas = []
131
+ enet = ElasticNet(alpha=alpha, l1_ratio=l1_ratio, fit_intercept=False)
132
+
133
+ for i in tqdm(range(window, len(y)), desc="Building rolling regression"):
134
+ X_window = X.iloc[i - window:i]
135
+ y_window = y.iloc[i - window:i]
136
+
137
+ # Fit the ElasticNet model
138
+ enet.fit(X_window, y_window)
139
+
140
+ # Save coefficients
141
+ betas.append(enet.coef_)
142
+
143
+ return np.array(betas)
144
+
145
+ @register_signal_class(register_in_agent=True)
146
+ class ETFReplicator(WeightsBase, DataNode):
147
+ def __init__(
148
+ self,
149
+ etf_ticker: str,
150
+ tracking_strategy_configuration: TrackingStrategyConfiguration,
151
+ in_window: int = 60,
152
+ tracking_strategy: TrackingStrategy = TrackingStrategy.LASSO,
153
+ *args,
154
+ **kwargs,
155
+ ):
156
+ """
157
+ Initialize the ETFReplicator.
158
+
159
+ Args:
160
+ etf_ticker (str): Figi of the etf to replicate.
161
+ tracking_strategy_configuration (TrackingStrategyConfiguration): Configuration parameters for the tracking strategy.
162
+ in_window (int, optional): The size of the rolling window for regression. Defaults to 60.
163
+ tracking_strategy (TrackingStrategy, optional): The regression strategy to use for tracking. Defaults to TrackingStrategy.LASSO.
164
+ *args: Variable length argument list.
165
+ **kwargs: Arbitrary keyword arguments.
166
+ """
167
+ super().__init__(*args, **kwargs)
168
+
169
+ self.in_window = in_window
170
+ self.bars_ts = get_interpolated_prices_timeseries(copy.deepcopy(self.assets_configuration))
171
+ etf_assets_configuration = copy.deepcopy(self.assets_configuration)
172
+ etf_assets_configuration.assets_category_unique_id = "etfs"
173
+ self.etf_bars_ts = get_interpolated_prices_timeseries(etf_assets_configuration)
174
+ self.etf_ticker = etf_ticker
175
+
176
+ self.tracking_strategy = tracking_strategy
177
+ self.tracking_strategy_configuration = tracking_strategy_configuration
178
+
179
+ def get_asset_list(self) -> Union[None, list]:
180
+ asset_category = AssetCategory.get(unique_identifier=self.assets_configuration.assets_category_unique_id)
181
+ self.price_assets = Asset.filter(id__in=asset_category.assets)
182
+ self.etf_asset = Asset.get(
183
+ ticker=self.etf_ticker,
184
+ exchange_code="US",
185
+ security_type=MARKETS_CONSTANTS.FIGI_SECURITY_TYPE_ETP,
186
+ security_market_sector=MARKETS_CONSTANTS.FIGI_MARKET_SECTOR_EQUITY,
187
+ )
188
+ return self.price_assets + [self.etf_asset]
189
+
190
+ def dependencies(self) -> Dict[str, Union["DataNode", "APIDataNode"]]:
191
+ return {
192
+ "bars_ts": self.bars_ts,
193
+ "etf_bars_ts": self.etf_bars_ts,
194
+ }
195
+
196
+ def get_explanation(self):
197
+ info = f"""
198
+ <p>{self.__class__.__name__}: Signal aims to replicate {self.etf_asset.ticker} using a data-driven approach.
199
+ This strategy will use {self.tracking_strategy} as approximation function with parameters </p>
200
+ <code>{self.tracking_strategy_configuration}</code>
201
+ """
202
+ return info
203
+
204
+ def maximum_forward_fill(self):
205
+ freq = self.assets_configuration.prices_configuration.bar_frequency_id
206
+ return pd.Timedelta(freq) - TIMEDELTA
207
+
208
+ def get_tracking_weights(self, prices: pd.DataFrame) -> pd.DataFrame:
209
+ prices = prices[~prices[self.etf_asset.unique_identifier].isnull()]
210
+ prices = prices.pct_change().iloc[1:]
211
+ prices = prices.replace([np.inf, -np.inf], np.nan)
212
+
213
+ y = prices[self.etf_asset.unique_identifier]
214
+ X = prices.drop(columns=[self.etf_asset.unique_identifier])
215
+
216
+ if self.tracking_strategy == TrackingStrategy.ELASTIC_NET:
217
+ betas = rolling_elastic_net(
218
+ y, X, window=self.in_window, **self.tracking_strategy_configuration.configuration
219
+ )
220
+ elif self.tracking_strategy == TrackingStrategy.LASSO:
221
+ betas = rolling_lasso_regression(
222
+ y, X, window=self.in_window, **self.tracking_strategy_configuration.configuration
223
+ )
224
+ else:
225
+ raise NotImplementedError
226
+
227
+ try:
228
+ betas = pd.concat(betas, axis=0)
229
+ except Exception as e:
230
+ raise e
231
+ betas.index.name = "time_index"
232
+ return betas
233
+
234
+ def update(self) -> pd.DataFrame:
235
+ if self.update_statistics.max_time_index_value:
236
+ prices_start_date = self.update_statistics.max_time_index_value - pd.Timedelta(days=self.in_window)
237
+ else:
238
+ prices_start_date = self.OFFSET_START - pd.Timedelta(days=self.in_window)
239
+
240
+ prices = self.bars_ts.get_df_between_dates(
241
+ start_date=prices_start_date,
242
+ end_date=None,
243
+ great_or_equal=True,
244
+ less_or_equal=True,
245
+ unique_identifier_list=[a.unique_identifier for a in self.price_assets],
246
+ )
247
+ etf_prices = self.etf_bars_ts.get_df_between_dates(
248
+ start_date=prices_start_date,
249
+ end_date=None,
250
+ great_or_equal=True,
251
+ less_or_equal=True,
252
+ unique_identifier_list=[self.etf_asset.unique_identifier],
253
+ )
254
+
255
+ prices = pd.concat([prices, etf_prices])
256
+ prices = prices.reset_index().pivot_table(
257
+ index="time_index",
258
+ columns="unique_identifier",
259
+ values=self.assets_configuration.price_type.value,
260
+ )
261
+
262
+ if prices.shape[0] < self.in_window:
263
+ self.logger.warning("Not enough prices to run regression")
264
+ return pd.DataFrame()
265
+
266
+ weights = self.get_tracking_weights(prices=prices)
267
+ weights = weights.unstack().to_frame(name="signal_weight")
268
+ weights = weights.swaplevel()
269
+ return weights
@@ -0,0 +1 @@
1
+ from .data_nodes import get_interpolated_prices_timeseries