sovai 0.2.78__tar.gz → 0.2.79__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {sovai-0.2.78 → sovai-0.2.79}/PKG-INFO +1 -1
  2. {sovai-0.2.78 → sovai-0.2.79}/pyproject.toml +1 -1
  3. sovai-0.2.79/sovai/compat.py +72 -0
  4. {sovai-0.2.78 → sovai-0.2.79}/sovai/get_data.py +17 -1
  5. sovai-0.2.78/sovai/.DS_Store +0 -0
  6. sovai-0.2.78/sovai/extensions/change_point_generator copy.py +0 -446
  7. sovai-0.2.78/sovai/extensions/signal_evaluation copy.py +0 -2720
  8. sovai-0.2.78/sovai/get_data copy 2.py +0 -1688
  9. sovai-0.2.78/sovai/get_data copy.py +0 -1732
  10. sovai-0.2.78/sovai/utils/file_management copy.py +0 -79
  11. sovai-0.2.78/sovai/utils/pandas_extensions copy.py +0 -222
  12. sovai-0.2.78/sovai/utils/port_manager copy.py +0 -25
  13. {sovai-0.2.78 → sovai-0.2.79}/LICENSE +0 -0
  14. {sovai-0.2.78 → sovai-0.2.79}/README.md +0 -0
  15. {sovai-0.2.78 → sovai-0.2.79}/sovai/__init__.py +0 -0
  16. {sovai-0.2.78 → sovai-0.2.79}/sovai/api_config.py +0 -0
  17. {sovai-0.2.78 → sovai-0.2.79}/sovai/assets/dropdown.css +0 -0
  18. {sovai-0.2.78 → sovai-0.2.79}/sovai/basic_auth.py +0 -0
  19. {sovai-0.2.78 → sovai-0.2.79}/sovai/computations/__init__.py +0 -0
  20. {sovai-0.2.78 → sovai-0.2.79}/sovai/computations/functions.py +0 -0
  21. {sovai-0.2.78 → sovai-0.2.79}/sovai/errors/__init__.py +0 -0
  22. {sovai-0.2.78 → sovai-0.2.79}/sovai/errors/sovai_errors.py +0 -0
  23. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/__init__.py +0 -0
  24. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/anomalies.py +0 -0
  25. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/ask_df_llm.py +0 -0
  26. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/change_point_generator.py +0 -0
  27. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/chart_explainer.py +0 -0
  28. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/clustering.py +0 -0
  29. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/core_kshape.py +0 -0
  30. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/cum_sum_torch.py +0 -0
  31. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/cusum.py +0 -0
  32. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/dimensionality_reduction.py +0 -0
  33. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/feature_extraction.py +0 -0
  34. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/feature_importance.py +0 -0
  35. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/feature_neutralizer.py +0 -0
  36. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/filter_df.py +0 -0
  37. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/fractional_differencing.py +0 -0
  38. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/nowcasting.py +0 -0
  39. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/overall_explainers.py +0 -0
  40. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/pairwise.py +0 -0
  41. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/pandas_extensions.py +0 -0
  42. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/parallel_functions.py +0 -0
  43. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/pfa_feature_selector.py +0 -0
  44. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/regime_change.py +0 -0
  45. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/regime_change_pca.py +0 -0
  46. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/shapley_global_importance.py +0 -0
  47. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/shapley_importance.py +0 -0
  48. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/signal_evaluation.py +0 -0
  49. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/signal_evaluation_old.py +0 -0
  50. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/table_explainer.py +0 -0
  51. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/technical_indicators.py +0 -0
  52. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/time_decomposition.py +0 -0
  53. {sovai-0.2.78 → sovai-0.2.79}/sovai/extensions/weight_optimization.py +0 -0
  54. {sovai-0.2.78 → sovai-0.2.79}/sovai/get_compute.py +0 -0
  55. {sovai-0.2.78 → sovai-0.2.79}/sovai/get_plots.py +0 -0
  56. {sovai-0.2.78 → sovai-0.2.79}/sovai/get_reports.py +0 -0
  57. {sovai-0.2.78 → sovai-0.2.79}/sovai/get_tools.py +0 -0
  58. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/__init__.py +0 -0
  59. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/accounting/__init__.py +0 -0
  60. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/accounting/accounting_plots.py +0 -0
  61. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/allocation/__init__.py +0 -0
  62. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/allocation/allocation_plots.py +0 -0
  63. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/bankruptcy/__init__.py +0 -0
  64. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/bankruptcy/bankruptcy_plots.py +0 -0
  65. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/breakout/__init__.py +0 -0
  66. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/breakout/breakout_plots.py +0 -0
  67. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/corp_risk/__init__.py +0 -0
  68. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/corp_risk/corp_risk_plots.py +0 -0
  69. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/earnings_surprise/__init__.py +0 -0
  70. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/earnings_surprise/earnings_surprise_plots.py +0 -0
  71. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/insider/__init__.py +0 -0
  72. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/insider/insider_plots.py +0 -0
  73. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/institutional/__init__.py +0 -0
  74. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/institutional/institutional_plots.py +0 -0
  75. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/news/__init__.py +0 -0
  76. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/news/news_plots.py +0 -0
  77. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/ratios/__init__.py +0 -0
  78. {sovai-0.2.78 → sovai-0.2.79}/sovai/plots/ratios/ratios_plots.py +0 -0
  79. {sovai-0.2.78 → sovai-0.2.79}/sovai/reports/accounting/__init__.py +0 -0
  80. {sovai-0.2.78 → sovai-0.2.79}/sovai/reports/accounting/accounting_balance_sheet.py +0 -0
  81. {sovai-0.2.78 → sovai-0.2.79}/sovai/reports/bankruptcy/__init__.py +0 -0
  82. {sovai-0.2.78 → sovai-0.2.79}/sovai/reports/bankruptcy/bankruptcy_monthly_top.py +0 -0
  83. {sovai-0.2.78 → sovai-0.2.79}/sovai/reports/general/__init__.py +0 -0
  84. {sovai-0.2.78 → sovai-0.2.79}/sovai/reports/general/general_plots.py +0 -0
  85. {sovai-0.2.78 → sovai-0.2.79}/sovai/reports/news/__inti__.py +0 -0
  86. {sovai-0.2.78 → sovai-0.2.79}/sovai/reports/news/news_econometric_analysis.py +0 -0
  87. {sovai-0.2.78 → sovai-0.2.79}/sovai/studies/nowcasting.py +0 -0
  88. {sovai-0.2.78 → sovai-0.2.79}/sovai/token_auth.py +0 -0
  89. {sovai-0.2.78 → sovai-0.2.79}/sovai/tools/authentication/__init__.py +0 -0
  90. {sovai-0.2.78 → sovai-0.2.79}/sovai/tools/authentication/authentication.py +0 -0
  91. {sovai-0.2.78 → sovai-0.2.79}/sovai/tools/explainers.py +0 -0
  92. {sovai-0.2.78 → sovai-0.2.79}/sovai/tools/sec/__init__.py +0 -0
  93. {sovai-0.2.78 → sovai-0.2.79}/sovai/tools/sec/graphs.py +0 -0
  94. {sovai-0.2.78 → sovai-0.2.79}/sovai/tools/sec/llm_code_generator.py +0 -0
  95. {sovai-0.2.78 → sovai-0.2.79}/sovai/tools/sec/sec_10_k_8_k_filings.py +0 -0
  96. {sovai-0.2.78 → sovai-0.2.79}/sovai/tools/sec/sec_edgar_search.py +0 -0
  97. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/__init__.py +0 -0
  98. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/client_side.py +0 -0
  99. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/client_side_s3.py +0 -0
  100. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/client_side_s3_part_high.py +0 -0
  101. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/converter.py +0 -0
  102. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/datetime_formats.py +0 -0
  103. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/file_management.py +0 -0
  104. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/get_tickers.py +0 -0
  105. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/helpers.py +0 -0
  106. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/plot.py +0 -0
  107. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/port_manager.py +0 -0
  108. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/stream.py +0 -0
  109. {sovai-0.2.78 → sovai-0.2.79}/sovai/utils/verbose_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sovai
