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,308 @@
1
+ """Pivot breakout detection and analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from datetime import datetime, timezone
7
+
8
+ import pandas as pd
9
+
10
+ from jasonlib._models import JInterval
11
+ from jasonlib._pivot_models import (
12
+ PivotAnalysisParameters,
13
+ PivotAnalysisResult,
14
+ PivotAnalysisSummary,
15
+ PivotSortOption,
16
+ )
17
+ from jasonlib._trend_models import CoinDataset, InsufficientDataError
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def _normalize_merge_time_precision(series: pd.Series) -> pd.Series:
23
+ """Force merge keys to a consistent timezone-aware precision for merge_asof."""
24
+ if series.dt.tz is None:
25
+ return series.dt.tz_localize("UTC").astype("datetime64[ns, UTC]")
26
+ return series.astype("datetime64[ns, UTC]")
27
+
28
+
29
+ def detect_pivot_breakouts(
30
+ jason_data: dict[str, pd.DataFrame],
31
+ ohlc_data: dict[str, pd.DataFrame],
32
+ cooldown_hours: float = 24.0,
33
+ lookback_hours: float = 4.0,
34
+ ) -> pd.DataFrame:
35
+ """Vectorized pivot breakout detection.
36
+
37
+ Detects when price breaks above/below the pivot from N hours ago.
38
+ Uses pd.merge_asof for efficient time-series matching.
39
+ Cooldown period avoids recounting oscillations.
40
+
41
+ Args:
42
+ jason_data: JASON DataFrames with 'high_pivot', 'low_pivot' columns.
43
+ ohlc_data: OHLC DataFrames with 'close' column.
44
+ cooldown_hours: Minimum hours between counting separate breakout events.
45
+ lookback_hours: Hours to look back for pivot comparison.
46
+
47
+ Returns:
48
+ DataFrame with breakout status, distances, and counts per ticker.
49
+ """
50
+ breakout_data = {}
51
+ lookback_timedelta = pd.Timedelta(hours=lookback_hours)
52
+ cooldown_timedelta = pd.Timedelta(hours=cooldown_hours)
53
+
54
+ for ticker in jason_data.keys():
55
+ if ticker not in ohlc_data:
56
+ continue
57
+
58
+ jason_df = jason_data[ticker].copy()
59
+ ohlc_df = ohlc_data[ticker].copy()
60
+
61
+ if jason_df.empty or ohlc_df.empty or len(jason_df) < 2:
62
+ continue
63
+
64
+ if "high_pivot" not in jason_df.columns or "low_pivot" not in jason_df.columns:
65
+ continue
66
+
67
+ # === CURRENT STATUS (Latest values) ===
68
+ latest_price = ohlc_df["close"].iloc[-1]
69
+ latest_timestamp = ohlc_df.index[-1]
70
+
71
+ # Find pivot from lookback_hours ago for current status
72
+ lookback_time = latest_timestamp - lookback_timedelta
73
+ valid_jason = jason_df[jason_df.index < latest_timestamp]
74
+ if len(valid_jason) == 0:
75
+ continue
76
+
77
+ time_diffs = abs(valid_jason.index - lookback_time)
78
+ closest_idx = time_diffs.argmin()
79
+ lookback_jason = valid_jason.iloc[closest_idx]
80
+
81
+ high_pivot = lookback_jason["high_pivot"]
82
+ low_pivot = lookback_jason["low_pivot"]
83
+
84
+ # Calculate distances
85
+ dist_to_high_pct = ((latest_price - high_pivot) / high_pivot) * 100
86
+ dist_to_low_pct = ((latest_price - low_pivot) / low_pivot) * 100
87
+
88
+ if latest_price > high_pivot:
89
+ current_breakout = "High Broken"
90
+ elif latest_price < low_pivot:
91
+ current_breakout = "Low Broken"
92
+ else:
93
+ current_breakout = "Between Pivots"
94
+
95
+ # Merge OHLC and JASON data
96
+ merged = pd.merge(
97
+ ohlc_df[["close"]],
98
+ jason_df[["high_pivot", "low_pivot"]],
99
+ left_index=True,
100
+ right_index=True,
101
+ how="inner",
102
+ ).dropna()
103
+
104
+ if len(merged) < 2:
105
+ breakout_data[ticker] = {
106
+ "current_price": latest_price,
107
+ "high_pivot": high_pivot,
108
+ "low_pivot": low_pivot,
109
+ "dist_to_high_pivot_pct": dist_to_high_pct,
110
+ "dist_to_low_pivot_pct": dist_to_low_pct,
111
+ "current_breakout": current_breakout,
112
+ "high_breaks_count": 0,
113
+ "low_breaks_count": 0,
114
+ "total_breaks": 0,
115
+ "last_breakout_time": None,
116
+ "hours_since_last_breakout": None,
117
+ }
118
+ continue
119
+
120
+ high_breaks = 0
121
+ low_breaks = 0
122
+ last_high_break_time = None
123
+ last_low_break_time = None
124
+
125
+ # === VECTORIZED HISTORICAL BREAKOUT COUNTING ===
126
+ merged_with_target = merged.copy()
127
+ merged_with_target["target_time"] = merged_with_target.index - lookback_timedelta
128
+
129
+ # Prepare jason data for merge_asof
130
+ jason_for_merge = jason_df[["high_pivot", "low_pivot"]].copy()
131
+ jason_for_merge = jason_for_merge.reset_index()
132
+ jason_for_merge.columns = ["time", "lookback_high_pivot", "lookback_low_pivot"]
133
+ jason_for_merge["time"] = _normalize_merge_time_precision(jason_for_merge["time"])
134
+
135
+ merged_with_target = merged_with_target.reset_index()
136
+ merged_with_target.columns = ["time", "close", "high_pivot", "low_pivot", "target_time"]
137
+ merged_with_target["target_time"] = _normalize_merge_time_precision(
138
+ merged_with_target["target_time"]
139
+ )
140
+
141
+ # Merge lookback pivots using merge_asof (efficient nearest-match join)
142
+ result = pd.merge_asof(
143
+ merged_with_target.sort_values("target_time"),
144
+ jason_for_merge.sort_values("time"),
145
+ left_on="target_time",
146
+ right_on="time",
147
+ direction="nearest",
148
+ suffixes=("", "_lookup"),
149
+ )
150
+ result = result.set_index("time").sort_index()
151
+
152
+ # Vectorized breakout detection
153
+ result["above_high"] = result["close"] > result["lookback_high_pivot"]
154
+ result["below_low"] = result["close"] < result["lookback_low_pivot"]
155
+
156
+ # Detect crossovers (transitions from False to True)
157
+ result["high_cross"] = result["above_high"] & ~result["above_high"].shift(
158
+ 1, fill_value=False
159
+ )
160
+ result["low_cross"] = result["below_low"] & ~result["below_low"].shift(
161
+ 1, fill_value=False
162
+ )
163
+
164
+ # Apply cooldown — iterate only over crossover events
165
+ high_break_indices = result[result["high_cross"]].index
166
+ for break_time in high_break_indices:
167
+ if (
168
+ last_high_break_time is None
169
+ or (break_time - last_high_break_time) > cooldown_timedelta
170
+ ):
171
+ high_breaks += 1
172
+ last_high_break_time = break_time
173
+
174
+ low_break_indices = result[result["low_cross"]].index
175
+ for break_time in low_break_indices:
176
+ if (
177
+ last_low_break_time is None
178
+ or (break_time - last_low_break_time) > cooldown_timedelta
179
+ ):
180
+ low_breaks += 1
181
+ last_low_break_time = break_time
182
+
183
+ # Determine the last breakout time
184
+ last_breakout_time = None
185
+ if last_high_break_time is not None and last_low_break_time is not None:
186
+ last_breakout_time = max(last_high_break_time, last_low_break_time)
187
+ elif last_high_break_time is not None:
188
+ last_breakout_time = last_high_break_time
189
+ elif last_low_break_time is not None:
190
+ last_breakout_time = last_low_break_time
191
+
192
+ # Calculate time since last breakout
193
+ time_since_last_breakout = None
194
+ if last_breakout_time is not None:
195
+ time_since_last_breakout = (
196
+ latest_timestamp - last_breakout_time
197
+ ).total_seconds() / 3600.0
198
+
199
+ breakout_data[ticker] = {
200
+ "current_price": latest_price,
201
+ "high_pivot": high_pivot,
202
+ "low_pivot": low_pivot,
203
+ "dist_to_high_pivot_pct": dist_to_high_pct,
204
+ "dist_to_low_pivot_pct": dist_to_low_pct,
205
+ "current_breakout": current_breakout,
206
+ "high_breaks_count": high_breaks,
207
+ "low_breaks_count": low_breaks,
208
+ "total_breaks": high_breaks + low_breaks,
209
+ "last_breakout_time": last_breakout_time,
210
+ "hours_since_last_breakout": time_since_last_breakout,
211
+ }
212
+
213
+ return pd.DataFrame.from_dict(breakout_data, orient="index")
214
+
215
+
216
+ _PIVOT_SORT_COLUMN_MAP: dict[PivotSortOption, str] = {
217
+ PivotSortOption.TOTAL_BREAKS: "total_breaks",
218
+ PivotSortOption.HIGH_BREAKS: "high_breaks_count",
219
+ PivotSortOption.LOW_BREAKS: "low_breaks_count",
220
+ PivotSortOption.BREAKOUT_STATUS: "current_breakout",
221
+ }
222
+
223
+
224
+ def compute_pivot_analysis(
225
+ datasets: list[CoinDataset],
226
+ interval: JInterval,
227
+ start_date: pd.Timestamp,
228
+ end_date: pd.Timestamp,
229
+ cooldown_hours: float = 24.0,
230
+ lookback_hours: float = 4.0,
231
+ sort_by: PivotSortOption = PivotSortOption.TOTAL_BREAKS,
232
+ ) -> PivotAnalysisResult:
233
+ """Compute pivot breakout analysis across a set of coins.
234
+
235
+ Args:
236
+ datasets: List of CoinDataset, each containing a coin identifier,
237
+ OHLC klines DataFrame (with 'close' column), and jason DataFrame
238
+ (with 'high_pivot', 'low_pivot' columns).
239
+ interval: Data interval (e.g., JInterval.H4, JInterval.D1).
240
+ start_date: Start of the analysis window.
241
+ end_date: End of the analysis window.
242
+ cooldown_hours: Minimum hours between counting separate breakout events.
243
+ lookback_hours: Hours to look back for pivot comparison.
244
+ sort_by: How to sort the breakout results.
245
+
246
+ Returns:
247
+ PivotAnalysisResult with summary metrics and breakout DataFrame.
248
+
249
+ Raises:
250
+ ValueError: If start_date > end_date.
251
+ InsufficientDataError: If no coins have enough data to produce results.
252
+ """
253
+ if start_date > end_date:
254
+ raise ValueError("start_date must be before or equal to end_date")
255
+
256
+ # Convert CoinDataset list to dict pairs for detect_pivot_breakouts
257
+ jason_data = {ds.coin: ds.jason_df for ds in datasets}
258
+ ohlc_data = {ds.coin: ds.klines_df for ds in datasets}
259
+
260
+ breakout_df = detect_pivot_breakouts(
261
+ jason_data=jason_data,
262
+ ohlc_data=ohlc_data,
263
+ cooldown_hours=cooldown_hours,
264
+ lookback_hours=lookback_hours,
265
+ )
266
+
267
+ if breakout_df.empty:
268
+ raise InsufficientDataError(
269
+ "No coins have enough data to produce pivot analysis results."
270
+ )
271
+
272
+ # Sort by requested column
273
+ sort_col = _PIVOT_SORT_COLUMN_MAP[sort_by]
274
+ sorted_df = breakout_df.sort_values(by=sort_col, ascending=False)
275
+
276
+ # Compute summary
277
+ summary = PivotAnalysisSummary(
278
+ high_pivots_broken=int((sorted_df["current_breakout"] == "High Broken").sum()),
279
+ low_pivots_broken=int((sorted_df["current_breakout"] == "Low Broken").sum()),
280
+ avg_total_breaks=float(sorted_df["total_breaks"].mean()),
281
+ between_pivots=int((sorted_df["current_breakout"] == "Between Pivots").sum()),
282
+ )
283
+
284
+ # Determine as_of from latest OHLC timestamp
285
+ latest_timestamps = []
286
+ for ds in datasets:
287
+ if ds.klines_df is not None and not ds.klines_df.empty:
288
+ latest_timestamps.append(pd.Timestamp(ds.klines_df.index.max()))
289
+ as_of = max(latest_timestamps) if latest_timestamps else datetime.now(timezone.utc)
290
+ # Ensure as_of is a proper datetime
291
+ if isinstance(as_of, pd.Timestamp):
292
+ as_of = as_of.to_pydatetime()
293
+
294
+ return PivotAnalysisResult(
295
+ start_date=start_date.strftime("%Y-%m-%d"),
296
+ end_date=end_date.strftime("%Y-%m-%d"),
297
+ interval=interval,
298
+ generated_at=datetime.now(timezone.utc),
299
+ as_of=as_of,
300
+ parameters=PivotAnalysisParameters(
301
+ cooldown_hours=cooldown_hours,
302
+ lookback_hours=lookback_hours,
303
+ sort_by=sort_by,
304
+ sort_options=list(PivotSortOption),
305
+ ),
306
+ summary=summary,
307
+ breakouts=sorted_df,
308
+ )
@@ -0,0 +1,45 @@
1
+ """Pivot analysis data models and enums."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import Enum
7
+
8
+ import pandas as pd
9
+
10
+ from jasonlib._models import JInterval
11
+ from jasonlib._trend_models import BaseModelWithPandasFields
12
+
13
+
14
+ class PivotSortOption(str, Enum):
15
+ """Sort options for pivot breakout results."""
16
+
17
+ TOTAL_BREAKS = "Total Breaks"
18
+ HIGH_BREAKS = "High Breaks"
19
+ LOW_BREAKS = "Low Breaks"
20
+ BREAKOUT_STATUS = "Breakout Status"
21
+
22
+
23
+ class PivotAnalysisSummary(BaseModelWithPandasFields):
24
+ high_pivots_broken: int
25
+ low_pivots_broken: int
26
+ avg_total_breaks: float
27
+ between_pivots: int
28
+
29
+
30
+ class PivotAnalysisParameters(BaseModelWithPandasFields):
31
+ cooldown_hours: float
32
+ lookback_hours: float
33
+ sort_by: PivotSortOption
34
+ sort_options: list[PivotSortOption]
35
+
36
+
37
+ class PivotAnalysisResult(BaseModelWithPandasFields):
38
+ start_date: str
39
+ end_date: str
40
+ interval: JInterval
41
+ generated_at: datetime
42
+ as_of: datetime
43
+ parameters: PivotAnalysisParameters
44
+ summary: PivotAnalysisSummary
45
+ breakouts: pd.DataFrame
@@ -0,0 +1,93 @@
1
+ """Trading calendar constants and helpers for JASON calculations."""
2
+
3
+ import logging
4
+
5
+ from jasonlib._models import JAssetClass, JInterval
6
+
7
+ TRADING_DAYS_PER_YEAR: dict[JAssetClass, int] = {
8
+ JAssetClass.CRYPTO: 365,
9
+ JAssetClass.EQUITIES: 252,
10
+ JAssetClass.COMMOD: 252,
11
+ JAssetClass.FX: 252,
12
+ JAssetClass.EQUITY_IDX: 252,
13
+ }
14
+
15
+ TRADING_HOURS_PER_DAY: dict[JAssetClass, float] = {
16
+ JAssetClass.CRYPTO: 24.0,
17
+ JAssetClass.EQUITIES: 6.5,
18
+ JAssetClass.COMMOD: 23.0,
19
+ JAssetClass.FX: 23.0,
20
+ JAssetClass.EQUITY_IDX: 23.0,
21
+ }
22
+
23
+
24
+ def normalize_asset_class(asset_class: JAssetClass | str | None) -> JAssetClass:
25
+ """Normalize legacy strings and enum members to JAssetClass.
26
+
27
+ Args:
28
+ asset_class: A JAssetClass member, a string alias, or None.
29
+ None defaults to CRYPTO.
30
+
31
+ Returns:
32
+ The resolved JAssetClass member.
33
+ """
34
+ if isinstance(asset_class, JAssetClass):
35
+ return asset_class
36
+ if asset_class is None:
37
+ return JAssetClass.CRYPTO
38
+
39
+ alias_map: dict[str, JAssetClass] = {
40
+ "commod": JAssetClass.COMMOD,
41
+ "commodity": JAssetClass.COMMOD,
42
+ "commodities": JAssetClass.COMMOD,
43
+ "crypto": JAssetClass.CRYPTO,
44
+ "fx": JAssetClass.FX,
45
+ "equity": JAssetClass.EQUITIES,
46
+ "equities": JAssetClass.EQUITIES,
47
+ "equity_idx": JAssetClass.EQUITY_IDX,
48
+ }
49
+ return alias_map.get(asset_class.strip().lower(), JAssetClass.CRYPTO)
50
+
51
+
52
+ def get_logger(name: str) -> logging.Logger:
53
+ """Get a configured logger instance.
54
+
55
+ Args:
56
+ name: Name for the logger.
57
+
58
+ Returns:
59
+ Configured Logger with a StreamHandler and INFO level.
60
+ """
61
+ logger = logging.getLogger(name)
62
+ if not logger.handlers:
63
+ handler = logging.StreamHandler()
64
+ formatter = logging.Formatter(
65
+ "[%(asctime)s] %(levelname)s %(name)s: %(message)s"
66
+ )
67
+ handler.setFormatter(formatter)
68
+ logger.addHandler(handler)
69
+ logger.setLevel(logging.INFO)
70
+ return logger
71
+
72
+
73
+ def get_annualization_factor(interval: JInterval, asset_class: JAssetClass) -> float:
74
+ """Return the number of periods per year for the given interval and asset class.
75
+
76
+ Computes the annualization factor based on the interval duration in seconds,
77
+ trading days per year, and trading hours per day for the asset class.
78
+
79
+ Args:
80
+ interval: Candle interval.
81
+ asset_class: Asset class determining the trading calendar.
82
+
83
+ Returns:
84
+ Number of periods per year.
85
+ """
86
+ trading_days = TRADING_DAYS_PER_YEAR[asset_class]
87
+ hours_per_day = TRADING_HOURS_PER_DAY[asset_class]
88
+ trading_seconds_per_day = hours_per_day * 3600
89
+ if interval.seconds >= 86400:
90
+ periods_per_day = 1.0
91
+ else:
92
+ periods_per_day = trading_seconds_per_day / interval.seconds
93
+ return trading_days * periods_per_day