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