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 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
+ ]
@@ -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
+ #-----------------------------------------------------------------------------
@@ -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
+ ![Spectral decomposition](assets/xpectral_banner.gif)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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