3
- Version: 0.2.78
3
+ Version: 0.2.79
4
4
  Summary: python for asset management
5
5
  License: Proprietary License
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "sovai"
7
- version = "0.2.78"
7
+ version = "0.2.79"
8
8
  description = "python for asset management"
9
9
  authors = [
10
10
  {name = "Derek Snow", email = "d.snow@outlook.com"}
@@ -0,0 +1,72 @@
1
+ """
2
+ SDK-side compatibility checker.
3
+
4
+ On first API call, reads server response headers to detect version deprecation
5
+ warnings. Never crashes -- degraded functionality is better than no functionality.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ import warnings
11
+ from typing import Optional
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ # Module-level flag so we warn at most once per process
16
+ _COMPAT_CHECKED: bool = False
17
+ _DEPRECATION_WARNING_SHOWN: bool = False
18
+
19
+
20
+ def get_sdk_version() -> str:
21
+ """Return the installed SDK version, falling back to '0.0.0'."""
22
+ try:
23
+ from importlib.metadata import version as _pkg_version
24
+ return _pkg_version("sovai")
25
+ except Exception:
26
+ return "0.0.0"
27
+
28
+
29
+ def check_response_compat(response_headers: dict[str, str]) -> None:
30
+ """
31
+ Inspect server response headers for SDK compatibility signals.
32
+
33
+ Called after every API response. Emits at most one user-facing warning
34
+ per process lifetime to avoid log spam.
35
+
36
+ Headers checked:
37
+ X-SDK-Deprecation-Warning -- soft warning, SDK is below recommended
38
+ X-SDK-Upgrade-Required -- hard signal, SDK is critically outdated
39
+ """
40
+ global _COMPAT_CHECKED, _DEPRECATION_WARNING_SHOWN
41
+
42
+ deprecation_msg: Optional[str] = response_headers.get("X-SDK-Deprecation-Warning")
43
+ upgrade_msg: Optional[str] = response_headers.get("X-SDK-Upgrade-Required")
44
+
45
+ if upgrade_msg and not _DEPRECATION_WARNING_SHOWN:
46
+ _DEPRECATION_WARNING_SHOWN = True
47
+ warnings.warn(
48
+ f"[sovai] SDK upgrade required: {upgrade_msg}. "
49
+ f"Run: pip install --upgrade sovai",
50
+ UserWarning,
51
+ stacklevel=3,
52
+ )
53
+ logger.warning("sdk.upgrade_required", extra={"detail": upgrade_msg})
54
+
55
+ elif deprecation_msg and not _DEPRECATION_WARNING_SHOWN:
56
+ _DEPRECATION_WARNING_SHOWN = True
57
+ warnings.warn(
58
+ f"[sovai] {deprecation_msg}. "
59
+ f"Run: pip install --upgrade sovai",
60
+ DeprecationWarning,
61
+ stacklevel=3,
62
+ )
63
+ logger.info("sdk.deprecation_warning", extra={"detail": deprecation_msg})
64
+
65
+ _COMPAT_CHECKED = True
66
+
67
+
68
+ def reset_compat_state() -> None:
69
+ """Reset module state -- useful for testing."""
70
+ global _COMPAT_CHECKED, _DEPRECATION_WARNING_SHOWN
71
+ _COMPAT_CHECKED = False
72
+ _DEPRECATION_WARNING_SHOWN = False
@@ -223,8 +223,16 @@ class ApiRequestHandler:
223
223
  self.max_retries = max_retries
224
224
  self.backoff_factor = backoff_factor
225
225
  self.timeout = timeout
226
- self.headers = {"Authorization": f"Bearer {token}"}
226
+ self.headers: Dict[str, str] = {"Authorization": f"Bearer {token}"}
227
227
  self.logger = logger or logging.getLogger(__name__)
228
+
229
+ # Inject SDK version header for server-side compatibility checks
230
+ try:
231
+ from importlib.metadata import version as _pkg_version
232
+ sdk_version = _pkg_version("sovai")
233
+ except Exception:
234
+ sdk_version = "0.0.0"
235
+ self.headers["X-SDK-Version"] = sdk_version
228
236
 
229
237
  def _should_retry(self, exception: Exception, attempt: int) -> bool:
230
238
  """
@@ -306,6 +314,14 @@ class ApiRequestHandler:
306
314
  )
