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,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
|