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