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,235 @@
1
+ import time
2
+
3
+ import fire
4
+
5
+ import json
6
+
7
+ import runpy
8
+ import tempfile
9
+ import os
10
+ from pathlib import Path
11
+
12
+ import requests
13
+ import yaml
14
+ from requests.structures import CaseInsensitiveDict
15
+
16
+
17
+ def get_tdag_headers():
18
+ headers = CaseInsensitiveDict()
19
+ headers["Content-Type"] = "application/json"
20
+ headers["Authorization"] = "Token " + os.getenv("MAINSEQUENCE_TOKEN")
21
+ return headers
22
+
23
+ def update_job_status(status_message):
24
+ url = f"{os.getenv('TDAG_ENDPOINT')}/orm/api/pods/job/job_run_status/"
25
+
26
+ payload = {
27
+ "status": status_message,
28
+ "git_hash": os.getenv("GIT_HASH"),
29
+ "command_id": os.getenv("COMMAND_ID")
30
+ }
31
+
32
+ response = requests.post(url, json=payload, headers=get_tdag_headers())
33
+
34
+ if response.status_code == 200:
35
+ data = response.json()
36
+ print("Update success:", data)
37
+ return data
38
+ else:
39
+ print("Error updating pod:", response.status_code, response.text)
40
+ return None
41
+
42
+ def run_configuration(configuration_name):
43
+ from mainsequence.virtualfundbuilder.portfolio_interface import PortfolioInterface
44
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
45
+ logger = get_vfb_logger()
46
+ logger.info(f"Run Timeseries Configuration {configuration_name}")
47
+ portfolio = PortfolioInterface.load_from_configuration(configuration_name)
48
+ res = portfolio.run()
49
+ print(res.head())
50
+
51
+
52
+ def run_app(app_name, configuration):
53
+ from mainsequence.virtualfundbuilder.resource_factory.app_factory import APP_REGISTRY
54
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
55
+ from mainsequence.virtualfundbuilder.config_handling import replace_none_and_empty_dict_with_python_none
56
+
57
+ logger = get_vfb_logger()
58
+ logger.info(f"Start App {app_name} with configuration\n{configuration}")
59
+ try:
60
+ app_cls = APP_REGISTRY[app_name]
61
+
62
+ configuration_json = yaml.load(configuration, Loader=yaml.UnsafeLoader)
63
+ configuration_json = replace_none_and_empty_dict_with_python_none(configuration_json)
64
+
65
+ # Pull out the dict under "configuration" (or flatten it, your choice)
66
+ actual_config = configuration_json.get("configuration", {})
67
+
68
+ # Now pass the unpacked dictionary:
69
+ pydantic_config = app_cls.configuration_class(**actual_config)
70
+
71
+ app_instance = app_cls(pydantic_config)
72
+ results = app_instance.run()
73
+ except Exception as e:
74
+ logger.error("Error running app", exc_info=True)
75
+ raise
76
+ logger.info(f"Finished App {app_name} run with results: {results}")
77
+
78
+ def run_notebook(execution_object):
79
+ from mainsequence.virtualfundbuilder.notebook_handling import convert_notebook_to_python_file
80
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
81
+ logger = get_vfb_logger()
82
+ logger.info(f"Run Notebook {execution_object}")
83
+ notebook_file_path = Path(os.getenv("VFB_PROJECT_PATH")).parent / execution_object
84
+ python_notebook_file = convert_notebook_to_python_file(notebook_file_path)
85
+ runpy.run_path(python_notebook_file, run_name="__main__")
86
+
87
+ def run_script(execution_object):
88
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
89
+ logger = get_vfb_logger()
90
+ logger.info(f"Run script {execution_object}")
91
+ python_file_path = Path(os.getenv("VFB_PROJECT_PATH")).parent / execution_object
92
+ runpy.run_path(python_file_path, run_name="__main__")
93
+
94
+ def get_py_modules(folder_path):
95
+ if not os.path.isdir(folder_path): return []
96
+ files = os.listdir(folder_path)
97
+ files = [f for f in files if f[0] not in ["_", "."] and f.endswith(".py")]
98
+ return [f.split(".")[0] for f in files]
99
+
100
+ def get_pod_configuration():
101
+ # TODO needs to introspect for apps in any folder?
102
+ print("Get pod configuration")
103
+
104
+ project_library = os.getenv("PROJECT_LIBRARY_NAME")
105
+ if not project_library:
106
+ raise RuntimeError("PROJECT_LIBRARY_NAME is not set in environment")
107
+
108
+ project_path = os.getenv("VFB_PROJECT_PATH")
109
+
110
+ # Gather all submodules in data_nodes
111
+ data_nodes_package = f"{project_library}.data_nodes"
112
+ data_nodes_modules = get_py_modules(os.path.join(project_path, "data_nodes"))
113
+
114
+ # Gather all submodules in rebalance_strategies
115
+ rebalance_package = f"{project_library}.rebalance_strategies"
116
+ rebalance_modules = get_py_modules(os.path.join(project_path, "rebalance_strategies"))
117
+
118
+ # Gather all submodules in apps
119
+ apps_package = f"{project_library}.apps"
120
+ apps_modules = get_py_modules(os.path.join(project_path, "apps"))
121
+
122
+ # Build the temporary Python script to import all files
123
+ script_lines = []
124
+
125
+ script_lines.append("# -- Auto-generated imports for data_nodes --")
126
+ for mod in data_nodes_modules:
127
+ script_lines.append(f"import {data_nodes_package}.{mod}")
128
+
129
+ script_lines.append("# -- Auto-generated imports for rebalance_strategies --")
130
+ for mod in rebalance_modules:
131
+ script_lines.append(f"import {rebalance_package}.{mod}")
132
+
133
+ script_lines.append("# -- Auto-generated imports for apps --")
134
+ for mod in apps_modules:
135
+ script_lines.append(f"import {apps_package}.{mod}")
136
+
137
+ script_lines.append("")
138
+ script_lines.append("from mainsequence.virtualfundbuilder.agent_interface import TDAGAgent")
139
+ script_lines.append("print('Initialize TDAGAgent')")
140
+ script_lines.append("tdag_agent = TDAGAgent()")
141
+
142
+ TMP_SCRIPT = "\n".join(script_lines)
143
+ print(f"Executing Script: \n{TMP_SCRIPT}")
144
+
145
+ # Write out to a temporary .py file and run
146
+ temp_dir = tempfile.mkdtemp()
147
+ python_file_path = Path(temp_dir) / "load_pod_configuration.py"
148
+ with open(python_file_path, "w", encoding="utf-8") as f:
149
+ f.write(TMP_SCRIPT)
150
+ try:
151
+ runpy.run_path(str(python_file_path), run_name="__main__")
152
+ except Exception:
153
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
154
+ logger = get_vfb_logger()
155
+ logger.exception("--- ERROR: Failed to import one or more project modules. ---")
156
+ raise
157
+
158
+ def import_project_configuration():
159
+ from mainsequence.client import ProjectConfiguration, Job
160
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
161
+ logger = get_vfb_logger()
162
+
163
+ # load project configuration if exists
164
+ config_file = Path(os.getenv("VFB_PROJECT_PATH")).parent / "project_configuration.yaml"
165
+ logger.info(f"Try to import project configuration from {config_file}")
166
+
167
+ if not config_file.exists():
168
+ logger.info("No project configuration found")
169
+ return
170
+
171
+ with open(config_file, "r") as f:
172
+ project_configuration_raw = yaml.safe_load(f)
173
+ project_configuration = ProjectConfiguration(**project_configuration_raw)
174
+
175
+ logger.info(f"Create or update {project_configuration.jobs} jobs in backend")
176
+ for job in project_configuration.jobs:
177
+ Job.create_from_configuration(job_configuration=job.model_dump())
178
+
179
+
180
+ def prerun_routines():
181
+ data = update_job_status("RUNNING")
182
+ env_update = data.get("environment_update", {})
183
+ for key, val in env_update.items():
184
+ os.environ[key] = val
185
+
186
+ def postrun_routines(error_on_run: bool):
187
+ if error_on_run:
188
+ update_job_status("FAILED")
189
+ else:
190
+ update_job_status("SUCCEEDED")
191
+
192
+ class VirtualFundLauncher:
193
+
194
+ def __init__(self):
195
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
196
+ self.logger = get_vfb_logger()
197
+
198
+ def run_resource(self, execution_type, execution_object=None):
199
+ error_on_run = False
200
+
201
+ try:
202
+ prerun_routines()
203
+ if execution_type == "configuration":
204
+ run_configuration(execution_object)
205
+ elif execution_type == "script":
206
+ run_script(execution_object)
207
+ elif execution_type == "notebook":
208
+ run_notebook(execution_object)
209
+ elif execution_type == "system_job":
210
+ self.logger.info("Update project resources and import project configuration")
211
+ import_project_configuration()
212
+ # get_pod_configuration already called from import
213
+ pass
214
+ elif execution_type == "app":
215
+ run_app(app_name=os.getenv("APP_NAME"), configuration=os.getenv("APP_CONFIGURATION"))
216
+ elif execution_type == "standby":
217
+ sleep_seconds = int(os.getenv("STANDBY_DURATION_SECONDS"))
218
+ self.logger.info(f"Sleep for {sleep_seconds} seconds")
219
+ time.sleep(sleep_seconds)
220
+ else:
221
+ raise NotImplementedError(f"Unknown execution type {execution_type}")
222
+
223
+ except Exception as e:
224
+ self.logger.exception(f"Exception during job run occured {e}")
225
+ import traceback
226
+ traceback.print_exc()
227
+ error_on_run = True
228
+
229
+ finally:
230
+ postrun_routines(error_on_run)
231
+
232
+
233
+ if __name__ == "__main__":
234
+ fire.Fire(VirtualFundLauncher)
235
+
@@ -0,0 +1,77 @@
1
+ import copy
2
+ import inspect
3
+ import json
4
+ import os
5
+ import traceback
6
+ from pathlib import Path
7
+
8
+ import yaml
9
+
10
+ from mainsequence.virtualfundbuilder.enums import ResourceType
11
+ from mainsequence.virtualfundbuilder.portfolio_interface import PortfolioInterface
12
+ from mainsequence.virtualfundbuilder.utils import is_jupyter_environment
13
+ from .resource_factory.base_factory import send_resource_to_backend
14
+ from .utils import logger
15
+
16
+
17
+ class TDAGAgent:
18
+
19
+ def __init__(self):
20
+ self.logger = logger
21
+ self.logger.info("Setup TDAG Agent successfull")
22
+
23
+ def query_agent(self, query):
24
+ try:
25
+ from mainsequence.client.models_tdag import query_agent
26
+ payload = {
27
+ "query": query,
28
+ }
29
+ response = query_agent(json_payload=payload)
30
+ if response.status_code not in [200, 201]:
31
+ raise Exception(response.text)
32
+
33
+ answer = response.json()["agent_response"]
34
+ except Exception as e:
35
+ self.logger.warning(f"Could not get answer from Agent {e}")
36
+ traceback.print_exc()
37
+ return None
38
+
39
+ return answer
40
+
41
+ def generate_portfolio(self, cls, signal_description=None):
42
+ full_signal_description = f"Create me a default portfolio using the {cls.__name__} signal."
43
+ if signal_description is not None:
44
+ full_signal_description += f"\n{signal_description}"
45
+ else:
46
+ full_signal_description += f"Use NVDA, AAPL and GOOGL for the assets universe."
47
+
48
+ if is_jupyter_environment():
49
+ code = cls.get_source_notebook()
50
+ else:
51
+ code = inspect.getsource(cls)
52
+ attributes = {"code": code}
53
+ send_resource_to_backend(cls, attributes=attributes)
54
+
55
+ payload = {
56
+ "strategy_name": cls.__name__,
57
+ "signal_description": full_signal_description,
58
+ }
59
+ self.logger.info(f"Get configuration for {cls.__name__} ...")
60
+ payload = json.loads(json.dumps(payload))
61
+ try:
62
+ from mainsequence.client.models_tdag import create_configuration_for_strategy
63
+ response = create_configuration_for_strategy(json_payload=payload)
64
+ if response.status_code not in [200, 201]:
65
+ raise Exception(response.text)
66
+
67
+ generated_configuration = response.json()["generated_configuration"]["configuration"]["portfolio_configuration"]
68
+ portfolio = PortfolioInterface(generated_configuration)
69
+
70
+ self.logger.info(f"Received configuration:\n{portfolio}")
71
+
72
+ except Exception as e:
73
+ self.logger.warning(f"Could not get configuration from TSORM {e}")
74
+ traceback.print_exc()
75
+ return None
76
+
77
+ return portfolio
@@ -0,0 +1,86 @@
1
+ import logging
2
+ from .models import *
3
+ logger = get_vfb_logger()
4
+
5
+ def replace_none_and_empty_dict_with_python_none(config):
6
+ """
7
+ Recursively replace all string 'None' with Python None in the given dictionary
8
+ and log the path where replacements occur.
9
+
10
+ Args:
11
+ config (dict): The configuration dictionary.
12
+
13
+ Returns:
14
+ dict: Updated dictionary with 'None' replaced by Python None.
15
+ """
16
+ logging.basicConfig(level=logging.INFO, format='%(message)s')
17
+
18
+ def recursive_replace(d, path="root"):
19
+ if isinstance(d, dict):
20
+ for key, value in d.items():
21
+ current_path = f"{path}.{key}"
22
+ if isinstance(value, dict) and not value:
23
+ d[key] = None
24
+ logger.info(f"Replaced empty dict {{}} with Python None at: {current_path}")
25
+ elif isinstance(value, dict):
26
+ recursive_replace(value, current_path)
27
+ elif isinstance(value, list):
28
+ for i, item in enumerate(value):
29
+ recursive_replace(item, f"{current_path}[{i}]")
30
+ elif isinstance(value, str) and value.lower() in ['none', 'null']:
31
+ d[key] = None
32
+ logger.info(f"Replaced 'None' in configuration with None at {current_path}")
33
+
34
+ elif isinstance(d, list):
35
+ for i, item in enumerate(d):
36
+ recursive_replace(item, f"{path}[{i}]")
37
+
38
+ recursive_replace(config)
39
+ return config
40
+
41
+ def configuration_sanitizer(configuration: dict) -> PortfolioConfiguration:
42
+ """
43
+ Verifies that a configuration has all the required attributes.
44
+ Args:
45
+ configuration (dict): The configuration dictionary to sanitize.
46
+ Returns:
47
+ PortfolioConfiguration: The sanitized portfolio configuration.
48
+ """
49
+ configuration = copy.deepcopy(configuration)
50
+ configuration = replace_none_and_empty_dict_with_python_none(configuration)
51
+ portfolio_build_config = configuration["portfolio_build_configuration"]
52
+ for key in ["assets_configuration", "backtesting_weights_configuration", "execution_configuration"]:
53
+ if key not in portfolio_build_config:
54
+ raise KeyError(f"Missing required key {key}")
55
+
56
+ if portfolio_build_config["assets_configuration"] is not None:
57
+ if "prices_configuration" not in portfolio_build_config["assets_configuration"]:
58
+ raise Exception("Missing prices configuration in portfolio_build_config['assets_configuration']")
59
+
60
+ if "rebalance_strategy_configuration" not in portfolio_build_config["backtesting_weights_configuration"]:
61
+ raise Exception(
62
+ "Missing 'rebalance_strategy_configuration' in 'backtesting_weights_configuration'"
63
+ )
64
+
65
+ if "calendar" not in portfolio_build_config["backtesting_weights_configuration"]["rebalance_strategy_configuration"] or not portfolio_build_config["backtesting_weights_configuration"]["rebalance_strategy_configuration"]["calendar"]:
66
+ raise Exception(
67
+ "Missing 'calendar' in 'rebalance_strategy_configuration'"
68
+ )
69
+
70
+ if "signal_weights_configuration" not in portfolio_build_config["backtesting_weights_configuration"]:
71
+ raise Exception(
72
+ "Missing 'signal_weights_configuration' in 'backtesting_weights_configuration'"
73
+ )
74
+
75
+ if "signal_assets_configuration" not in portfolio_build_config['backtesting_weights_configuration']['signal_weights_configuration']:
76
+ raise Exception(
77
+ "Missing 'signal_weights_configuration' in 'backtesting_weights_configuration'"
78
+ )
79
+
80
+ if "portfolio_prices_frequency" not in portfolio_build_config:
81
+ raise Exception("Missing 'portfolio_prices_frequency' in 'portfolio_build_config'")
82
+
83
+ return PortfolioConfiguration.parse_portfolio_configurations(
84
+ portfolio_build_configuration=portfolio_build_config,
85
+ portfolio_markets_configuration=configuration['portfolio_markets_configuration'],
86
+ )
File without changes
@@ -0,0 +1,8 @@
1
+ from .generate_report import *
2
+ from .load_external_portfolio import *
3
+ from .news_app import *
4
+ from .run_portfolio import *
5
+ from .run_named_portfolio import *
6
+ from .portfolio_report_app import *
7
+ from .portfolio_table import *
8
+ from .etf_replicator_app import *
@@ -0,0 +1,164 @@
1
+ import datetime
2
+ from enum import Enum
3
+ from typing import List, Union
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+ import plotly.graph_objects as go
8
+ from plotly.subplots import make_subplots
9
+ from mainsequence.client import Portfolio, Asset
10
+ from mainsequence.virtualfundbuilder.contrib.data_nodes import TrackingStrategy, TrackingStrategyConfiguration
11
+ from mainsequence.virtualfundbuilder.models import PortfolioConfiguration
12
+ from mainsequence.virtualfundbuilder.portfolio_interface import PortfolioInterface
13
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
14
+ from pydantic import BaseModel
15
+ from mainsequence.virtualfundbuilder.resource_factory.app_factory import (
16
+ HtmlApp,
17
+ register_app,
18
+ )
19
+
20
+ logger = get_vfb_logger()
21
+
22
+ class ETFReplicatorConfiguration(BaseModel):
23
+ source_asset_category_identifier: str = "magnificent_7"
24
+ etf_to_replicate: str = "XLF"
25
+ in_window: int = 60
26
+ tracking_strategy: TrackingStrategy = TrackingStrategy.LASSO
27
+ tracking_strategy_configuration: TrackingStrategyConfiguration
28
+
29
+ @register_app()
30
+ class ETFReplicatorApp(HtmlApp):
31
+ configuration_class = ETFReplicatorConfiguration
32
+
33
+ def _build_portfolio_config(self) -> dict:
34
+ """
35
+ Loads a portfolio configuration template and customizes it for ETF replication.
36
+ """
37
+ portfolio_config = PortfolioInterface.load_configuration(configuration_name="market_cap")
38
+ signal_weights_configuration = {
39
+ "etf_ticker": self.configuration.etf_to_replicate,
40
+ "in_window": self.configuration.in_window,
41
+ "tracking_strategy": self.configuration.tracking_strategy,
42
+ "tracking_strategy_configuration": self.configuration.tracking_strategy_configuration,
43
+ "signal_assets_configuration": portfolio_config.portfolio_build_configuration.backtesting_weights_configuration.signal_weights_configuration[
44
+ "signal_assets_configuration"]
45
+ }
46
+ signal_weights_configuration["signal_assets_configuration"].assets_category_unique_id = self.configuration.source_asset_category_identifier
47
+
48
+ portfolio_config.portfolio_build_configuration.backtesting_weights_configuration.signal_weights_configuration = signal_weights_configuration
49
+ portfolio_config.portfolio_build_configuration.backtesting_weights_configuration.signal_weights_name = "ETFReplicator"
50
+ portfolio_config.portfolio_markets_configuration.portfolio_name = f"ETFReplicator Portfolio for {self.configuration.etf_to_replicate} using {self.configuration.source_asset_category_identifier}"
51
+
52
+ return portfolio_config.model_dump()
53
+
54
+ def _create_plot(self, df_plot_normalized: pd.DataFrame, weights_pivot: pd.DataFrame) -> str:
55
+ """
56
+ Creates a combined Plotly figure with performance and asset weight subplots.
57
+ """
58
+ fig = make_subplots(rows=2, cols=1, shared_xaxes=True, vertical_spacing=0.1,
59
+ subplot_titles=("ETF Replication Performance", "Calculated Asset Weights"))
60
+
61
+ # Add performance traces to the first subplot
62
+ fig.add_trace(go.Scatter(
63
+ x=df_plot_normalized.index,
64
+ y=df_plot_normalized[self.configuration.etf_to_replicate],
65
+ mode='lines',
66
+ name=f"Original: {self.configuration.etf_to_replicate}"
67
+ ), row=1, col=1)
68
+
69
+ fig.add_trace(go.Scatter(
70
+ x=df_plot_normalized.index,
71
+ y=df_plot_normalized[f"Replicated_{self.configuration.etf_to_replicate}"],
72
+ mode='lines',
73
+ name="Replicated Portfolio"
74
+ ), row=1, col=1)
75
+
76
+ # Add weights traces to the second subplot as a stacked area chart
77
+ for asset in weights_pivot.columns:
78
+ fig.add_trace(go.Scatter(
79
+ x=weights_pivot.index,
80
+ y=weights_pivot[asset],
81
+ mode='lines',
82
+ name=asset,
83
+ showlegend=True
84
+ ), row=2, col=1)
85
+
86
+ fig.update_layout(
87
+ title_text=f"ETF Replication Analysis: {self.configuration.etf_to_replicate} vs. Replicated Portfolio",
88
+ legend_title="Series",
89
+ height=800 # Increase height to accommodate both plots
90
+ )
91
+
92
+ fig.update_yaxes(title_text="Normalized Performance (Indexed to 100)", row=1, col=1)
93
+ fig.update_yaxes(title_text="Asset Weight", row=2, col=1)
94
+
95
+ return fig.to_html(full_html=False, include_plotlyjs='cdn')
96
+
97
+ def run(self) -> str:
98
+ # Build Portfolio Configuration
99
+ portfolio_config_dump = self._build_portfolio_config()
100
+ portfolio = PortfolioInterface(portfolio_config_template=portfolio_config_dump)
101
+
102
+ # Run Portfolio
103
+ portfolio.run(add_portfolio_to_markets_backend=True)
104
+ self.add_output(output=portfolio.target_portfolio)
105
+
106
+ # Fetch Portfolio Results in batches to avoid timeouts
107
+ loop_start_date = portfolio.portfolio_strategy_data_node.OFFSET_START
108
+ final_end_date = datetime.datetime.now(datetime.timezone.utc)
109
+ all_results = []
110
+ current_start = loop_start_date
111
+ while current_start < final_end_date:
112
+ current_end = current_start + pd.DateOffset(months=6)
113
+ logger.info(f"Fetching data from {current_start.date()} to {min(current_end, final_end_date).date()}")
114
+ results_chunk = portfolio.portfolio_strategy_data_node.get_df_between_dates(start_date=current_start, end_date=current_end)
115
+ all_results.append(results_chunk)
116
+ current_start = current_end
117
+
118
+ results = pd.concat(all_results).drop_duplicates()
119
+ if results.empty:
120
+ return "<html><body><h1>No data available to generate ETF replication report.</h1></body></html>"
121
+
122
+ # Fetch and Process Data for Plotting
123
+ etf_replicator_signal = portfolio.portfolio_strategy_data_node.signal_weights
124
+ etf_asset = etf_replicator_signal.etf_asset
125
+ etf_data = etf_replicator_signal.etf_bars_ts.get_df_between_dates(
126
+ start_date=results.index.min(),
127
+ end_date=results.index.max(),
128
+ unique_identifier_list=[etf_asset.unique_identifier]
129
+ )
130
+
131
+ replicated_df = results.sort_index().reset_index()[['time_index', 'close']].rename(
132
+ columns={'close': f"Replicated_{self.configuration.etf_to_replicate}"}
133
+ )
134
+ original_df = etf_data.sort_index().reset_index()[['time_index', 'close']].rename(
135
+ columns={'close': self.configuration.etf_to_replicate}
136
+ )
137
+ df_plot = pd.concat([original_df, replicated_df]).sort_values("time_index").ffill()
138
+ df_plot = df_plot.set_index('time_index').dropna()
139
+ if df_plot.empty:
140
+ return "<html><body><h1>Could not align original and replicated portfolio data.</h1></body></html>"
141
+
142
+ df_plot_normalized = (df_plot / df_plot.iloc[0]) * 100
143
+
144
+ weights_df = etf_replicator_signal.get_df_between_dates(start_date=results.index.min(), end_date=results.index.max())
145
+ weights_df = weights_df.reset_index()
146
+ weight_assets = Asset.filter(unique_identifier__in=list(weights_df["unique_identifier"].unique()))
147
+ translation_map = {asset.unique_identifier: asset.ticker for asset in weight_assets}
148
+ weights_df["ticker"] = weights_df["unique_identifier"].map(translation_map)
149
+
150
+ weights_pivot = weights_df.pivot(index='time_index', columns='ticker', values='signal_weight').fillna(0)
151
+
152
+ # reduce size of plot
153
+ weights_pivot = weights_pivot.resample('W').last().fillna(0) # only use weekly weights
154
+ weights_pivot = weights_pivot.loc[:, (weights_pivot > 0.01).any(axis=0)] # filter out assets with very small weights
155
+
156
+ return self._create_plot(df_plot_normalized, weights_pivot)
157
+
158
+
159
+ if __name__ == "__main__":
160
+ cfg = ETFReplicatorConfiguration(
161
+ tracking_strategy_configuration=TrackingStrategyConfiguration(),
162
+ source_asset_category_identifier="s&p500_constitutents"
163
+ )
164
+ ETFReplicatorApp(cfg).run()