xpectral 1.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.
- xpectral/__init__.py +20 -0
- xpectral/charts/__init__.py +17 -0
- xpectral/charts/_decorators.py +73 -0
- xpectral/charts/_figure.py +86 -0
- xpectral/charts/polars_accessors.py +256 -0
- xpectral/charts/theme_manager.py +136 -0
- xpectral/data/__init__.py +16 -0
- xpectral/data/massive.py +97 -0
- xpectral/quant/__init__.py +18 -0
- xpectral/quant/polars_accessors.py +142 -0
- xpectral/quant/polars_expressions.py +39 -0
- xpectral/quant/portfolio.py +82 -0
- xpectral/utils/logger.py +55 -0
- xpectral/utils/rate_limiter.py +52 -0
- xpectral-1.0.dist-info/METADATA +72 -0
- xpectral-1.0.dist-info/RECORD +19 -0
- xpectral-1.0.dist-info/WHEEL +5 -0
- xpectral-1.0.dist-info/licenses/LICENSE +21 -0
- xpectral-1.0.dist-info/top_level.txt +1 -0
xpectral/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
# Other imports
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
from . import charts
|
|
11
|
+
from . import data
|
|
12
|
+
from . import quant
|
|
13
|
+
|
|
14
|
+
#-----------------------------------------------------------------------------
|
|
15
|
+
# Globals and constants
|
|
16
|
+
#-----------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
load_dotenv(
|
|
19
|
+
dotenv_path=Path(__file__).resolve().parent / ".env"
|
|
20
|
+
)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
|
|
7
|
+
# Other imports
|
|
8
|
+
from .polars_accessors import BokehAccessor
|
|
9
|
+
from .polars_accessors import Figure
|
|
10
|
+
|
|
11
|
+
#-----------------------------------------------------------------------------
|
|
12
|
+
# Globals and constants
|
|
13
|
+
#-----------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BokehAccessor"
|
|
17
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
from functools import wraps
|
|
8
|
+
from inspect import Parameter
|
|
9
|
+
from inspect import Signature
|
|
10
|
+
from inspect import signature
|
|
11
|
+
|
|
12
|
+
# Other imports
|
|
13
|
+
from bokeh.plotting._docstring import generate_docstring
|
|
14
|
+
from bokeh.plotting._renderer import create_renderer
|
|
15
|
+
|
|
16
|
+
#-----------------------------------------------------------------------------
|
|
17
|
+
# Globals and constants
|
|
18
|
+
#-----------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
#-----------------------------------------------------------------------------
|
|
21
|
+
# General API
|
|
22
|
+
#-----------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
def glyph_method(glyphclass):
|
|
25
|
+
def decorator(func):
|
|
26
|
+
parameters = glyphclass.parameters()
|
|
27
|
+
|
|
28
|
+
# TODO: send issue so that this signature only takes glyphclass.args instead of [x[0] for x in parameters]
|
|
29
|
+
sigparams = (
|
|
30
|
+
[Parameter("self", Parameter.POSITIONAL_OR_KEYWORD)]
|
|
31
|
+
+ [x[0] for x in parameters]
|
|
32
|
+
+ [Parameter("source", Parameter.KEYWORD_ONLY, default=None)]
|
|
33
|
+
+ [Parameter("kwargs", Parameter.VAR_KEYWORD)]
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@wraps(func)
|
|
37
|
+
def wrapped(self, *args, **kwargs):
|
|
38
|
+
# Validate positional arguments against the glyph's declared positional field order.
|
|
39
|
+
if len(args) > len(glyphclass._args):
|
|
40
|
+
raise TypeError(f"{func.__name__} takes {len(glyphclass._args)} positional argument but {len(args)} were given")
|
|
41
|
+
# Map positional inputs to their corresponding glyph kwargs by param name.
|
|
42
|
+
for arg, param in zip(args, sigparams[1:]):
|
|
43
|
+
kwargs[param.name] = arg
|
|
44
|
+
# Default source comes from the accessor unless explicitly provided.
|
|
45
|
+
kwargs.setdefault("source", self.source)
|
|
46
|
+
# Preserve any coordinate context passed through a custom Figure subclass.
|
|
47
|
+
if self.coordinates is not None:
|
|
48
|
+
kwargs.setdefault("coordinates", self.coordinates)
|
|
49
|
+
# Inspect/create synthetic fields on the source when required by the glyph.
|
|
50
|
+
source = kwargs["source"]
|
|
51
|
+
n = len(next(iter(source.data.values()), ()))
|
|
52
|
+
# If x is required but missing, inject a 1..n index column into source and reference it by name.
|
|
53
|
+
if "x" in glyphclass._args and "x" not in kwargs and "x" not in source.data:
|
|
54
|
+
source.data["x"] = list(range(1, n + 1))
|
|
55
|
+
kwargs["x"] = "x"
|
|
56
|
+
# If y is required but missing, inject a 1..n index column into source and reference it by name.
|
|
57
|
+
if "y" in glyphclass._args and "y" not in kwargs and "y" not in source.data:
|
|
58
|
+
source.data["y"] = list(range(1, n + 1))
|
|
59
|
+
kwargs["y"] = "y"
|
|
60
|
+
# Delegate to Bokeh with normalized kwargs and possibly augmented source columns.
|
|
61
|
+
return create_renderer(glyphclass, self.plot, **kwargs)
|
|
62
|
+
|
|
63
|
+
wrapped.__signature__ = Signature(parameters=sigparams, return_annotation=signature(func).return_annotation)
|
|
64
|
+
|
|
65
|
+
wrapped.__doc__ = generate_docstring(glyphclass, parameters, func.__doc__)
|
|
66
|
+
|
|
67
|
+
return wrapped
|
|
68
|
+
|
|
69
|
+
return decorator
|
|
70
|
+
|
|
71
|
+
#-----------------------------------------------------------------------------
|
|
72
|
+
# Private API
|
|
73
|
+
#-----------------------------------------------------------------------------
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
# Other imports
|
|
9
|
+
from bokeh.models import Plot
|
|
10
|
+
from bokeh.plotting._figure import FigureOptions
|
|
11
|
+
from bokeh.plotting._plot import get_range
|
|
12
|
+
from bokeh.plotting._plot import get_scale
|
|
13
|
+
from bokeh.plotting._plot import process_axis_and_grid
|
|
14
|
+
from bokeh.plotting._tools import process_active_tools
|
|
15
|
+
from bokeh.plotting._tools import process_tools_arg
|
|
16
|
+
|
|
17
|
+
#-----------------------------------------------------------------------------
|
|
18
|
+
# Globals and constants
|
|
19
|
+
#-----------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
__all__ = ["Figure"]
|
|
22
|
+
|
|
23
|
+
#-----------------------------------------------------------------------------
|
|
24
|
+
# General API
|
|
25
|
+
#-----------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
class Figure(Plot):
|
|
28
|
+
|
|
29
|
+
def __init__(self, *arg, **kwargs) -> None:
|
|
30
|
+
opts = FigureOptions(kwargs)
|
|
31
|
+
|
|
32
|
+
names = self.properties()
|
|
33
|
+
for name in kwargs.keys():
|
|
34
|
+
if name not in names:
|
|
35
|
+
self._raise_attribute_error_with_matches(name, names | opts.properties())
|
|
36
|
+
|
|
37
|
+
super().__init__(*arg, **kwargs)
|
|
38
|
+
|
|
39
|
+
self.x_range = get_range(opts.x_range)
|
|
40
|
+
self.y_range = get_range(opts.y_range)
|
|
41
|
+
|
|
42
|
+
self.x_scale = get_scale(self.x_range, opts.x_axis_type)
|
|
43
|
+
self.y_scale = get_scale(self.y_range, opts.y_axis_type)
|
|
44
|
+
|
|
45
|
+
process_axis_and_grid(
|
|
46
|
+
self,
|
|
47
|
+
opts.x_axis_type,
|
|
48
|
+
opts.x_axis_location,
|
|
49
|
+
opts.x_minor_ticks,
|
|
50
|
+
opts.x_axis_label,
|
|
51
|
+
self.x_range,
|
|
52
|
+
0,
|
|
53
|
+
)
|
|
54
|
+
process_axis_and_grid(
|
|
55
|
+
self,
|
|
56
|
+
opts.y_axis_type,
|
|
57
|
+
opts.y_axis_location,
|
|
58
|
+
opts.y_minor_ticks,
|
|
59
|
+
opts.y_axis_label,
|
|
60
|
+
self.y_range,
|
|
61
|
+
1,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
tool_objs, tool_map = process_tools_arg(self, opts.tools, opts.tooltips)
|
|
65
|
+
self.add_tools(*tool_objs)
|
|
66
|
+
process_active_tools(
|
|
67
|
+
self.toolbar,
|
|
68
|
+
tool_map,
|
|
69
|
+
opts.active_drag,
|
|
70
|
+
opts.active_inspect,
|
|
71
|
+
opts.active_scroll,
|
|
72
|
+
opts.active_tap,
|
|
73
|
+
opts.active_multi,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def plot(self):
|
|
78
|
+
return self
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def coordinates(self):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
#-----------------------------------------------------------------------------
|
|
85
|
+
# Private API
|
|
86
|
+
#-----------------------------------------------------------------------------
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
import warnings
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
# Other imports
|
|
11
|
+
from bokeh.models import ColumnDataSource
|
|
12
|
+
from bokeh.models import glyphs
|
|
13
|
+
from bokeh.models.renderers import GlyphRenderer
|
|
14
|
+
from bokeh.plotting._stack import double_stack
|
|
15
|
+
from bokeh.plotting._stack import single_stack
|
|
16
|
+
from bokeh.util.warnings import BokehUserWarning
|
|
17
|
+
import polars as pl
|
|
18
|
+
from ._decorators import glyph_method
|
|
19
|
+
from ._figure import Figure
|
|
20
|
+
|
|
21
|
+
#-----------------------------------------------------------------------------
|
|
22
|
+
# Globals and constants
|
|
23
|
+
#-----------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
warnings.simplefilter("ignore", BokehUserWarning)
|
|
26
|
+
|
|
27
|
+
#-----------------------------------------------------------------------------
|
|
28
|
+
# General API
|
|
29
|
+
#-----------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
@pl.api.register_dataframe_namespace("bokeh")
|
|
32
|
+
class BokehAccessor(Figure):
|
|
33
|
+
|
|
34
|
+
__view_model__ = "Figure"
|
|
35
|
+
__view_module__ = "bokeh.plotting.figure"
|
|
36
|
+
|
|
37
|
+
def __init__(self, df: pl.DataFrame) -> None:
|
|
38
|
+
self._df = df
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def source(self) -> ColumnDataSource:
|
|
42
|
+
if not hasattr(self, "_source"):
|
|
43
|
+
self._source = ColumnDataSource(self._df.to_dict(as_series=False))
|
|
44
|
+
return self._source
|
|
45
|
+
|
|
46
|
+
def __call__(self, *args, **kwargs) -> "BokehAccessor":
|
|
47
|
+
super().__init__(*args, **kwargs)
|
|
48
|
+
return self.plot
|
|
49
|
+
|
|
50
|
+
#-------------------------------------------
|
|
51
|
+
# Glyph methods with both x and y parameters
|
|
52
|
+
@glyph_method(glyphs.AnnularWedge)
|
|
53
|
+
def annular_wedge(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@glyph_method(glyphs.Annulus)
|
|
57
|
+
def annulus(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
@glyph_method(glyphs.Arc)
|
|
61
|
+
def arc(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
@glyph_method(glyphs.Block)
|
|
65
|
+
def block(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
@glyph_method(glyphs.Ellipse)
|
|
69
|
+
def ellipse(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
@glyph_method(glyphs.Image)
|
|
73
|
+
def image(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
@glyph_method(glyphs.ImageRGBA)
|
|
77
|
+
def image_rgba(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
@glyph_method(glyphs.ImageStack)
|
|
81
|
+
def image_stack(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
@glyph_method(glyphs.ImageURL)
|
|
85
|
+
def image_url(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
@glyph_method(glyphs.Line)
|
|
89
|
+
def line(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
@glyph_method(glyphs.MathMLGlyph)
|
|
93
|
+
def mathml_glyph(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
@glyph_method(glyphs.Ngon)
|
|
97
|
+
def ngon(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
@glyph_method(glyphs.Patch)
|
|
101
|
+
def patch(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
@glyph_method(glyphs.Ray)
|
|
105
|
+
def ray(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
@glyph_method(glyphs.Rect)
|
|
109
|
+
def rect(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
@glyph_method(glyphs.Scatter)
|
|
113
|
+
def scatter(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
114
|
+
pass
|
|
115
|
+
|
|
116
|
+
@glyph_method(glyphs.Step)
|
|
117
|
+
def step(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
@glyph_method(glyphs.TeXGlyph)
|
|
121
|
+
def tex_glyph(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
@glyph_method(glyphs.Text)
|
|
125
|
+
def text(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
@glyph_method(glyphs.Wedge)
|
|
129
|
+
def wedge(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
#----------------------------------------------------
|
|
133
|
+
# Vertical glyph methods (have parameter x but not y)
|
|
134
|
+
@glyph_method(glyphs.VArea)
|
|
135
|
+
def varea(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
@glyph_method(glyphs.VAreaStep)
|
|
139
|
+
def varea_step(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
@glyph_method(glyphs.VBar)
|
|
143
|
+
def vbar(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
@glyph_method(glyphs.VSpan)
|
|
147
|
+
def vspan(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
#------------------------------------------------------
|
|
151
|
+
# Horizontal glyph methods (have parameter y but not x)
|
|
152
|
+
@glyph_method(glyphs.HArea)
|
|
153
|
+
def harea(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
154
|
+
pass
|
|
155
|
+
|
|
156
|
+
@glyph_method(glyphs.HAreaStep)
|
|
157
|
+
def harea_step(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
158
|
+
pass
|
|
159
|
+
|
|
160
|
+
@glyph_method(glyphs.HBar)
|
|
161
|
+
def hbar(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
@glyph_method(glyphs.HSpan)
|
|
165
|
+
def hspan(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
166
|
+
pass
|
|
167
|
+
|
|
168
|
+
#-----------------------------------------
|
|
169
|
+
# Glyph methods without x nor y parameters
|
|
170
|
+
@glyph_method(glyphs.Bezier)
|
|
171
|
+
def bezier(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
@glyph_method(glyphs.HStrip)
|
|
175
|
+
def hstrip(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
@glyph_method(glyphs.HexTile)
|
|
179
|
+
def hextile(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
@glyph_method(glyphs.MultiLine)
|
|
183
|
+
def multi_line(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
@glyph_method(glyphs.MultiPolygons)
|
|
187
|
+
def multipolygons(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
@glyph_method(glyphs.Patches)
|
|
191
|
+
def patches(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
@glyph_method(glyphs.Quad)
|
|
195
|
+
def quad(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
196
|
+
pass
|
|
197
|
+
|
|
198
|
+
@glyph_method(glyphs.Quadratic)
|
|
199
|
+
def quadratic(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
@glyph_method(glyphs.Segment)
|
|
203
|
+
def segment(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
@glyph_method(glyphs.VStrip)
|
|
207
|
+
def vstrip(self, *args: Any, **kwargs: Any) -> GlyphRenderer:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
#-----------------------------------------
|
|
211
|
+
# Glyph stack methods
|
|
212
|
+
def harea_stack(self, stackers: list[str], **kwargs: Any) -> list[GlyphRenderer]:
|
|
213
|
+
result = []
|
|
214
|
+
for kwarg in double_stack(stackers=stackers, spec0="x1", spec1="x2", **kwargs):
|
|
215
|
+
result.append(self.harea(**kwarg))
|
|
216
|
+
return result
|
|
217
|
+
|
|
218
|
+
def hbar_stack(self, stackers: list[str], **kwargs: Any) -> list[GlyphRenderer]:
|
|
219
|
+
result = []
|
|
220
|
+
for kwarg in double_stack(stackers=stackers, spec0="left", spec1="right", **kwargs):
|
|
221
|
+
result.append(self.hbar(**kwarg))
|
|
222
|
+
return result
|
|
223
|
+
|
|
224
|
+
def hline_stack(self, stackers: list[str], **kwargs: Any) -> list[GlyphRenderer]:
|
|
225
|
+
result = []
|
|
226
|
+
for kwarg in single_stack(stackers=stackers, spec="x", **kwargs):
|
|
227
|
+
result.append(self.line(**kwarg))
|
|
228
|
+
return result
|
|
229
|
+
|
|
230
|
+
def varea_stack(self, stackers: list[str], **kwargs: Any) -> list[GlyphRenderer]:
|
|
231
|
+
result = []
|
|
232
|
+
for kwarg in double_stack(stackers=stackers, spec0="y1", spec1="y2", **kwargs):
|
|
233
|
+
result.append(self.varea(**kwarg))
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
def vbar_stack(self, stackers: list[str], **kwargs: Any) -> list[GlyphRenderer]:
|
|
237
|
+
result = []
|
|
238
|
+
for kwarg in double_stack(stackers=stackers, spec0="bottom", spec1="top", **kwargs):
|
|
239
|
+
result.append(self.vbar(**kwarg))
|
|
240
|
+
return result
|
|
241
|
+
|
|
242
|
+
def vline_stack(self, stackers: list[str], **kwargs: Any) -> list[GlyphRenderer]:
|
|
243
|
+
result = []
|
|
244
|
+
for kwarg in single_stack(stackers=stackers, spec="y", **kwargs):
|
|
245
|
+
result.append(self.line(**kwarg))
|
|
246
|
+
return result
|
|
247
|
+
|
|
248
|
+
#-----------------------------------------------------------------------------
|
|
249
|
+
# Private API
|
|
250
|
+
#-----------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
# Polars' NameSpace descriptor caches the accessor on the DataFrame instance
|
|
253
|
+
# via setattr, causing the same BokehAccessor to be returned on repeated
|
|
254
|
+
# accesses of df.bokeh. Replacing the descriptor with a non-caching property
|
|
255
|
+
# ensures each access produces a fresh instance.
|
|
256
|
+
pl.DataFrame.bokeh = property(lambda self: BokehAccessor(self)) # type: ignore[assignment]
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Central theme management for Bokeh apps."""
|
|
2
|
+
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
# Imports
|
|
5
|
+
#-----------------------------------------------------------------------------
|
|
6
|
+
|
|
7
|
+
# Standard library imports
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
# Other imports
|
|
11
|
+
from bokeh.io import curdoc
|
|
12
|
+
from bokeh.themes import built_in_themes
|
|
13
|
+
from bokeh.themes import Theme
|
|
14
|
+
|
|
15
|
+
#-----------------------------------------------------------------------------
|
|
16
|
+
# Globals and constants
|
|
17
|
+
#-----------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
THEMES = {
|
|
20
|
+
"caliber": built_in_themes["caliber"],
|
|
21
|
+
"carbon": built_in_themes["carbon"],
|
|
22
|
+
"light_minimal": built_in_themes["light_minimal"],
|
|
23
|
+
"dark_minimal": built_in_themes["dark_minimal"],
|
|
24
|
+
"night_sky": built_in_themes["night_sky"],
|
|
25
|
+
"contrast": built_in_themes["contrast"],
|
|
26
|
+
"ocean": Theme(
|
|
27
|
+
json={
|
|
28
|
+
"attrs": {
|
|
29
|
+
"Toolbar": {
|
|
30
|
+
"logo": None
|
|
31
|
+
},
|
|
32
|
+
"Plot": {
|
|
33
|
+
"background_fill_color": "#e8e8ea",
|
|
34
|
+
"border_fill_color": "#e8e8ea",
|
|
35
|
+
"outline_line_color": "#253d8f",
|
|
36
|
+
"outline_line_alpha": 0.3,
|
|
37
|
+
"min_border_left": 36,
|
|
38
|
+
"min_border_right": 24,
|
|
39
|
+
"min_border_top": 26,
|
|
40
|
+
"min_border_bottom": 36,
|
|
41
|
+
},
|
|
42
|
+
"Grid": {
|
|
43
|
+
"grid_line_color": "#497bbc",
|
|
44
|
+
"grid_line_alpha": 0.2,
|
|
45
|
+
},
|
|
46
|
+
"Axis": {
|
|
47
|
+
"major_tick_line_alpha": 0.35,
|
|
48
|
+
"major_tick_line_color": "#6b7ab0",
|
|
49
|
+
"minor_tick_line_alpha": 0.25,
|
|
50
|
+
"minor_tick_line_color": "#6b7ab0",
|
|
51
|
+
"axis_line_alpha": 0.5,
|
|
52
|
+
"axis_line_color": "#6b7ab0",
|
|
53
|
+
"major_label_text_color": "#4d4d4d",
|
|
54
|
+
"major_label_text_font": "Helvetica",
|
|
55
|
+
"major_label_text_font_size": "1.025em",
|
|
56
|
+
"axis_label_standoff": 18,
|
|
57
|
+
"axis_label_text_color": "#6b7ab0",
|
|
58
|
+
"axis_label_text_font": "Helvetica",
|
|
59
|
+
"axis_label_text_font_size": "1.25em",
|
|
60
|
+
"axis_label_text_font_style": "normal",
|
|
61
|
+
},
|
|
62
|
+
"Legend": {
|
|
63
|
+
"spacing": 8,
|
|
64
|
+
"glyph_width": 15,
|
|
65
|
+
"label_standoff": 8,
|
|
66
|
+
"label_text_color": "#253d8f",
|
|
67
|
+
"label_text_font": "Helvetica",
|
|
68
|
+
"label_text_font_size": "1.025em",
|
|
69
|
+
"border_line_alpha": 0,
|
|
70
|
+
"background_fill_alpha": 0.45,
|
|
71
|
+
"background_fill_color": "#e8e8ea",
|
|
72
|
+
},
|
|
73
|
+
"BaseColorBar": {
|
|
74
|
+
"title_text_color": "#253d8f",
|
|
75
|
+
"title_text_font": "Helvetica",
|
|
76
|
+
"title_text_font_size": "1.025em",
|
|
77
|
+
"title_text_font_style": "normal",
|
|
78
|
+
"major_label_text_color": "#253d8f",
|
|
79
|
+
"major_label_text_font": "Helvetica",
|
|
80
|
+
"major_label_text_font_size": "1.025em",
|
|
81
|
+
"background_fill_color": "#e8e8ea",
|
|
82
|
+
"major_tick_line_alpha": 0,
|
|
83
|
+
"bar_line_alpha": 0,
|
|
84
|
+
},
|
|
85
|
+
"Title": {
|
|
86
|
+
"text_color": "#253d8f",
|
|
87
|
+
"text_font": "Helvetica",
|
|
88
|
+
"text_font_size": "1.15em",
|
|
89
|
+
},
|
|
90
|
+
"Line": {
|
|
91
|
+
"line_color": "#497bbc",
|
|
92
|
+
},
|
|
93
|
+
"Fill": {
|
|
94
|
+
"fill_color": "#bf4e30",
|
|
95
|
+
},
|
|
96
|
+
"Text": {
|
|
97
|
+
"text_color": "#253d8f",
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
),
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#-----------------------------------------------------------------------------
|
|
105
|
+
# General API
|
|
106
|
+
#-----------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
class ThemeAccessor:
|
|
109
|
+
"""Small accessor around a named collection of Bokeh themes."""
|
|
110
|
+
|
|
111
|
+
def __init__(self, default: str = "light") -> None:
|
|
112
|
+
if default not in THEMES:
|
|
113
|
+
raise ValueError(f"Unknown default theme '{default}'")
|
|
114
|
+
self._name = default
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def name(self) -> str:
|
|
118
|
+
return self._name
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def current(self):
|
|
122
|
+
return THEMES[self._name]
|
|
123
|
+
|
|
124
|
+
def set(self, name: str) -> None:
|
|
125
|
+
if name not in THEMES:
|
|
126
|
+
raise ValueError(f"Unknown theme '{name}'. Options: {list(THEMES)}")
|
|
127
|
+
self._name = name
|
|
128
|
+
curdoc().theme = self.current
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# Global accessor for app/notebook code.
|
|
132
|
+
theme = ThemeAccessor(default="light_minimal")
|
|
133
|
+
|
|
134
|
+
#-----------------------------------------------------------------------------
|
|
135
|
+
# Private API
|
|
136
|
+
#-----------------------------------------------------------------------------
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
|
|
7
|
+
# Other imports
|
|
8
|
+
from . import massive
|
|
9
|
+
|
|
10
|
+
#-----------------------------------------------------------------------------
|
|
11
|
+
# Globals and constants
|
|
12
|
+
#-----------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"massive"
|
|
16
|
+
]
|
xpectral/data/massive.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
from datetime import date
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from functools import lru_cache
|
|
9
|
+
from typing import Any
|
|
10
|
+
from typing import Dict
|
|
11
|
+
from typing import List
|
|
12
|
+
from typing import Optional
|
|
13
|
+
from typing import Tuple
|
|
14
|
+
from typing import Union
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
# Other imports
|
|
18
|
+
from massive import RESTClient
|
|
19
|
+
from massive.rest.models import Sort
|
|
20
|
+
import polars as pl
|
|
21
|
+
from ..utils.rate_limiter import RateLimiter
|
|
22
|
+
|
|
23
|
+
#-----------------------------------------------------------------------------
|
|
24
|
+
# Globals and constants
|
|
25
|
+
#-----------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
client = RESTClient(
|
|
28
|
+
api_key=os.getenv("MASSIVE_API_KEY", ""), pagination=True, trace=False
|
|
29
|
+
)
|
|
30
|
+
_rate_limiter = RateLimiter(calls=5, per_seconds=60)
|
|
31
|
+
|
|
32
|
+
#-----------------------------------------------------------------------------
|
|
33
|
+
# General API
|
|
34
|
+
#-----------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
def get_aggregate_bars(
|
|
37
|
+
tickers: List[str],
|
|
38
|
+
multiplier: int,
|
|
39
|
+
timespan: str,
|
|
40
|
+
from_: Union[str, int, datetime, date],
|
|
41
|
+
to: Union[str, int, datetime, date],
|
|
42
|
+
adjusted: Optional[bool] = True,
|
|
43
|
+
sort: Optional[Union[str, Sort]] = None,
|
|
44
|
+
limit: Optional[int] = None
|
|
45
|
+
):
|
|
46
|
+
# create kwargs dictionary from local variables (copy to avoid mutating locals directly)
|
|
47
|
+
kwargs = locals().copy()
|
|
48
|
+
# convert tickers to tuple for caching (lru_cache)
|
|
49
|
+
kwargs["tickers"] = tuple(kwargs["tickers"])
|
|
50
|
+
|
|
51
|
+
df = _get_aggregate_bars(**kwargs)
|
|
52
|
+
|
|
53
|
+
return df
|
|
54
|
+
|
|
55
|
+
#-----------------------------------------------------------------------------
|
|
56
|
+
# Private API
|
|
57
|
+
#-----------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
@lru_cache(maxsize=10)
|
|
60
|
+
def _get_aggregate_bars(**kwargs):
|
|
61
|
+
tickers = list(kwargs.pop("tickers"))
|
|
62
|
+
|
|
63
|
+
aggs = []
|
|
64
|
+
for ticker in tickers:
|
|
65
|
+
_rate_limiter.acquire()
|
|
66
|
+
for a in client.list_aggs(ticker=ticker, **kwargs):
|
|
67
|
+
data = a.__dict__
|
|
68
|
+
timestamp = data["timestamp"]
|
|
69
|
+
long_format = [
|
|
70
|
+
{"timestamp": timestamp, "ticker": ticker, "metric": key, "value": value}
|
|
71
|
+
for key, value in data.items() if key != "timestamp"
|
|
72
|
+
]
|
|
73
|
+
aggs.extend(long_format)
|
|
74
|
+
|
|
75
|
+
df = pl.DataFrame(data=aggs)
|
|
76
|
+
|
|
77
|
+
# transform unix timestamp to daily format
|
|
78
|
+
_FREQ_MAP = {
|
|
79
|
+
"second": "1s",
|
|
80
|
+
"minute": "1m",
|
|
81
|
+
}
|
|
82
|
+
df = df.with_columns(
|
|
83
|
+
pl.col("timestamp")
|
|
84
|
+
.cast(pl.Datetime("ms"))
|
|
85
|
+
.dt.replace_time_zone("UTC")
|
|
86
|
+
.dt.convert_time_zone("America/New_York")
|
|
87
|
+
.dt.truncate(_FREQ_MAP.get(kwargs.pop("timespan"), "1d"))
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# pivot polars dataframe to semi-wide format
|
|
91
|
+
df = df.pivot(
|
|
92
|
+
values="value",
|
|
93
|
+
index=["timestamp", "ticker"],
|
|
94
|
+
on="metric"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return df.lazy()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
|
|
7
|
+
# Other imports
|
|
8
|
+
from .polars_accessors import QuantAccessor
|
|
9
|
+
from .portfolio import Portfolio
|
|
10
|
+
|
|
11
|
+
#-----------------------------------------------------------------------------
|
|
12
|
+
# Globals and constants
|
|
13
|
+
#-----------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Portfolio",
|
|
17
|
+
"QuantAccessor",
|
|
18
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
|
|
7
|
+
# Other imports
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
#-----------------------------------------------------------------------------
|
|
11
|
+
# Globals and constants
|
|
12
|
+
#-----------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
#-----------------------------------------------------------------------------
|
|
15
|
+
# General API
|
|
16
|
+
#-----------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
@pl.api.register_expr_namespace("quant")
|
|
19
|
+
class QuantAccessor:
|
|
20
|
+
def __init__(self, expr: pl.Expr):
|
|
21
|
+
self._expr = expr
|
|
22
|
+
|
|
23
|
+
def returns(
|
|
24
|
+
self,
|
|
25
|
+
periods: int = 1,
|
|
26
|
+
over: str | None = None
|
|
27
|
+
) -> pl.Expr:
|
|
28
|
+
"""
|
|
29
|
+
Compute simple percentage return for the expression.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
periods : int
|
|
34
|
+
Number of periods to shift (default is 1).
|
|
35
|
+
over : str | None
|
|
36
|
+
Optional grouping column (e.g. 'ticker'). If None, compute over the full series.
|
|
37
|
+
"""
|
|
38
|
+
expr = (self._expr / self._expr.shift(n=periods)) - 1
|
|
39
|
+
return expr.over(over).alias('return') if over is not None else expr.alias('return')
|
|
40
|
+
|
|
41
|
+
def compound(
|
|
42
|
+
self,
|
|
43
|
+
over: str | None = None,
|
|
44
|
+
) -> pl.Expr:
|
|
45
|
+
"""
|
|
46
|
+
Compound an existing series.
|
|
47
|
+
|
|
48
|
+
Parameters
|
|
49
|
+
----------
|
|
50
|
+
over : str | None
|
|
51
|
+
Optional grouping column (e.g. 'ticker'). If None, compound over the full series.
|
|
52
|
+
"""
|
|
53
|
+
expr = (1 + self._expr).cum_prod().sub(1)
|
|
54
|
+
|
|
55
|
+
return expr.over(over).alias(f"compounded") if over is not None else expr.alias(f"compounded")
|
|
56
|
+
|
|
57
|
+
def rolling_vol(
|
|
58
|
+
self,
|
|
59
|
+
window_size: int,
|
|
60
|
+
weights: list[float] | None = None,
|
|
61
|
+
*,
|
|
62
|
+
min_samples: int | None = None,
|
|
63
|
+
center: bool = False,
|
|
64
|
+
ddof: int = 1,
|
|
65
|
+
over: str | None = None
|
|
66
|
+
) -> pl.Expr:
|
|
67
|
+
"""
|
|
68
|
+
Compute rolling volatility (standard deviation) for the expression.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
over : str | None
|
|
73
|
+
Optional grouping column (e.g. 'ticker').
|
|
74
|
+
window_size : int
|
|
75
|
+
Rolling window size.
|
|
76
|
+
weights : list[float] | None
|
|
77
|
+
Optional weights for weighted standard deviation.
|
|
78
|
+
min_samples : int | None
|
|
79
|
+
Minimum number of samples required to compute a value.
|
|
80
|
+
center : bool
|
|
81
|
+
Whether to center the window around the current row.
|
|
82
|
+
ddof : int
|
|
83
|
+
Delta degrees of freedom (default = 1 for sample std).
|
|
84
|
+
"""
|
|
85
|
+
expr = self._expr.rolling_std(
|
|
86
|
+
window_size=window_size,
|
|
87
|
+
weights=weights,
|
|
88
|
+
min_samples=min_samples,
|
|
89
|
+
center=center,
|
|
90
|
+
ddof=ddof,
|
|
91
|
+
)
|
|
92
|
+
return expr.over(over).alias('rolling_vol') if over is not None else expr.alias('rolling_vol')
|
|
93
|
+
|
|
94
|
+
def rolling_beta(
|
|
95
|
+
self,
|
|
96
|
+
benchmark_col: pl.Expr,
|
|
97
|
+
window_size: int,
|
|
98
|
+
min_periods: int | None = None,
|
|
99
|
+
ddof: int = 1,
|
|
100
|
+
over: str | list[str] | None = None
|
|
101
|
+
) -> pl.Expr:
|
|
102
|
+
"""
|
|
103
|
+
Compute rolling beta of this expression against a benchmark expression.
|
|
104
|
+
|
|
105
|
+
Parameters
|
|
106
|
+
----------
|
|
107
|
+
benchmark : pl.Expr
|
|
108
|
+
Benchmark return column.
|
|
109
|
+
window : int
|
|
110
|
+
Rolling window size.
|
|
111
|
+
min_periods : int | None
|
|
112
|
+
Minimum number of observations required to compute a value.
|
|
113
|
+
ddof : int
|
|
114
|
+
Delta degrees of freedom for variance (default 1 for sample variance).
|
|
115
|
+
over : str | list[str] | None
|
|
116
|
+
Optional grouping column(s), e.g., "ticker".
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
pl.Expr
|
|
121
|
+
Rolling beta expression
|
|
122
|
+
"""
|
|
123
|
+
expr = (
|
|
124
|
+
pl.rolling_cov(
|
|
125
|
+
a=self._expr,
|
|
126
|
+
b=benchmark_col,
|
|
127
|
+
window_size=window_size,
|
|
128
|
+
min_periods=min_periods,
|
|
129
|
+
ddof=ddof
|
|
130
|
+
)
|
|
131
|
+
/ benchmark_col.rolling_var(
|
|
132
|
+
window_size=window_size,
|
|
133
|
+
min_periods=min_periods,
|
|
134
|
+
ddof=ddof
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return expr.over(over).alias('rolling_beta') if over is not None else expr.alias('rolling_beta')
|
|
139
|
+
|
|
140
|
+
#-----------------------------------------------------------------------------
|
|
141
|
+
# Private API
|
|
142
|
+
#-----------------------------------------------------------------------------
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
|
|
7
|
+
# Other imports
|
|
8
|
+
import polars as pl
|
|
9
|
+
|
|
10
|
+
#-----------------------------------------------------------------------------
|
|
11
|
+
# Globals and constants
|
|
12
|
+
#-----------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
#-----------------------------------------------------------------------------
|
|
15
|
+
# General API
|
|
16
|
+
#-----------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def ffill_inside(expr: pl.Expr) -> pl.Expr:
|
|
19
|
+
# True from the start up to the *last* non-null; False after that
|
|
20
|
+
mask = expr.is_not_null().reverse().cum_max().reverse()
|
|
21
|
+
|
|
22
|
+
return pl.when(mask).then(expr.forward_fill()).otherwise(None)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
df = pl.DataFrame({
|
|
26
|
+
"a": [None, 1, None, 2, None, None],
|
|
27
|
+
"b": [5, None, None, 7, None, 9],
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
df_out = df.with_columns(
|
|
31
|
+
ffill_inside(pl.col("a")).alias("a"),
|
|
32
|
+
ffill_inside(pl.col("b")).alias("b"),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
print(df_out)
|
|
36
|
+
|
|
37
|
+
#-----------------------------------------------------------------------------
|
|
38
|
+
# Private API
|
|
39
|
+
#-----------------------------------------------------------------------------
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
from typing import Dict
|
|
7
|
+
|
|
8
|
+
# Other imports
|
|
9
|
+
import polars as pl
|
|
10
|
+
|
|
11
|
+
#-----------------------------------------------------------------------------
|
|
12
|
+
# Globals and constants
|
|
13
|
+
#-----------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
#-----------------------------------------------------------------------------
|
|
16
|
+
# General API
|
|
17
|
+
#-----------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
class Portfolio:
|
|
20
|
+
def __init__(self, df: pl.DataFrame, portfolio: Dict, benchmark: str):
|
|
21
|
+
"""
|
|
22
|
+
Portfolio analytics class for computing total, systematic, and idiosyncratic returns.
|
|
23
|
+
|
|
24
|
+
This class takes in a dataset of asset returns (including a benchmark) and a
|
|
25
|
+
dictionary of portfolio weights, then computes the time series of portfolio-level
|
|
26
|
+
returns and their decomposition into systematic and idiosyncratic components.
|
|
27
|
+
|
|
28
|
+
Parameters
|
|
29
|
+
----------
|
|
30
|
+
df : pl.DataFrame
|
|
31
|
+
Polars DataFrame containing asset and benchmark data.
|
|
32
|
+
Must include at least the following columns:
|
|
33
|
+
- 'timestamp' : datetime
|
|
34
|
+
- 'ticker' : str
|
|
35
|
+
- 'return' : float
|
|
36
|
+
- 'beta' : float
|
|
37
|
+
|
|
38
|
+
portfolio : Dict[str, float]
|
|
39
|
+
Dictionary mapping asset tickers to their portfolio weights.
|
|
40
|
+
Example: ``{"AAPL": 0.4, "MSFT": 0.6}``.
|
|
41
|
+
|
|
42
|
+
benchmark : str
|
|
43
|
+
Ticker symbol identifying the benchmark asset within `df`
|
|
44
|
+
"""
|
|
45
|
+
self.df = df
|
|
46
|
+
|
|
47
|
+
self.benchmark_df = df.filter(pl.col('ticker') == benchmark).select(
|
|
48
|
+
['timestamp', pl.col('return').alias('benchmark_return')]
|
|
49
|
+
)
|
|
50
|
+
self.assets_df = df.filter(pl.col('ticker').is_in(list(portfolio.keys())))
|
|
51
|
+
|
|
52
|
+
_weights_df = (
|
|
53
|
+
pl.DataFrame({'ticker': list(portfolio.keys()), 'weight': list(portfolio.values())})
|
|
54
|
+
).lazy()
|
|
55
|
+
self.assets_df = self.assets_df.join(other=_weights_df, on='ticker', how='inner')
|
|
56
|
+
|
|
57
|
+
def compute_returns(self) -> pl.DataFrame:
|
|
58
|
+
"""Compute portfolio total, systematic, and idiosyncratic returns."""
|
|
59
|
+
df = self.assets_df.join(self.benchmark_df, on='timestamp', how='inner')
|
|
60
|
+
|
|
61
|
+
df = df.with_columns([
|
|
62
|
+
(pl.col('systematic_return') * pl.col('weight')).alias('port_systematic_return'),
|
|
63
|
+
(pl.col('idio_return') * pl.col('weight')).alias('port_idio_return'),
|
|
64
|
+
(pl.col('return') * pl.col('weight')).alias('port_total_return')
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
portfolio = (
|
|
68
|
+
df.group_by('timestamp')
|
|
69
|
+
.agg([
|
|
70
|
+
pl.sum('port_systematic_return').alias('systematic_return'),
|
|
71
|
+
pl.sum('port_idio_return').alias('idio_return'),
|
|
72
|
+
pl.sum('port_total_return').alias('return'),
|
|
73
|
+
pl.first('benchmark_return')
|
|
74
|
+
])
|
|
75
|
+
.sort('timestamp')
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
return portfolio
|
|
79
|
+
|
|
80
|
+
#-----------------------------------------------------------------------------
|
|
81
|
+
# Private API
|
|
82
|
+
#-----------------------------------------------------------------------------
|
xpectral/utils/logger.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Callable
|
|
9
|
+
from typing import Dict
|
|
10
|
+
|
|
11
|
+
#-----------------------------------------------------------------------------
|
|
12
|
+
# Globals and constants
|
|
13
|
+
#-----------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
#-----------------------------------------------------------------------------
|
|
16
|
+
# General API
|
|
17
|
+
#-----------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class LogLevel:
|
|
21
|
+
name: str
|
|
22
|
+
color: str # ANSI color code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ColorLogger:
|
|
26
|
+
_LEVELS: Dict[str, LogLevel] = {
|
|
27
|
+
"DEBUG": LogLevel("DEBUG", "\033[94m"),
|
|
28
|
+
"INFO": LogLevel("INFO", "\033[92m"),
|
|
29
|
+
"WARNING": LogLevel("WARNING", "\033[93m"),
|
|
30
|
+
"ERROR": LogLevel("ERROR", "\033[91m"),
|
|
31
|
+
"CRITICAL": LogLevel("CRITICAL", "\033[95m"),
|
|
32
|
+
}
|
|
33
|
+
_RESET = "\033[0m"
|
|
34
|
+
|
|
35
|
+
def __init__(self, name: str):
|
|
36
|
+
self.name = name
|
|
37
|
+
for level in self._LEVELS:
|
|
38
|
+
setattr(self, level.lower(), self._build_logger(level))
|
|
39
|
+
|
|
40
|
+
def _build_logger(self, level_name: str) -> Callable[..., None]:
|
|
41
|
+
def log(message: str, *args) -> None:
|
|
42
|
+
level = self._LEVELS[level_name]
|
|
43
|
+
formatted = message.format(*args)
|
|
44
|
+
output = f"{level.color}[{level.name}] {self.name}: {formatted}{self._RESET}"
|
|
45
|
+
print(output, file=sys.stderr if level_name in {"ERROR", "CRITICAL"} else sys.stdout)
|
|
46
|
+
|
|
47
|
+
return log
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_logger(name: str) -> ColorLogger:
|
|
51
|
+
return ColorLogger(name)
|
|
52
|
+
|
|
53
|
+
#-----------------------------------------------------------------------------
|
|
54
|
+
# Private API
|
|
55
|
+
#-----------------------------------------------------------------------------
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#-----------------------------------------------------------------------------
|
|
2
|
+
# Imports
|
|
3
|
+
#-----------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
# Standard library imports
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Deque
|
|
9
|
+
|
|
10
|
+
# Other imports
|
|
11
|
+
from .logger import get_logger
|
|
12
|
+
|
|
13
|
+
#-----------------------------------------------------------------------------
|
|
14
|
+
# Globals and constants
|
|
15
|
+
#-----------------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
log = get_logger(name="rate_limiter")
|
|
18
|
+
|
|
19
|
+
#-----------------------------------------------------------------------------
|
|
20
|
+
# General API
|
|
21
|
+
#-----------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
class RateLimiter:
|
|
24
|
+
"""Simple token bucket style limiter"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, calls: int, per_seconds: float):
|
|
27
|
+
if calls <= 0 or per_seconds <= 0:
|
|
28
|
+
raise ValueError("calls and per_seconds must be positive")
|
|
29
|
+
self.calls = calls
|
|
30
|
+
self.per_seconds = per_seconds
|
|
31
|
+
self._hits: Deque[float] = deque()
|
|
32
|
+
|
|
33
|
+
def acquire(self) -> None:
|
|
34
|
+
now = time.monotonic()
|
|
35
|
+
window_start = now - self.per_seconds
|
|
36
|
+
|
|
37
|
+
while self._hits and self._hits[0] <= window_start:
|
|
38
|
+
self._hits.popleft()
|
|
39
|
+
|
|
40
|
+
if len(self._hits) >= self.calls:
|
|
41
|
+
sleep_for = self.per_seconds - (now - self._hits[0])
|
|
42
|
+
if sleep_for > 0:
|
|
43
|
+
log.info("sleeping for {:.0f} seconds", sleep_for)
|
|
44
|
+
time.sleep(sleep_for)
|
|
45
|
+
self.acquire()
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
self._hits.append(time.monotonic())
|
|
49
|
+
|
|
50
|
+
#-----------------------------------------------------------------------------
|
|
51
|
+
# Private API
|
|
52
|
+
#-----------------------------------------------------------------------------
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xpectral
|
|
3
|
+
Version: 1.0
|
|
4
|
+
Summary: Quant Research Library
|
|
5
|
+
Author: BayQuant
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 BayQuant
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/bayquant/xpectral
|
|
29
|
+
Keywords: finance,quant,bokeh,polars
|
|
30
|
+
Classifier: Programming Language :: Python :: 3
|
|
31
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Requires-Python: >=3.12
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
License-File: LICENSE
|
|
37
|
+
Requires-Dist: bokeh==3.8.1
|
|
38
|
+
Requires-Dist: massive>=2.0.2
|
|
39
|
+
Requires-Dist: matplotlib>=3.10.7
|
|
40
|
+
Requires-Dist: pandas>=2.3.3
|
|
41
|
+
Requires-Dist: polars>=1.34.0
|
|
42
|
+
Requires-Dist: pyarrow>=21.0.0
|
|
43
|
+
Requires-Dist: python-dotenv>=1.2.1
|
|
44
|
+
Dynamic: license-file
|
|
45
|
+
|
|
46
|
+
# Xpectral
|
|
47
|
+
|
|
48
|
+

|
|
49
|
+
|
|
50
|
+
A quantitative research library that extends **Polars** DataFrames with charting and financial analytics.
|
|
51
|
+
|
|
52
|
+
## Modules
|
|
53
|
+
|
|
54
|
+
- **`xpectral.charts`** — Fluent Bokeh visualization via `df.bokeh.line(...)`, `df.bokeh.scatter(...)`, etc.
|
|
55
|
+
- **`xpectral.quant`** — Financial metrics (returns, volatility, beta) via `pl.col(...).quant.returns()`
|
|
56
|
+
- **`xpectral.data`** — Market data from the Polygon/Massive API with caching and rate limiting
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
import polars as pl
|
|
62
|
+
import xpectral # registers .bokeh and .quant accessors
|
|
63
|
+
|
|
64
|
+
df = pl.DataFrame({"x": [1, 2, 3], "y": [4, 5, 6]})
|
|
65
|
+
fig = df.bokeh.line(x="x", y="y")
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Install
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
uv sync
|
|
72
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
xpectral/__init__.py,sha256=vV8B9L7dvlEJellIhMaSq-pVcetYQBBJRgjKSUQW8Gs,585
|
|
2
|
+
xpectral/charts/__init__.py,sha256=uzlyQqomt8yLB3zZ_W_jrnEL8N1SKVMLwi8F7hr4je4,512
|
|
3
|
+
xpectral/charts/_decorators.py,sha256=cBbJ4JpvZFDewOGDNOL0acco6Mj71ZJE9Wu59RUH4cs,3524
|
|
4
|
+
xpectral/charts/_figure.py,sha256=G5NaWUG5L9JKujzNn2LZfmO3irjtl-qWBCrooTM3wFk,2627
|
|
5
|
+
xpectral/charts/polars_accessors.py,sha256=q9MIZxGmgLjiEe0y_Nnuwx7X0G_iW4jAfh69Wj3TMUM,8546
|
|
6
|
+
xpectral/charts/theme_manager.py,sha256=6z5LoBR13xFjyUbZRVYZntH5weQK6m6JrNoiLIxV7O0,5001
|
|
7
|
+
xpectral/data/__init__.py,sha256=CJ3B4DWPPTyzy4alg0DaRe39Hszz39VPrSR_U40vXgw,447
|
|
8
|
+
xpectral/data/massive.py,sha256=BqvTJyMldJCTNDkiDzFC_PqPd-odDaduAHn-vZkliqI,3011
|
|
9
|
+
xpectral/quant/__init__.py,sha256=E_33oFPPtrcpP0oW2tAPX_CSlQB0tktALB7E-uRKU4g,526
|
|
10
|
+
xpectral/quant/polars_accessors.py,sha256=Ra0OUnZWCvBqsIvS3vj-jHdWZ8cpS5WzCC4kp74oxdA,4524
|
|
11
|
+
xpectral/quant/polars_expressions.py,sha256=LAEwMnr5e7V9QhorcQ1g1iHOLULYOYi2bi17NbKaISU,1237
|
|
12
|
+
xpectral/quant/portfolio.py,sha256=NNH1yb2l-aky1q3_NMg3A7cUtWo3OB4cW3AbcIEz9Hg,3235
|
|
13
|
+
xpectral/utils/logger.py,sha256=kQsgzxPuzxXGtwgbxB4kejWx8q2fEOY548rE8pIEkRI,1898
|
|
14
|
+
xpectral/utils/rate_limiter.py,sha256=7lPj92FXAnJyj3eLu7HW70vFb6nsKah6P0OVVHa2I8I,1760
|
|
15
|
+
xpectral-1.0.dist-info/licenses/LICENSE,sha256=oKFaOASRExmxKyg4h4tzD8yffNm1zGNFwubGUOVwq_E,1065
|
|
16
|
+
xpectral-1.0.dist-info/METADATA,sha256=37RqcqCY7_4fbsVsb2sAr7nGkZKXgg_gBiNV-lU_J8A,2687
|
|
17
|
+
xpectral-1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
18
|
+
xpectral-1.0.dist-info/top_level.txt,sha256=b7Ydg4eYKLz1u82k7zKTjdqL4TvZs490bZtPJFgvEaw,9
|
|
19
|
+
xpectral-1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 BayQuant
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
xpectral
|