307
315
  response.raise_for_status()
308
316
  self.logger.debug(f"Request successful (status: {response.status_code})")
317
+
318
+ # Check server compatibility headers (warns at most once per process)
319
+ try:
320
+ from sovai.compat import check_response_compat
321
+ check_response_compat(dict(response.headers))
322
+ except Exception:
323
+ pass # Never let compat checking break data retrieval
324
+
309
325
  return response
310
326
 
311
327
  except Exception as e:
Binary file
@@ -1,446 +0,0 @@
1
- import numpy as np
2
- import pandas as pd
3
- from typing import Tuple
4
- import plotly.graph_objects as go
5
- from plotly.subplots import make_subplots
6
- import dash
7
- from dash import dcc, html
8
- from dash.dependencies import Input, Output
9
- import random
10
-
11
- random.seed(42)
12
- np.random.seed(42)
13
-
14
- # Your code that uses NumPy's random functions
15
- # Global dictionary to keep track of apps and their ports
16
- app_ports = {}
17
-
18
-
19
- def get_unique_port(app_name):
20
- global app_ports
21
- min_port = 8050 # define the range of ports you want to use
22
- max_port = 8099
23
-
24
- if app_name in app_ports:
25
- # Return the previously assigned port for this app
26
- return app_ports[app_name]
27
- else:
28
- # Generate a unique port for the new app
29
- while True:
30
- port = random.randint(min_port, max_port)
31
- if (
32
- port not in app_ports.values()
33
- ): # check if the port is not already in use
34
- app_ports[app_name] = port
35
- return port
36
-
37
-
38
- class ImprovedCusumDetector:
39
- def __init__(
40
- self,
41
- window_size_ratio: float = 0.05,
42
- threshold_factor: float = 2.5,
43
- drift_factor: float = 0.05,
44
- min_distance: int = 30,
45
- sideways_threshold: float = 0.03,
46
- warm_up_period_ratio: float = 0.1,
47
- ):
48
- self.window_size_ratio = window_size_ratio
49
- self.threshold_factor = threshold_factor
50
- self.drift_factor = drift_factor
51
- self.min_distance = min_distance
52
- self.sideways_threshold = sideways_threshold
53
- self.warm_up_period_ratio = warm_up_period_ratio
54
-
55
- def detect_changepoints(
56
- self, signal: np.ndarray
57
- ) -> Tuple[np.ndarray, np.ndarray, np.ndarray, int]:
58
- n = len(signal)
59
- window_size = max(int(n * self.window_size_ratio), 8)
60
- warm_up_period = int(n * self.warm_up_period_ratio)
61
-
62
- ewma = self._calculate_ewma(signal, window_size)
63
- z_scores = self._calculate_rolling_z_scores(signal, ewma, window_size)
64
- scores, changepoints, trend_codes = self._calculate_two_sided_cusum(
65
- signal,
66
- z_scores,
67
- self.drift_factor,
68
- self.threshold_factor,
69
- self.min_distance,
70
- self.sideways_threshold,
71
- warm_up_period,
72
- )
73
-
74
- trends = np.where(
75
- trend_codes == 1,
76
- "Increasing",
77
- np.where(trend_codes == -1, "Decreasing", "Sideways"),
78
- )
79
-
80
- return changepoints, trends, scores, window_size
81
-
82
- import numba
83
- import numpy as np
84
-
85
- @staticmethod
86
- @numba.njit(cache=True)
87
- def _calculate_ewma(signal, window_size):
88
- alpha = 2 / (window_size + 1)
89
- ewma = np.zeros_like(signal)
90
- ewma[0] = signal[0]
91
- for i in range(1, len(signal)):
92
- ewma[i] = alpha * signal[i] + (1 - alpha) * ewma[i - 1]
93
- return ewma
94
-
95
- @staticmethod
96
- @numba.njit(cache=True)
97
- def _calculate_rolling_z_scores(signal, ewma, window_size):
98
- rolling_var = np.zeros_like(signal)
99
- for i in range(window_size, len(signal)):
100
- rolling_var[i] = np.var(signal[i - window_size + 1 : i + 1])
101
- rolling_std = np.sqrt(rolling_var)
102
- z_scores = (signal - ewma) / (rolling_std + 1e-8)
103
- return z_scores
104
-
105
- @staticmethod
106
- @numba.njit(cache=True)
107
- def _calculate_two_sided_cusum(
108
- signal,
109
- z,
110
- drift,
111
- threshold_factor,
112
- min_distance,
113
- sideways_threshold,
114
- warm_up_period,
115
- ):
116
- cs_upper = np.zeros_like(z)
117
- cs_lower = np.zeros_like(z)
118
- changepoints = []
119
- trend_codes = []
120
- last_cp = -min_distance
121
- for i in range(1, len(z)):
122
- if i < warm_up_period:
123
- continue
124
- cs_upper[i] = max(0, cs_upper[i - 1] + z[i] - drift)
125
- cs_lower[i] = min(0, cs_lower[i - 1] + z[i] + drift)
126
- if i - last_cp >= min_distance:
127
- if cs_upper[i] > threshold_factor * np.sqrt(i):
128
- changepoints.append(i)
129
- trend_codes.append(1)
130
- cs_upper[i] = 0
131
- cs_lower[i] = 0
132
- last_cp = i
133
- elif cs_lower[i] < -threshold_factor * np.sqrt(i):
134
- changepoints.append(i)
135
- trend_codes.append(-1)
136
- cs_upper[i] = 0
137
- cs_lower[i] = 0
138
- last_cp = i
139
- elif (
140
- i > 0
141
- and abs((signal[i] - signal[last_cp]) / signal[last_cp])
142
- < sideways_threshold
143
- ):
144
- changepoints.append(i)
145
- trend_codes.append(0)
146
- cs_upper[i] = 0
147
- cs_lower[i] = 0
148
- last_cp = i
149
- return (
150
- np.maximum(cs_upper, -cs_lower),
151
- np.array(changepoints),
152
- np.array(trend_codes),
153
- )
154
-
155
- def plot_cusum_results(
156
- df_signal, signal_array, changepoints, trends, scores, ticker, feature
157
- ):
158
- fig = make_subplots(
159
- rows=2,
160
- cols=1,
161
- shared_xaxes=True,
162
- vertical_spacing=0.1,
163
- subplot_titles=(f"{feature} Over Time for {ticker}", "CUSUM Score"),
164
- )
165
-
166
- fig.add_trace(
167
- go.Scatter(
168
- x=df_signal.index,
169
- y=signal_array,
170
- mode="lines",
171
- line=dict(color="gold", width=2),
172
- name=feature,
173
- ),
174
- row=1,
175
- col=1,
176
- )
177
-
178
- fig.add_trace(
179
- go.Scatter(
180
- x=df_signal.index,
181
- y=scores,
182
- mode="lines",
183
- line=dict(color="rgba(30, 144, 255, 0.7)", width=1.5),
184
- name="CUSUM Score",
185
- ),
186
- row=2,
187
- col=1,
188
- )
189
-
190
- colors = {"Increasing": "lime", "Decreasing": "red", "Sideways": "yellow"}
191
- symbols = {
192
- "Increasing": "triangle-up",
193
- "Decreasing": "triangle-down",
194
- "Sideways": "circle",
195
- }
196
-
197
- for trend in set(trends):
198
- mask = trends == trend
199
- fig.add_trace(
200
- go.Scatter(
201
- x=df_signal.index[changepoints[mask]],
202
- y=signal_array[changepoints[mask]],
203
- mode="markers",
204
- marker=dict(color=colors[trend], size=10, symbol=symbols[trend]),
205
- name=f"{trend}",
206
- ),
207
- row=1,
208
- col=1,
209
- )
210
-
211
- fig.update_layout(
212
- title=f"{feature} Change Point Detection for {ticker}",
213
- plot_bgcolor="rgba(0,0,0,0.95)",
214
- paper_bgcolor="rgba(0,0,0,0.95)",
215
- font=dict(color="white", size=12),
216
- autosize=True,
217
- legend=dict(
218
- orientation="h",
219
- yanchor="bottom",
220
- y=1.1,
221
- xanchor="right",
222
- x=1,
223
- bgcolor="rgba(0,0,0,0.5)",
224
- bordercolor="rgba(255,255,255,0.5)",
225
- ),
226
- showlegend=True,
227
- )
228
-
229
- fig.update_xaxes(title_text="Date", row=2, col=1, gridcolor="rgba(255,255,255,0.2)")
230
- fig.update_yaxes(
231
- title_text=feature, type="log", row=1, col=1, gridcolor="rgba(255,255,255,0.2)"
232
- )
233
- fig.update_yaxes(
234
- title_text="CUSUM Score", row=2, col=1, gridcolor="rgba(255,255,255,0.2)"
235
- )
236
-
237
- for i, (cp, trend) in enumerate(zip(changepoints, trends)):
238
- fig.add_annotation(
239
- x=df_signal.index[cp],
240
- y=signal_array[cp],
241
- text=f"{trend[0]} {i+1}",
242
- showarrow=False,
243
- yshift=10 if trend == "Increasing" else -10,
244
- font=dict(color=colors[trend]),
245
- row=1,
246
- col=1,
247
- )
248
-
249
- return fig
250
-
251
-
252
- def run_cusum_dashboard(df_accounting, ticker=None, feature=None):
253
- if ticker is None:
254
- ticker = df_accounting.index.get_level_values("ticker").unique()[0]
255
- if feature is None:
256
- feature = df_accounting.columns[0]
257
-
258
- app = dash.Dash(__name__)
259
-
260
- dark_mode_colors = {
261
- "background": "#1E1E1E",
262
- "text": "#FFFFFF",
263
- "plot_bg": "#2B2B2B",
264
- "plot_gridlines": "#3A3A3A",
265
- }
266
-
267
- app.layout = html.Div(
268
- [
269
- html.H3(
270
- "Financial Metric Change Point Detection",
271
- style={
272
- "textAlign": "center",
273
- "marginBottom": "20px",
274
- "paddingTop": "10px",
275
- "color": dark_mode_colors["text"],
276
- },
277
- ),
278
- html.Div(
279
- [
280
- html.Div(
281
- [
282
- html.Label(
283
- "Select Ticker:",
284
- style={"color": dark_mode_colors["text"]},
285
- ),
286
- dcc.Dropdown(
287
- id="ticker-dropdown",
288
- options=[
289
- {"label": t, "value": t}
290
- for t in df_accounting.index.get_level_values(
291
- "ticker"
292
- ).unique()
293
- ],
294
- value=ticker,
295
- ),
296
- ],
297
- style={"width": "48%", "display": "inline-block"},
298
- ),
299
- html.Div(
300
- [
301
- html.Label(
302
- "Select Feature:",
303
- style={"color": dark_mode_colors["text"]},
304
- ),
305
- dcc.Dropdown(
306
- id="feature-dropdown",
307
- options=[
308
- {"label": col, "value": col}
309
- for col in df_accounting.columns
310
- ],
311
- value=feature,
312
- ),
313
- ],
314
- style={
315
- "width": "48%",
316
- "float": "right",
317
- "display": "inline-block",
318
- },
319
- ),
320
- ],
321
- style={"padding": "20px 0px"},
322
- ),
323
- dcc.Graph(id="cusum-plot", style={"height": "80vh"}),
324
- ],
325
- style={
326
- "padding": "0 20px",
327
- "margin": "0 auto",
328
- "backgroundColor": dark_mode_colors["background"],
329
- },
330
- )
331
-
332
- @app.callback(
333
- Output("cusum-plot", "figure"),
334
- [Input("ticker-dropdown", "value"), Input("feature-dropdown", "value")],
335
- )
336
- def update_graph(selected_ticker, selected_feature):
337
- df_signal = (
338
- df_accounting.query(f"ticker == '{selected_ticker}'")
339
- .reset_index()
340
- .set_index("date")[selected_feature]
341
- )
342
- signal_array = df_signal.values
343
- detector = ImprovedCusumDetector()
344
- changepoints, trends, scores, window_size = detector.detect_changepoints(
345
- signal_array
346
- )
347
- fig = plot_cusum_results(
348
- df_signal,
349
- signal_array,
350
- changepoints,
351
- trends,
352
- scores,
353
- selected_ticker,
354
- selected_feature,
355
- )
356
- fig.update_layout(
357
- plot_bgcolor=dark_mode_colors["plot_bg"],
358
- paper_bgcolor=dark_mode_colors["plot_bg"],
359
- font_color=dark_mode_colors["text"],
360
- )
361
- fig.update_xaxes(gridcolor=dark_mode_colors["plot_gridlines"])
362
- fig.update_yaxes(gridcolor=dark_mode_colors["plot_gridlines"])
363
- return fig
364
-
365
- app_name = "cumsum-app"
366
- return app.run(debug=False, port=get_unique_port(app_name))
367
-
368
-
369
- def perform_cusum_analysis(df_accounting, ticker=None, feature=None):
370
- # Get the first ticker and feature if not provided
371
- if ticker is None:
372
- ticker = df_accounting.index.get_level_values("ticker").unique()[0]
373
- if feature is None:
374
- feature = df_accounting.columns[0]
375
-
376
- # Extract the signal for the specified ticker and feature
377
- df_signal = (
378
- df_accounting.query(f"ticker == '{ticker}'")
379
- .reset_index()
380
- .set_index("date")[feature]
381
- )
382
-
383
- detector = ImprovedCusumDetector()
384
- signal_array = df_signal.values
385
- changepoints, trends, scores, window_size = detector.detect_changepoints(
386
- signal_array
387
- )
388
-
389
- # Create main analysis DataFrame
390
- analysis_df = pd.DataFrame(
391
- {
392
- "Date": df_signal.index[changepoints],
393
- "Trend": trends,
394
- "Signal_Value": signal_array[changepoints],
395
- "CUSUM_Score": scores[
396
- changepoints - 1
397
- ], # Take the score from just before the change point
398
- }
399
- )
400
-
401
- # Calculate duration between change points
402
- analysis_df["Duration"] = (
403
- analysis_df["Date"].diff().shift(-1).fillna(pd.Timedelta(days=0))
404
- )
405
- analysis_df["Duration_Days"] = analysis_df["Duration"].dt.days
406
-
407
- # Calculate percentage change between change points
408
- analysis_df["Percent_Change"] = (
409
- analysis_df["Signal_Value"].pct_change().shift(-1) * 100
410
- ).fillna(0)
411
-
412
- # Overall statistics
413
- overall_stats = pd.DataFrame(
414
- {
415
- "Statistic": [
416
- "Ticker",
417
- "Feature",
418
- "Total_Change_Points",
419
- "Average_Duration_Days",
420
- "Average_Percent_Change",
421
- "Increasing_Trends",
422
- "Decreasing_Trends",
423
- "Sideways_Trends",
424
- "Max_CUSUM_Score",
425
- "Min_CUSUM_Score",
426
- "Window_Size",
427
- ],
428
- "Value": [
429
- ticker,
430
- feature,
431
- len(changepoints),
432
- analysis_df["Duration_Days"].mean(),
433
- analysis_df["Percent_Change"].mean(),
434
- sum(trends == "Increasing"),
435
- sum(trends == "Decreasing"),
436
- sum(trends == "Sideways"),
437
- scores.max(),
438
- scores[scores > 0].min() if any(scores > 0) else 0,
439
- window_size,
440
- ],
441
- }
442
- )
443
-
444
- analysis_df.attrs["stats"] = overall_stats
445
-
446
- return analysis_df