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.
- jasonlib/__init__.py +53 -0
- jasonlib/_calculator.py +248 -0
- jasonlib/_math.py +52 -0
- jasonlib/_models.py +62 -0
- jasonlib/_numba_kernels.py +195 -0
- jasonlib/_pivot_analysis.py +308 -0
- jasonlib/_pivot_models.py +45 -0
- jasonlib/_trading_calendar.py +93 -0
- jasonlib/_trend_analysis.py +634 -0
- jasonlib/_trend_models.py +165 -0
- jasonlib_dev-0.1.0.dist-info/METADATA +10 -0
- jasonlib_dev-0.1.0.dist-info/RECORD +13 -0
- jasonlib_dev-0.1.0.dist-info/WHEEL +4 -0
|
@@ -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
|
+
)
|