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,170 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ from abc import abstractmethod
5
+ from typing import Any
6
+ from pydantic import BaseModel
7
+ import inspect
8
+ from mainsequence.client.models_tdag import Artifact, add_created_object_to_jobrun
9
+ from mainsequence.virtualfundbuilder.enums import ResourceType
10
+ from mainsequence.virtualfundbuilder.resource_factory.base_factory import insert_in_registry, BaseResource
11
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
12
+
13
+ logger = get_vfb_logger()
14
+
15
+ class BaseApp(BaseResource):
16
+ TYPE = ResourceType.APP
17
+
18
+ def __init__(self, configuration: BaseModel):
19
+ self.configuration = configuration
20
+
21
+ def add_output(self, output: Any):
22
+ """
23
+ Saves the given output in the backend.
24
+ """
25
+ logger.info(f"Add object {output} to job run output")
26
+ job_id = os.getenv("JOB_RUN_ID", None)
27
+
28
+ if job_id:
29
+ add_created_object_to_jobrun(
30
+ model_name=output.orm_class,
31
+ app_label=output.get_app_label(),
32
+ object_id=output.id
33
+ )
34
+ logger.info("Output added successfully")
35
+ else:
36
+ logger.info("This is not a Job Run - no output can be added")
37
+ @staticmethod
38
+ def hash_pydantic_object(obj: Any,digest_size: int = 16) -> str:
39
+ """
40
+ Generate a unique SHA-256 hash for any Pydantic object (including nested dependencies),
41
+ ensuring that lists of objects are deterministically ordered.
42
+
43
+ Args:
44
+ obj: A Pydantic BaseModel instance or any JSON-serializable structure.
45
+
46
+ Returns:
47
+ A hex string representing the SHA-256 hash of the canonical JSON representation.
48
+ """
49
+
50
+ def serialize(item: Any) -> Any:
51
+ if isinstance(item, BaseModel):
52
+ return serialize(item.dict(by_alias=True, exclude_unset=True))
53
+ elif isinstance(item, dict):
54
+ return {str(k): serialize(v) for k, v in sorted(item.items())}
55
+ elif isinstance(item, (list, tuple, set)):
56
+ serialized_items = [serialize(v) for v in item]
57
+ try:
58
+ return sorted(
59
+ serialized_items,
60
+ key=lambda x: json.dumps(x, sort_keys=True, separators=(",", ":"))
61
+ )
62
+ except (TypeError, ValueError):
63
+ return serialized_items
64
+ else:
65
+ if hasattr(item, "isoformat"):
66
+ try:
67
+ return item.isoformat()
68
+ except Exception:
69
+ pass
70
+ return item
71
+
72
+ json_str = json.dumps(serialize(obj), sort_keys=True, separators=(",", ":"))
73
+ h = hashlib.blake2b(digest_size=digest_size)
74
+ h.update(json_str.encode("utf-8"))
75
+ return h.hexdigest()
76
+
77
+
78
+ APP_REGISTRY = APP_REGISTRY if 'APP_REGISTRY' in globals() else {}
79
+ def register_app(name=None, register_in_agent=True):
80
+ """
81
+ Decorator to register a model class in the factory.
82
+ If `name` is not provided, the class's name is used as the key.
83
+ """
84
+ def decorator(cls):
85
+ return insert_in_registry(APP_REGISTRY, cls, register_in_agent, name)
86
+ return decorator
87
+
88
+ class HtmlApp(BaseApp):
89
+ """
90
+ A base class for apps that generate HTML output.
91
+ """
92
+ TYPE = ResourceType.HTML_APP
93
+
94
+ def __init__(self, *args, **kwargs):
95
+ self.created_artifacts = []
96
+ super().__init__(*args, **kwargs)
97
+
98
+ def _get_hash_from_configuration(self):
99
+ try:
100
+ return hashlib.sha256(
101
+ json.dumps(self.configuration.__dict__, sort_keys=True, default=str).encode()
102
+ ).hexdigest()[:8]
103
+ except Exception as e:
104
+ logger.warning(f"[{self.__name__}] Could not hash configuration: {e}")
105
+
106
+ def add_html_output(self, html_content, output_name=None):
107
+ """
108
+ Saves the given HTML content to a file, uploads it as an artifact,
109
+ and stores the artifact reference.
110
+ If output_name is not provided, a sequential name (e.g., ClassName_1.html) is generated.
111
+ """
112
+ if not isinstance(html_content, str):
113
+ raise TypeError(f"The 'add_html_output' method of {self.__class__.__name__} must be called with a string of HTML content.")
114
+
115
+ if output_name is None:
116
+ output_name = len(self.created_artifacts)
117
+
118
+ output_name = f"{self.__class__.__name__}_{output_name}.html"
119
+
120
+ try:
121
+ with open(output_name, "w", encoding="utf-8") as f:
122
+ f.write(html_content)
123
+
124
+ logger.info(f"[{self.__class__.__name__}] Successfully saved HTML to: {output_name}")
125
+ except IOError as e:
126
+ logger.error(f"[{self.__class__.__name__}] Error saving file: {e}")
127
+ raise
128
+
129
+ job_id = os.getenv("JOB_ID", None)
130
+ if job_id:
131
+ html_artifact = None
132
+ try:
133
+ html_artifact = Artifact.upload_file(
134
+ filepath=output_name,
135
+ name=output_name,
136
+ created_by_resource_name=self.__class__.__name__,
137
+ bucket_name="HTMLOutput"
138
+ )
139
+ if html_artifact:
140
+ self.created_artifacts.append(html_artifact)
141
+ self.add_output(html_artifact)
142
+ logger.info(f"Artifact uploaded successfully: {html_artifact.id}")
143
+ else:
144
+ logger.info("Artifact upload failed")
145
+ except Exception as e:
146
+ logger.info(f"Error uploading artifact: {e}")
147
+
148
+
149
+ def __init_subclass__(cls, **kwargs):
150
+ """
151
+ Wraps the subclass's `run` method to add validation and saving logic.
152
+ """
153
+ super().__init_subclass__(**kwargs)
154
+ original_run = cls.run
155
+
156
+ def run_wrapper(self, *args, **kwargs) -> str:
157
+ html_content = original_run(self, *args, **kwargs)
158
+
159
+ if html_content:
160
+ self.add_html_output(html_content)
161
+
162
+ cls.run = run_wrapper
163
+
164
+ @abstractmethod
165
+ def run(self) -> str:
166
+ """
167
+ This method should be implemented by subclasses to return HTML content as a string.
168
+ The base class will handle saving the output.
169
+ """
170
+ raise NotImplementedError("Subclasses of HtmlApp must implement the 'run' method.")
@@ -0,0 +1,238 @@
1
+ import json
2
+ import os
3
+ import inspect
4
+ import importlib.util
5
+ from threading import Thread
6
+
7
+ import yaml
8
+
9
+ from mainsequence.client.models_tdag import DynamicResource
10
+ from mainsequence.tdag import DataNode
11
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger, create_schema_from_signature
12
+ from typing import get_type_hints, List, Optional, Union
13
+ from pydantic import BaseModel
14
+ from enum import Enum
15
+ from pathlib import Path
16
+ import sys
17
+ import ast
18
+
19
+ logger = get_vfb_logger()
20
+ from mainsequence.virtualfundbuilder.enums import ResourceType
21
+ from mainsequence.virtualfundbuilder.utils import runs_in_main_process
22
+
23
+ class BaseResource():
24
+ @classmethod
25
+ def get_source_notebook(cls):
26
+ """Retrieve the exact source code of the class from notebook cells."""
27
+ from IPython import get_ipython
28
+ ipython_shell = get_ipython()
29
+ history = ipython_shell.history_manager.get_range()
30
+
31
+ for _, _, cell_content in history:
32
+ try:
33
+ # Parse the cell content as Python code
34
+ parsed = ast.parse(cell_content)
35
+
36
+ # Look for the class definition in the AST (Abstract Syntax Tree)
37
+ for node in ast.walk(parsed):
38
+ if isinstance(node, ast.ClassDef) and node.name == cls.__name__:
39
+ # Extract the start and end lines of the class
40
+ start_line = node.lineno - 1
41
+ end_line = max(
42
+ [child.lineno for child in ast.walk(node) if hasattr(child, "lineno")]
43
+ )
44
+ lines = cell_content.splitlines()
45
+ return "\n".join(lines[start_line:end_line])
46
+ except Exception as e:
47
+ print(e)
48
+ continue
49
+
50
+ return "Class definition not found in notebook history."
51
+
52
+ @classmethod
53
+ def build_and_parse_from_configuration(cls, **kwargs) -> 'WeightsBase':
54
+ type_hints = get_type_hints(cls.__init__)
55
+
56
+ def parse_value_into_hint(value, hint):
57
+ """
58
+ Recursively parse `value` according to `hint`.
59
+ Handles:
60
+ - Pydantic models
61
+ - Enums
62
+ - Lists of Pydantic models
63
+ - Optional[...] / Union[..., NoneType]
64
+ """
65
+ if value is None:
66
+ return None
67
+
68
+ from typing import get_origin, get_args, Union
69
+ origin = get_origin(hint)
70
+ args = get_args(hint)
71
+
72
+ # Handle Optional/Union
73
+ # e.g. Optional[SomeModel] => Union[SomeModel, NoneType]
74
+ if origin is Union and len(args) == 2 and type(None) in args:
75
+ # Identify the non-None type
76
+ non_none_type = args[0] if args[1] == type(None) else args[1]
77
+ return parse_value_into_hint(value, non_none_type)
78
+
79
+ # Handle single Pydantic model
80
+ if inspect.isclass(hint) and issubclass(hint, BaseModel):
81
+ if not isinstance(value, hint):
82
+ return hint(**value)
83
+ return value
84
+
85
+ # Handle single Enum
86
+ if inspect.isclass(hint) and issubclass(hint, Enum):
87
+ if not isinstance(value, hint):
88
+ return hint(value)
89
+ return value
90
+
91
+ # Handle List[...] of Pydantic models or other types
92
+ if origin is list:
93
+ inner_type = args[0]
94
+ # If the list elements are Pydantic models
95
+ if inspect.isclass(inner_type) and issubclass(inner_type, BaseModel):
96
+ return [
97
+ inner_type(**item) if not isinstance(item, inner_type) else item
98
+ for item in value
99
+ ]
100
+ # If the list elements are Enums, or other known transformations, handle similarly
101
+ if inspect.isclass(inner_type) and issubclass(inner_type, Enum):
102
+ return [
103
+ inner_type(item) if not isinstance(item, inner_type) else item
104
+ for item in value
105
+ ]
106
+ # Otherwise, just return the list as is
107
+ return value
108
+
109
+ # If none of the above, just return the value unchanged.
110
+ return value
111
+
112
+ # Now loop through each argument in kwargs and parse
113
+ for arg, value in kwargs.items():
114
+ if arg in type_hints:
115
+ hint = type_hints[arg]
116
+ kwargs[arg] = parse_value_into_hint(value, hint)
117
+
118
+ return cls(**kwargs)
119
+
120
+ SKIP_REGISTRATION = os.getenv("SKIP_REGISTRATION", "").lower() == "true"
121
+ def insert_in_registry(registry, cls, register_in_agent, name=None, attributes: Optional[dict]=None):
122
+ """ helper for strategy decorators """
123
+ key = name or cls.__name__ # Use the given name or the class name as the key
124
+
125
+ if key in registry and register_in_agent:
126
+ logger.debug(f"{cls.TYPE} '{key}' is already registered.")
127
+ return cls
128
+
129
+ registry[key] = cls
130
+ logger.debug(f"Registered {cls.TYPE} class '{key}': {cls}")
131
+
132
+ if register_in_agent and not SKIP_REGISTRATION and runs_in_main_process():
133
+ # send_resource_to_backend(cls, attributes)
134
+ Thread(
135
+ target=send_resource_to_backend,
136
+ args=(cls, attributes),
137
+ ).start()
138
+
139
+ return cls
140
+
141
+
142
+ class BaseFactory:
143
+ @staticmethod
144
+ def import_module(strategy_name):
145
+ VFB_PROJECT_PATH = os.environ.get("VFB_PROJECT_PATH", None)
146
+ assert VFB_PROJECT_PATH, "There is no signals folder variable specified"
147
+
148
+ project_path = Path(VFB_PROJECT_PATH)
149
+
150
+ strategy_folder_path = project_path / strategy_name
151
+ logger.debug(f"Registering signals from {strategy_folder_path}")
152
+ package_name = f"{project_path.name}.{strategy_name}"
153
+
154
+ project_root_path = project_path.parent.parent
155
+ if project_root_path not in sys.path:
156
+ sys.path.insert(0, project_root_path)
157
+
158
+ for filename in os.listdir(strategy_folder_path):
159
+ try:
160
+ if filename.endswith(".py"):
161
+ # Build the full module name
162
+ module_name = f"{package_name}.{filename[:-3]}"
163
+
164
+ # Dynamically import the module
165
+ module = importlib.import_module(module_name)
166
+ except Exception as e:
167
+ logger.warning(f"Error reading code in strategy {filename}: {e}")
168
+
169
+
170
+ def send_resource_to_backend(resource_class, attributes: Optional[dict] = None):
171
+ """
172
+ Parses the __init__ signatures of the class and its parents to generate a
173
+ unified JSON schema and sends the resource payload to the backend.
174
+ """
175
+ merged_properties = {}
176
+ merged_required = set()
177
+ merged_definitions = {}
178
+
179
+ # Special case for BaseApp subclasses that use a configuration_class
180
+ if hasattr(resource_class, 'configuration_class') and inspect.isclass(resource_class.configuration_class) and issubclass(resource_class.configuration_class,
181
+ BaseModel):
182
+ config_class = resource_class.configuration_class
183
+ config_name = config_class.__name__
184
+
185
+ # Get the full schema for the configuration class
186
+ config_schema = config_class.model_json_schema(ref_template="#/$defs/{model}")
187
+
188
+ # Merge any nested definitions from the config schema
189
+ if "$defs" in config_schema:
190
+ merged_definitions.update(config_schema.pop("$defs"))
191
+
192
+ # Add the configuration class's own schema to the definitions
193
+ merged_definitions[config_name] = config_schema
194
+
195
+ # Create a top-level "configuration" property that references the schema
196
+ merged_properties["configuration"] = {
197
+ "$ref": f"#/$defs/{config_name}",
198
+ "title": "Configuration"
199
+ }
200
+ # Mark the top-level "configuration" as required
201
+ merged_required.add("configuration")
202
+
203
+ else:
204
+ # Standard logic for other resource types
205
+ for parent_class in reversed(resource_class.__mro__):
206
+ if parent_class is object or not hasattr(parent_class, '__init__') or parent_class is DataNode:
207
+ continue
208
+ if "__init__" in parent_class.__dict__:
209
+ parent_schema = create_schema_from_signature(parent_class.__init__)
210
+ merged_properties.update(parent_schema.get("properties", {}))
211
+ merged_definitions.update(parent_schema.get("$defs", {}))
212
+ merged_required.update(parent_schema.get("required", []))
213
+
214
+ final_json_schema = {
215
+ "title": resource_class.__name__,
216
+ "type": "object",
217
+ "properties": merged_properties,
218
+ }
219
+ if merged_required:
220
+ schema_required = sorted([
221
+ field for field in merged_required
222
+ if 'default' not in merged_properties.get(field, {})
223
+ ])
224
+ if schema_required:
225
+ final_json_schema["required"] = schema_required
226
+
227
+ if merged_definitions:
228
+ final_json_schema["$defs"] = merged_definitions
229
+
230
+ resource_config = DynamicResource.create(
231
+ name=resource_class.__name__,
232
+ type=resource_class.TYPE.value,
233
+ object_signature=final_json_schema,
234
+ attributes=attributes,
235
+ )
236
+
237
+ logger.debug(f"Sending resource '{resource_class.__name__}' to backend.")
238
+
@@ -0,0 +1,101 @@
1
+ import pandas as pd
2
+ import datetime
3
+ import logging
4
+ import pandas_market_calendars as mcal
5
+
6
+ from mainsequence.virtualfundbuilder.enums import ResourceType
7
+ from mainsequence.virtualfundbuilder.resource_factory.base_factory import BaseFactory, BaseResource, insert_in_registry
8
+
9
+ logger = logging.getLogger("virtualfundbuilder")
10
+
11
+ class RebalanceStrategyBase(BaseResource):
12
+ TYPE = ResourceType.REBALANCE_STRATEGY
13
+
14
+ def __init__(self,
15
+ calendar: str='24/7',
16
+ *args, **kwargs
17
+ ):
18
+ """
19
+ Args:
20
+ calendar (str): Trading calendar. The string should must be valid calendar from the pandas_market_calendars (like '24/7' or 'NYSE')
21
+ """
22
+ self._calendar = calendar
23
+
24
+ @property
25
+ def calendar(self):
26
+ """ Workaround due to error when pickleing the calendar """
27
+ return mcal.get_calendar(self._calendar)
28
+
29
+ def get_explanation(self):
30
+ info = f"""
31
+ <p>{self.__class__.__name__}: Rebalance strategy class.</p>
32
+ """
33
+ return info
34
+
35
+ def calculate_rebalance_dates(
36
+ self,
37
+ start: datetime.datetime,
38
+ end: datetime.datetime,
39
+ calendar,
40
+ rebalance_frequency_strategy: str
41
+ ) -> pd.DatetimeIndex:
42
+ """
43
+ Determines the dates on which portfolio rebalancing should be executed based on the specified rebalancing strategy.
44
+ This calculation takes into account the start time of the rebalancing window and the execution frequency.
45
+
46
+ Args:
47
+ start (pd.DataFrame): A datetime containing the start time
48
+
49
+ Returns:
50
+ pd.DatetimeIndex: A DatetimeIndex containing all the dates when rebalancing should occur.
51
+ """
52
+ # to account for the time during the day at which the execution starts
53
+ if end is None:
54
+ raise NotImplementedError("end_date cannot be None")
55
+
56
+ if rebalance_frequency_strategy == "daily":
57
+ early = calendar.schedule(start_date=start.date(), end_date=end.date())
58
+ rebalance_dates = early.set_index("market_open")
59
+ rebalance_dates = rebalance_dates.index
60
+ elif rebalance_frequency_strategy == "EOQ":
61
+ # carefull to use dates from the same calendar
62
+ raise NotImplementedError
63
+ else:
64
+ raise NotImplementedError(f"Strategy {rebalance_frequency_strategy} not implemented")
65
+
66
+ return rebalance_dates
67
+
68
+ REBALANCE_CLASS_REGISTRY = REBALANCE_CLASS_REGISTRY if 'REBALANCE_CLASS_REGISTRY' in globals() else {}
69
+ def register_rebalance_class(name=None, register_in_agent=True):
70
+ """
71
+ Decorator to register a model class in the factory.
72
+ If `name` is not provided, the class's name is used as the key.
73
+ """
74
+ def decorator(cls):
75
+ return insert_in_registry(REBALANCE_CLASS_REGISTRY, cls, register_in_agent, name)
76
+ return decorator
77
+
78
+ class RebalanceFactory(BaseFactory):
79
+
80
+ @staticmethod
81
+ def get_rebalance_strategy(rebalance_strategy_name: str):
82
+ if rebalance_strategy_name not in REBALANCE_CLASS_REGISTRY:
83
+ RebalanceFactory.get_rebalance_strategies()
84
+ try:
85
+ return REBALANCE_CLASS_REGISTRY[rebalance_strategy_name]
86
+ except KeyError:
87
+ logger.exception(f"{rebalance_strategy_name} is not registered in this project")
88
+
89
+ @staticmethod
90
+ def get_rebalance_strategies():
91
+ import mainsequence.virtualfundbuilder.contrib.rebalance_strategies # get default strategies
92
+ try:
93
+ RebalanceFactory.import_module("rebalance_strategies")
94
+ except FileNotFoundError:
95
+ logger.info("rebalance_strategies folder no present no strategies to import")
96
+ return REBALANCE_CLASS_REGISTRY
97
+
98
+
99
+
100
+
101
+
@@ -0,0 +1,183 @@
1
+ import ast
2
+ import inspect
3
+
4
+ from mainsequence.tdag.data_nodes import DataNode
5
+ from datetime import datetime, timedelta
6
+ import numpy as np
7
+ import pytz
8
+
9
+ from mainsequence.virtualfundbuilder.enums import ResourceType
10
+ from mainsequence.virtualfundbuilder.resource_factory.base_factory import BaseResource, BaseFactory, insert_in_registry
11
+ from mainsequence.virtualfundbuilder.models import AssetsConfiguration
12
+
13
+ import pandas as pd
14
+ from mainsequence.client import (Asset)
15
+ from mainsequence.virtualfundbuilder.utils import get_vfb_logger
16
+
17
+ logger = get_vfb_logger()
18
+
19
+
20
+ class WeightsBase(BaseResource):
21
+ TYPE = ResourceType.SIGNAL_WEIGHTS_STRATEGY
22
+
23
+ def __init__(self,
24
+ signal_assets_configuration: AssetsConfiguration,
25
+ *args, **kwargs):
26
+ """
27
+ Base Class for all signal weights
28
+
29
+ Attributes:
30
+ assets_configuration (AssetsConfiguration): Configuration details for signal assets.
31
+ """
32
+ self.assets_configuration = signal_assets_configuration
33
+ super().__init__()
34
+
35
+ def get_explanation(self):
36
+ info = f"""
37
+ <p>{self.__class__.__name__}: Signal weights class.</p>
38
+ """
39
+ return info
40
+
41
+ def maximum_forward_fill(self) -> timedelta:
42
+ raise NotImplementedError
43
+
44
+ def get_asset_uid_to_override_portfolio_price(self):
45
+
46
+ return None
47
+
48
+ def interpolate_index(self, new_index: pd.DatetimeIndex):
49
+ """
50
+ Get interpolated weights for a time index. Weights are only valid for a certain time, therefore forward fill is limited.
51
+ Especially needed for gaps within the weights
52
+ """
53
+ # get values between new index
54
+ try:
55
+ weights = self.get_df_between_dates(start_date=new_index.min(), end_date=new_index.max())
56
+ except Exception as e:
57
+ raise e
58
+
59
+ # if we need more data before to interpolate first value of new_index
60
+ if len(weights) == 0: # or (weights.index.get_level_values("time_index").min() > new_index.min()):
61
+
62
+ unique_identifier_range_map = {a: {"start_date": d} for a, d in self.update_statistics.asset_time_statistics.items()}
63
+ last_observation = self.get_df_between_dates(unique_identifier_range_map=unique_identifier_range_map)
64
+ if last_observation is None or last_observation.empty:
65
+ return pd.DataFrame()
66
+ last_date = last_observation.index.get_level_values("time_index")[0]
67
+
68
+ if last_date < new_index.min():
69
+ self.logger.warning(f"No weights data at start of the portfolio at {new_index.min()}"
70
+ f" will use last available weights {last_date}")
71
+ weights = self.get_df_between_dates(start_date=last_date, end_date=new_index.max())
72
+
73
+ if len(weights) == 0 :
74
+ self.logger.warning(f"No weights data in index interpolation")
75
+ return pd.DataFrame()
76
+
77
+
78
+
79
+ weights_pivot = weights.reset_index().pivot(
80
+ index="time_index",
81
+ columns=[ "unique_identifier"],
82
+ values="signal_weight").fillna(0)
83
+ weights_pivot["last_weights"] = weights_pivot.index.get_level_values(level="time_index")
84
+
85
+ # combine existing index with new index
86
+ combined_index = weights_pivot.index.union(new_index)
87
+ combined_index.name = "time_index"
88
+ weights_reindex = weights_pivot.reindex(combined_index)
89
+
90
+ # check which dates are outside of valid forward filling range
91
+ weights_reindex["last_weights"] = weights_reindex["last_weights"].ffill()
92
+ weights_reindex["diff_to_last_weights"] = weights_reindex.index.get_level_values(level="time_index") - \
93
+ weights_reindex["last_weights"]
94
+
95
+ invalid_forward_fills = weights_reindex["diff_to_last_weights"] >= self.maximum_forward_fill() # source_frequency is the duration a weight is valid
96
+ weights_reindex.drop(columns=["last_weights", "diff_to_last_weights"], inplace=True)
97
+
98
+ # forward fill and set dates that are outside of valid range to nan
99
+ weights_reindex = weights_reindex.ffill()
100
+ weights_reindex[invalid_forward_fills] = np.nan
101
+
102
+ if weights_reindex.isna().values.any():
103
+ self.logger.info(f"Could not fully interpolate for signal weights")
104
+
105
+ weights_reindex = weights_reindex.loc[new_index]
106
+ weights_reindex.index.name = "time_index"
107
+
108
+
109
+
110
+ return weights_reindex
111
+
112
+ def _get_class_source_code(cls):
113
+ import ast
114
+ import inspect
115
+ import sys
116
+
117
+ try:
118
+ # Get the source code of the module where the class is defined
119
+ module = sys.modules[cls.__module__]
120
+ source = inspect.getsource(module)
121
+ except Exception as e:
122
+ logger.warning(f"Could not get source code for module {cls.__module__}: {e}")
123
+ return None
124
+
125
+ # Parse the module's source code
126
+ try:
127
+ module_ast = ast.parse(source)
128
+ class_source_code = None
129
+
130
+ # Iterate through the module's body to find the class definition
131
+ for node in module_ast.body:
132
+ if isinstance(node, ast.ClassDef) and node.name == cls.__name__:
133
+ # Get the lines corresponding to the class definition
134
+ lines = source.splitlines()
135
+ # Get the lines for the class definition
136
+ class_source_lines = lines[node.lineno - 1: node.end_lineno]
137
+ class_source_code = '\n'.join(class_source_lines)
138
+ break
139
+
140
+ if not class_source_code:
141
+ logger.warning(f"Class definition for {cls.__name__} not found in module {cls.__module__}")
142
+ return None
143
+
144
+ return class_source_code
145
+
146
+ except Exception as e:
147
+ logger.warning(f"Could not parse source code for module {cls.__module__}: {e}")
148
+ return None
149
+
150
+
151
+ SIGNAL_CLASS_REGISTRY = SIGNAL_CLASS_REGISTRY if 'SIGNAL_CLASS_REGISTRY' in globals() else {}
152
+ def register_signal_class(name=None, register_in_agent=True):
153
+ """
154
+ Decorator to register a model class in the factory.
155
+ If `name` is not provided, the class's name is used as the key.
156
+ """
157
+ def decorator(cls):
158
+ code = inspect.getsource(cls)
159
+ attributes = {"code": code}
160
+ return insert_in_registry(SIGNAL_CLASS_REGISTRY, cls, register_in_agent, attributes=attributes)
161
+ return decorator
162
+
163
+
164
+ class SignalWeightsFactory(BaseFactory):
165
+ @staticmethod
166
+ def get_signal_weights_strategy(signal_weights_name) -> DataNode:
167
+ """
168
+ Creates an instance of the appropriate SignalWeights class based on the provided name.
169
+ """
170
+ if signal_weights_name not in SIGNAL_CLASS_REGISTRY:
171
+ SignalWeightsFactory.get_signal_weights_strategies()
172
+
173
+ return SIGNAL_CLASS_REGISTRY[signal_weights_name]
174
+
175
+ @staticmethod
176
+ def get_signal_weights_strategies():
177
+ """
178
+ Scans the given directory for Python files, imports the classes,
179
+ and returns all classes that are subclasses of WeightsBase.
180
+ """
181
+ import mainsequence.virtualfundbuilder.contrib.data_nodes # get default strategies
182
+ SignalWeightsFactory.import_module("data_nodes")
183
+ return SIGNAL_CLASS_REGISTRY