jasonlib-dev 0.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.
@@ -0,0 +1,634 @@
1
+ """Trend analysis engine: scoring, ranking, and chart construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Callable, Dict, List, Optional
7
+
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+ from jasonlib._math import sigmoid_diff, sigmoid_vol, sigmoid_z
12
+ from jasonlib._models import JAssetClass, JInterval
13
+ from jasonlib._trading_calendar import get_annualization_factor
14
+ from jasonlib._trend_models import (
15
+ ChartData,
16
+ CoinDataset,
17
+ CoinSeries,
18
+ InsufficientDataError,
19
+ MarketTrendSummary,
20
+ PlotWindow,
21
+ SummaryMetric,
22
+ TREND_COLUMN_LABELS,
23
+ TrendAnalysisResult,
24
+ TrendCharts,
25
+ TrendFunction,
26
+ )
27
+
28
+ logger = logging.getLogger("jasonlib.trend_analysis")
29
+
30
+
31
+ # ============================================================================
32
+ # HALF-LIFE WEIGHTS
33
+ # ============================================================================
34
+
35
+ _HALF_LIFE_WEIGHTS: Dict[pd.Timedelta, float] = {
36
+ pd.Timedelta("1D"): 3.0,
37
+ pd.Timedelta("3D"): 2.0,
38
+ pd.Timedelta("7D"): 1.0,
39
+ }
40
+
41
+
42
+ # ============================================================================
43
+ # SCORE HELPER
44
+ # ============================================================================
45
+
46
+ def _calculate_weighted_average_score(
47
+ data_series: pd.Series,
48
+ half_life_weights: Dict[pd.Timedelta, float],
49
+ ) -> float:
50
+ weighted_scores = 0.0
51
+ total_weight = 0.0
52
+ for hl, weight in half_life_weights.items():
53
+ score = data_series.ewm(halflife=hl, times=data_series.index).mean().iloc[-1]
54
+ weighted_scores += score * weight
55
+ total_weight += weight
56
+ if total_weight == 0:
57
+ return 0.0
58
+ return weighted_scores / total_weight
59
+
60
+
61
+ # ============================================================================
62
+ # TREND SCORE FUNCTIONS
63
+ # ============================================================================
64
+
65
+ def _log_returns(
66
+ ohlc: pd.DataFrame,
67
+ jason: pd.DataFrame,
68
+ half_life_weights: Dict[pd.Timedelta, float],
69
+ **kwargs,
70
+ ) -> float:
71
+ log_returns = np.log(ohlc["close"]).diff()
72
+ weighted_scores = 0.0
73
+ total_weight = 0.0
74
+ for hl, weight in half_life_weights.items():
75
+ ewm_mean_return = log_returns.ewm(halflife=hl, times=log_returns.index).mean().iloc[-1]
76
+ score = np.clip(ewm_mean_return * 100, -1, 1)
77
+ weighted_scores += score * weight
78
+ total_weight += weight
79
+ if total_weight == 0:
80
+ return 0.0
81
+ return weighted_scores / total_weight
82
+
83
+
84
+ def _z_score_above_threshold(
85
+ ohlc: pd.DataFrame,
86
+ jason: pd.DataFrame,
87
+ half_life_weights: Dict[pd.Timedelta, float],
88
+ threshold: float = 2.0,
89
+ **kwargs,
90
+ ) -> float:
91
+ z_signal = sigmoid_z(jason["low_z"], threshold) - sigmoid_z(jason["high_z"], threshold)
92
+ return _calculate_weighted_average_score(z_signal, half_life_weights)
93
+
94
+
95
+ def _z_score_low_vol(
96
+ ohlc: pd.DataFrame,
97
+ jason: pd.DataFrame,
98
+ half_life_weights: Dict[pd.Timedelta, float],
99
+ interval: JInterval = JInterval.H4,
100
+ threshold: float = 2.0,
101
+ **kwargs,
102
+ ) -> float:
103
+ annualization_factor = get_annualization_factor(interval, JAssetClass.CRYPTO)
104
+ historical_vol = 100 * np.log(ohlc["close"]).diff().std() * np.sqrt(annualization_factor)
105
+ vol_weight_low = sigmoid_vol(jason["low_vol"], hist_vol=historical_vol)
106
+ vol_weight_high = sigmoid_vol(jason["high_vol"], hist_vol=historical_vol)
107
+ z_signal = (
108
+ vol_weight_low * sigmoid_z(jason["low_z"], threshold)
109
+ - vol_weight_high * sigmoid_z(jason["high_z"], threshold)
110
+ )
111
+ return _calculate_weighted_average_score(z_signal, half_life_weights)
112
+
113
+
114
+ def _avg_z_diff(
115
+ ohlc: pd.DataFrame,
116
+ jason: pd.DataFrame,
117
+ half_life_weights: Dict[pd.Timedelta, float],
118
+ **kwargs,
119
+ ) -> float:
120
+ z_diff = jason["low_z"] - jason["high_z"]
121
+ return _calculate_weighted_average_score(z_diff, half_life_weights)
122
+
123
+
124
+ def _avg_z_diff_above_threshold(
125
+ ohlc: pd.DataFrame,
126
+ jason: pd.DataFrame,
127
+ half_life_weights: Dict[pd.Timedelta, float],
128
+ threshold: float = 1.0,
129
+ **kwargs,
130
+ ) -> float:
131
+ z_diff = jason["low_z"] - jason["high_z"]
132
+ z_signal = sigmoid_diff(z_diff, threshold=threshold)
133
+ return _calculate_weighted_average_score(z_signal, half_life_weights)
134
+
135
+
136
+ def _pivots_difference(
137
+ ohlc: pd.DataFrame,
138
+ jason: pd.DataFrame,
139
+ half_life_weights: Dict[pd.Timedelta, float],
140
+ **kwargs,
141
+ ) -> float:
142
+ price_range = abs(ohlc["close"].iloc[-1] - ohlc["close"].iloc[0])
143
+ if price_range == 0:
144
+ return 0.0
145
+ pivots_diff = (jason["high_pivot"].diff() - jason["low_pivot"].diff()) / price_range
146
+ return _calculate_weighted_average_score(pivots_diff, half_life_weights)
147
+
148
+
149
+ def _pivots_time_difference(
150
+ ohlc: pd.DataFrame,
151
+ jason: pd.DataFrame,
152
+ half_life_weights: Dict[pd.Timedelta, float],
153
+ **kwargs,
154
+ ) -> float:
155
+ time_pivot_diff = jason["low_days"] - jason["high_days"]
156
+ return _calculate_weighted_average_score(time_pivot_diff, half_life_weights)
157
+
158
+
159
+ def _obv_momentum(
160
+ ohlc: pd.DataFrame,
161
+ jason: pd.DataFrame,
162
+ half_life_weights: Dict[pd.Timedelta, float],
163
+ **kwargs,
164
+ ) -> float:
165
+ if "Volume" not in ohlc.columns and "volume" not in ohlc.columns:
166
+ return 0.0
167
+ vol_col = "Volume" if "Volume" in ohlc.columns else "volume"
168
+ price_change = ohlc["close"].diff()
169
+ signed_volume = np.sign(price_change).fillna(0) * ohlc[vol_col]
170
+ obv = signed_volume.cumsum()
171
+ obv_diff = obv.diff()
172
+ weighted_scores = 0.0
173
+ total_weight = 0.0
174
+ for hl, weight in half_life_weights.items():
175
+ obv_momentum = obv_diff.ewm(halflife=hl, times=obv_diff.index).mean().iloc[-1]
176
+ ewm_volume = ohlc[vol_col].ewm(halflife=hl, times=ohlc[vol_col].index).mean().iloc[-1]
177
+ if ewm_volume == 0:
178
+ score = 0.0
179
+ else:
180
+ score = np.clip(obv_momentum / ewm_volume, -1, 1)
181
+ weighted_scores += score * weight
182
+ total_weight += weight
183
+ if total_weight == 0:
184
+ return 0.0
185
+ return weighted_scores / total_weight
186
+
187
+
188
+ def _trend_efficiency(
189
+ ohlc: pd.DataFrame,
190
+ jason: pd.DataFrame,
191
+ half_life_weights: Dict[pd.Timedelta, float],
192
+ **kwargs,
193
+ ) -> float:
194
+ price_diffs = ohlc["close"].diff()
195
+ weighted_scores = 0.0
196
+ total_weight = 0.0
197
+ for hl, weight in half_life_weights.items():
198
+ ewm_direction = price_diffs.ewm(halflife=hl, times=price_diffs.index).mean().iloc[-1]
199
+ ewm_volatility = price_diffs.abs().ewm(halflife=hl, times=price_diffs.index).mean().iloc[-1]
200
+ if ewm_volatility == 0:
201
+ score = 0.0
202
+ else:
203
+ efficiency_score = ewm_direction / ewm_volatility
204
+ score = np.clip(efficiency_score, -1, 1)
205
+ weighted_scores += score * weight
206
+ total_weight += weight
207
+ if total_weight == 0:
208
+ return 0.0
209
+ return weighted_scores / total_weight
210
+
211
+
212
+ def _volatility_return_correlation(
213
+ ohlc: pd.DataFrame,
214
+ jason: pd.DataFrame,
215
+ half_life_weights: Dict[pd.Timedelta, float],
216
+ **kwargs,
217
+ ) -> float:
218
+ returns = np.log(ohlc["close"]).diff()
219
+ volatility = returns.rolling(window=5).std()
220
+ valid = pd.concat([returns, volatility], axis=1, keys=["returns", "volatility"]).dropna()
221
+ if valid.empty:
222
+ return 0.0
223
+ returns = valid["returns"]
224
+ volatility = valid["volatility"]
225
+ weighted_scores = 0.0
226
+ total_weight = 0.0
227
+ for hl, weight in half_life_weights.items():
228
+ correlation = returns.ewm(halflife=hl).corr(volatility).fillna(0)
229
+ score = float(correlation.iloc[-1]) if not correlation.empty else 0.0
230
+ weighted_scores += score * weight
231
+ total_weight += weight
232
+ if total_weight == 0:
233
+ return 0.0
234
+ return weighted_scores / total_weight
235
+
236
+
237
+ # ============================================================================
238
+ # FUNCTION REGISTRY
239
+ # ============================================================================
240
+
241
+ _TREND_FUNCTION_MAP: Dict[TrendFunction, Callable] = {
242
+ TrendFunction.LOG_RETURNS: _log_returns,
243
+ TrendFunction.Z_SCORE_ABOVE_THRESHOLD: _z_score_above_threshold,
244
+ TrendFunction.Z_SCORE_LOW_VOL: _z_score_low_vol,
245
+ TrendFunction.AVG_Z_DIFF: _avg_z_diff,
246
+ TrendFunction.Z_DIFF_ABOVE_THRESHOLD: _avg_z_diff_above_threshold,
247
+ TrendFunction.PIVOTS_DIFFERENCE: _pivots_difference,
248
+ TrendFunction.TIME_SINCE_PIVOTS: _pivots_time_difference,
249
+ TrendFunction.OBV_MOMENTUM: _obv_momentum,
250
+ TrendFunction.TREND_EFFICIENCY: _trend_efficiency,
251
+ TrendFunction.VOL_RETURN_CORRELATION: _volatility_return_correlation,
252
+ }
253
+
254
+
255
+ # ============================================================================
256
+ # TREND ANALYZER CLASS
257
+ # ============================================================================
258
+
259
+ class TrendAnalyzer:
260
+ """Multi-factor trend scorer using exponentially weighted averages.
261
+
262
+ Args:
263
+ interval: Candle interval for the data being analyzed.
264
+ trend_functions: Which trend functions to compute.
265
+ """
266
+
267
+ def __init__(self, interval: JInterval, trend_functions: list[TrendFunction]) -> None:
268
+ self.interval = interval
269
+ self.trend_functions = trend_functions
270
+ self.half_life_weights = _HALF_LIFE_WEIGHTS
271
+
272
+ def run_analysis_from_data(
273
+ self,
274
+ ohlc_data: Dict[str, pd.DataFrame],
275
+ jason_data: Dict[str, pd.DataFrame],
276
+ ) -> pd.DataFrame:
277
+ """Run trend analysis on pre-fetched data.
278
+
279
+ Args:
280
+ ohlc_data: OHLC DataFrames keyed by coin identifier.
281
+ jason_data: JASON DataFrames keyed by coin identifier.
282
+
283
+ Returns:
284
+ DataFrame with normalized trend scores and Average Score, sorted descending.
285
+ Empty DataFrame if no coins have data.
286
+ """
287
+ all_scores: Dict[str, Dict[str, float]] = {}
288
+ active_coins = list(jason_data.keys())
289
+
290
+ for coin in active_coins:
291
+ coin_ohlc = ohlc_data.get(coin)
292
+ coin_jason = jason_data.get(coin)
293
+ if coin_ohlc is None or coin_jason is None:
294
+ continue
295
+ if coin_ohlc.empty or coin_jason.empty:
296
+ continue
297
+
298
+ coin_scores: Dict[str, float] = {}
299
+ for func in self.trend_functions:
300
+ try:
301
+ score_method = _TREND_FUNCTION_MAP.get(func)
302
+ if score_method:
303
+ score = score_method(
304
+ coin_ohlc,
305
+ coin_jason,
306
+ self.half_life_weights,
307
+ interval=self.interval,
308
+ )
309
+ coin_scores[func.value] = score
310
+ else:
311
+ coin_scores[func.value] = 0.0
312
+ except Exception as e:
313
+ logger.warning("Error computing %s for %s: %s", func.value, coin, e)
314
+ coin_scores[func.value] = 0.0
315
+
316
+ all_scores[coin] = coin_scores
317
+
318
+ if not all_scores:
319
+ return pd.DataFrame()
320
+
321
+ df = pd.DataFrame(all_scores).T
322
+ available_funcs = [f.value for f in self.trend_functions if f.value in df.columns]
323
+ if available_funcs:
324
+ df = df[available_funcs]
325
+
326
+ df_normalized = df.div(df.abs().max()).fillna(0)
327
+ df_normalized["Average Score"] = df_normalized.mean(axis=1)
328
+ return df_normalized.sort_values(by="Average Score", ascending=False).round(4)
329
+
330
+
331
+ # ============================================================================
332
+ # HELPER FUNCTIONS
333
+ # ============================================================================
334
+
335
+ def _slice_data_for_end_time(
336
+ data: Dict[str, pd.DataFrame],
337
+ start_time: pd.Timestamp,
338
+ end_time: pd.Timestamp,
339
+ ) -> Dict[str, pd.DataFrame]:
340
+ sliced: Dict[str, pd.DataFrame] = {}
341
+ for coin, df in data.items():
342
+ if df is None or df.empty:
343
+ continue
344
+ window = df[(df.index >= start_time) & (df.index <= end_time)]
345
+ if not window.empty:
346
+ sliced[coin] = window
347
+ return sliced
348
+
349
+
350
+ def _calculate_market_trend_summary(
351
+ ranking_df: pd.DataFrame,
352
+ trend_df_t24: pd.DataFrame,
353
+ trend_df_t3d: pd.DataFrame,
354
+ trend_df_t7d: pd.DataFrame,
355
+ ) -> MarketTrendSummary:
356
+ market_average = float(ranking_df["Average Score"].mean())
357
+
358
+ if not trend_df_t24.empty and "Average Score" in trend_df_t24.columns:
359
+ delta_24h = market_average - float(trend_df_t24["Average Score"].mean())
360
+ else:
361
+ delta_24h = 0.0
362
+
363
+ if not trend_df_t3d.empty and "Average Score" in trend_df_t3d.columns:
364
+ delta_3d = market_average - float(trend_df_t3d["Average Score"].mean())
365
+ else:
366
+ delta_3d = 0.0
367
+
368
+ if not trend_df_t7d.empty and "Average Score" in trend_df_t7d.columns:
369
+ delta_7d = market_average - float(trend_df_t7d["Average Score"].mean())
370
+ else:
371
+ delta_7d = 0.0
372
+
373
+ return MarketTrendSummary(
374
+ market_trend=SummaryMetric(label="MARKET TREND", value=market_average),
375
+ delta_24h=SummaryMetric(label="Δ 24H", value=delta_24h),
376
+ delta_3d=SummaryMetric(label="Δ 3D", value=delta_3d),
377
+ delta_7d=SummaryMetric(label="Δ 7D", value=delta_7d),
378
+ )
379
+
380
+
381
+ def _build_price_rows(
382
+ ohlc_data: Dict[str, pd.DataFrame],
383
+ ranking_index: pd.Index,
384
+ periods_24h: int,
385
+ periods_7d: int,
386
+ ) -> pd.DataFrame:
387
+ price_rows: Dict[str, dict] = {}
388
+ min_periods = max((2 * periods_24h), periods_7d) + 1 if periods_24h > 0 or periods_7d > 0 else 1
389
+
390
+ for coin in ranking_index:
391
+ coin_ohlc = ohlc_data.get(coin)
392
+ if coin_ohlc is not None and not coin_ohlc.empty and len(coin_ohlc) > min_periods:
393
+ close_series = coin_ohlc["close"]
394
+ last_price = close_series.iloc[-1]
395
+ price_24h_ago = close_series.iloc[-1 - periods_24h] if periods_24h > 0 else last_price
396
+ price_48h_ago = close_series.iloc[-1 - (2 * periods_24h)] if periods_24h > 0 else last_price
397
+ price_7d_ago = close_series.iloc[-1 - periods_7d] if periods_7d > 0 else last_price
398
+
399
+ price_rows[coin] = {
400
+ "Last Spot": last_price,
401
+ "24h Chg%": (last_price / price_24h_ago - 1) if price_24h_ago != 0 else 0.0,
402
+ "Prev. 24h Chg%": (price_24h_ago / price_48h_ago - 1) if price_48h_ago != 0 else 0.0,
403
+ "7d Chg%": (last_price / price_7d_ago - 1) if price_7d_ago != 0 else 0.0,
404
+ }
405
+ continue
406
+
407
+ price_rows[coin] = {
408
+ "Last Spot": coin_ohlc["close"].iloc[-1] if coin_ohlc is not None and not coin_ohlc.empty else np.nan,
409
+ "24h Chg%": np.nan,
410
+ "Prev. 24h Chg%": np.nan,
411
+ "7d Chg%": np.nan,
412
+ }
413
+
414
+ return pd.DataFrame.from_dict(price_rows, orient="index")
415
+
416
+
417
+ def _build_chart_series(
418
+ ranking_df: pd.DataFrame,
419
+ ohlc_data: Dict[str, pd.DataFrame],
420
+ count: int,
421
+ plot_start: pd.Timestamp,
422
+ chart_title: str,
423
+ chart_subtitle: str,
424
+ worst: bool = False,
425
+ ) -> ChartData:
426
+ selected = (
427
+ ranking_df["Average Score"].nsmallest(count)
428
+ if worst
429
+ else ranking_df["Average Score"].nlargest(count)
430
+ )
431
+
432
+ series_payload: List[CoinSeries] = []
433
+ for coin, average_score in selected.items():
434
+ coin_ohlc = ohlc_data.get(coin)
435
+ if coin_ohlc is None or coin_ohlc.empty:
436
+ continue
437
+
438
+ prices = coin_ohlc["close"].loc[plot_start:]
439
+ if prices.empty:
440
+ continue
441
+
442
+ normalized_prices = (prices / prices.iloc[0]) * 100.0
443
+ series_payload.append(
444
+ CoinSeries(
445
+ coin=coin,
446
+ average_score=float(average_score),
447
+ series=normalized_prices,
448
+ )
449
+ )
450
+
451
+ return ChartData(
452
+ title=chart_title,
453
+ subtitle=chart_subtitle,
454
+ series=series_payload,
455
+ )
456
+
457
+
458
+ # ============================================================================
459
+ # PUBLIC ENTRY POINT
460
+ # ============================================================================
461
+
462
+ def compute_trend_analysis(
463
+ datasets: list[CoinDataset],
464
+ interval: JInterval,
465
+ start_date: pd.Timestamp,
466
+ end_date: pd.Timestamp,
467
+ top_n: int = 4,
468
+ plot_window_days: Optional[int] = None,
469
+ trend_functions: Optional[list[TrendFunction]] = None,
470
+ ) -> TrendAnalysisResult:
471
+ """Compute trend analysis across a set of coins.
472
+
473
+ Args:
474
+ datasets: List of CoinDataset, each containing a coin identifier,
475
+ OHLC klines DataFrame, and jason DataFrame.
476
+ interval: Data interval (e.g., JInterval.H4, JInterval.D1).
477
+ start_date: Start of the analysis window.
478
+ end_date: End of the analysis window.
479
+ top_n: Number of top/bottom coins to include in charts.
480
+ plot_window_days: Chart lookback window in days. If None, derived
481
+ from the analysis period (clamped to 7-30 days).
482
+ trend_functions: Subset of trend functions to compute. If None,
483
+ all 10 are computed.
484
+
485
+ Returns:
486
+ TrendAnalysisResult with summary metrics, charts, and ranking DataFrame.
487
+
488
+ Raises:
489
+ ValueError: If start_date > end_date.
490
+ InsufficientDataError: If no coins have enough data to produce results.
491
+
492
+ Note:
493
+ For meaningful historical delta metrics (24h, 3d, 7d), provide data
494
+ starting at least 42 days (7d * 5 lookback + 7d) before end_date.
495
+ """
496
+ if start_date > end_date:
497
+ raise ValueError("start_date must be before or equal to end_date")
498
+
499
+ trend_functions_to_use = trend_functions or list(TrendFunction)
500
+
501
+ # Convert datasets to dicts
502
+ ohlc_data: Dict[str, pd.DataFrame] = {ds.coin: ds.klines_df for ds in datasets}
503
+ jason_data: Dict[str, pd.DataFrame] = {ds.coin: ds.jason_df for ds in datasets}
504
+
505
+ analyzer = TrendAnalyzer(interval=interval, trend_functions=trend_functions_to_use)
506
+
507
+ # Period calculations
508
+ interval_td = interval.to_timedelta()
509
+ try:
510
+ periods_24h = int(pd.Timedelta("24h") / interval_td)
511
+ except ZeroDivisionError:
512
+ periods_24h = 0
513
+ try:
514
+ periods_7d = int(pd.Timedelta("7D") / interval_td)
515
+ except ZeroDivisionError:
516
+ periods_7d = 0
517
+
518
+ lookback_mult = pd.Timedelta("7D") * 5
519
+
520
+ # Historical snapshots
521
+ end_t24 = end_date - pd.Timedelta(days=1)
522
+ start_t24 = end_t24 - lookback_mult
523
+ trend_df_t24 = analyzer.run_analysis_from_data(
524
+ _slice_data_for_end_time(ohlc_data, start_t24, end_t24),
525
+ _slice_data_for_end_time(jason_data, start_t24, end_t24),
526
+ )
527
+ historical_ranks_t24 = (
528
+ trend_df_t24["Average Score"].rank(ascending=False).astype(int).rename("Rank_T24")
529
+ if not trend_df_t24.empty and "Average Score" in trend_df_t24.columns
530
+ else pd.Series(dtype=int, name="Rank_T24")
531
+ )
532
+
533
+ end_t3d = end_date - pd.Timedelta(days=3)
534
+ start_t3d = end_t3d - lookback_mult
535
+ trend_df_t3d = analyzer.run_analysis_from_data(
536
+ _slice_data_for_end_time(ohlc_data, start_t3d, end_t3d),
537
+ _slice_data_for_end_time(jason_data, start_t3d, end_t3d),
538
+ )
539
+
540
+ end_t7d = end_date - pd.Timedelta(days=7)
541
+ start_t7d = end_t7d - lookback_mult
542
+ trend_df_t7d = analyzer.run_analysis_from_data(
543
+ _slice_data_for_end_time(ohlc_data, start_t7d, end_t7d),
544
+ _slice_data_for_end_time(jason_data, start_t7d, end_t7d),
545
+ )
546
+ historical_ranks_t7d = (
547
+ trend_df_t7d["Average Score"].rank(ascending=False).astype(int).rename("Rank_T7d")
548
+ if not trend_df_t7d.empty and "Average Score" in trend_df_t7d.columns
549
+ else pd.Series(dtype=int, name="Rank_T7d")
550
+ )
551
+
552
+ # Check that at least one coin has data overlapping the requested window
553
+ sliced_for_window = _slice_data_for_end_time(ohlc_data, start_date, end_date)
554
+ if not sliced_for_window:
555
+ raise InsufficientDataError(
556
+ "No coins have data within the requested date range "
557
+ f"[{start_date}, {end_date}]."
558
+ )
559
+
560
+ # Current ranking
561
+ ranking_df = analyzer.run_analysis_from_data(ohlc_data, jason_data)
562
+ if ranking_df.empty:
563
+ raise InsufficientDataError("No coins have enough data to produce trend analysis results.")
564
+
565
+ current_ranks = ranking_df["Average Score"].rank(ascending=False).astype(int).rename("Rank_T0")
566
+ price_df = _build_price_rows(ohlc_data, ranking_df.index, periods_24h, periods_7d)
567
+
568
+ combined_df = pd.concat([price_df, ranking_df, current_ranks], axis=1)
569
+ combined_df = combined_df.join(historical_ranks_t24).join(historical_ranks_t7d)
570
+ combined_df["Rank Δ 24h"] = (combined_df["Rank_T24"] - combined_df["Rank_T0"]).fillna(0).astype(int)
571
+ combined_df["Rank Δ 7d"] = (combined_df["Rank_T7d"] - combined_df["Rank_T0"]).fillna(0).astype(int)
572
+
573
+ ordered_columns = [
574
+ "Last Spot",
575
+ "24h Chg%",
576
+ "Prev. 24h Chg%",
577
+ "7d Chg%",
578
+ "Rank Δ 24h",
579
+ "Rank Δ 7d",
580
+ "Average Score",
581
+ *[tf.value for tf in trend_functions_to_use],
582
+ ]
583
+ combined_df = combined_df.reindex(columns=[col for col in ordered_columns if col in combined_df.columns])
584
+
585
+ # Plot window
586
+ if plot_window_days is None:
587
+ analysis_period_days = max(0, (end_date.normalize() - start_date.normalize()).days + 1)
588
+ plot_window_days = max(7, min(30, analysis_period_days))
589
+ plot_label = f"{plot_window_days}d"
590
+
591
+ latest_timestamp = max(df.index.max() for df in ohlc_data.values() if df is not None and not df.empty)
592
+ plot_start = latest_timestamp - pd.Timedelta(days=plot_window_days)
593
+ subtitle = latest_timestamp.strftime("%Y-%m-%d %H:%M UTC")
594
+
595
+ return TrendAnalysisResult(
596
+ start_date=start_date.strftime("%Y-%m-%d"),
597
+ end_date=end_date.strftime("%Y-%m-%d"),
598
+ interval=interval,
599
+ generated_at=pd.Timestamp.now("UTC").to_pydatetime(),
600
+ as_of=latest_timestamp.to_pydatetime(),
601
+ top_n=top_n,
602
+ plot_window=PlotWindow(
603
+ label=plot_label,
604
+ start_timestamp=plot_start.to_pydatetime(),
605
+ end_timestamp=latest_timestamp.to_pydatetime(),
606
+ ),
607
+ summary=_calculate_market_trend_summary(
608
+ combined_df,
609
+ trend_df_t24=trend_df_t24,
610
+ trend_df_t3d=trend_df_t3d,
611
+ trend_df_t7d=trend_df_t7d,
612
+ ),
613
+ charts=TrendCharts(
614
+ spot_price_leaders=_build_chart_series(
615
+ ranking_df=combined_df,
616
+ ohlc_data=ohlc_data,
617
+ count=top_n,
618
+ plot_start=plot_start,
619
+ chart_title="Spot Price - Leaders",
620
+ chart_subtitle=subtitle,
621
+ worst=False,
622
+ ),
623
+ spot_price_laggers=_build_chart_series(
624
+ ranking_df=combined_df,
625
+ ohlc_data=ohlc_data,
626
+ count=top_n,
627
+ plot_start=plot_start,
628
+ chart_title="Spot Price - Laggers",
629
+ chart_subtitle=subtitle,
630
+ worst=True,
631
+ ),
632
+ ),
633
+ ranking=combined_df,
634
+ )