tiportfolio 1.0.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,24 @@
1
+ """TiPortfolio — portfolio backtesting as simple as writing SQL."""
2
+
3
+ from tiportfolio.algo import And, Not, Or
4
+ from tiportfolio.algos import Action, Select, Signal, Weigh
5
+ from tiportfolio.backtest import Backtest, run
6
+ from tiportfolio.config import TiConfig
7
+ from tiportfolio.data import fetch_data, validate_data
8
+ from tiportfolio.portfolio import Portfolio
9
+
10
+ __all__ = [
11
+ "Action",
12
+ "And",
13
+ "Backtest",
14
+ "Not",
15
+ "Or",
16
+ "Portfolio",
17
+ "Select",
18
+ "Signal",
19
+ "TiConfig",
20
+ "Weigh",
21
+ "fetch_data",
22
+ "run",
23
+ "validate_data",
24
+ ]
tiportfolio/algo.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import Callable
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+ import pandas as pd
9
+
10
+ from tiportfolio.config import TiConfig
11
+
12
+
13
+ @dataclass
14
+ class Context:
15
+ """Passed to every Algo.__call__. Carries read-only inputs and mutable inter-algo state."""
16
+
17
+ # read-only inputs
18
+ portfolio: Any # Portfolio (Any to avoid circular import at runtime)
19
+ prices: dict[str, pd.DataFrame]
20
+ date: pd.Timestamp
21
+ config: TiConfig
22
+
23
+ # mutable inter-algo communication
24
+ selected: list[Any] = field(default_factory=list) # list[str | Portfolio]
25
+ weights: dict[str, float] = field(default_factory=dict)
26
+
27
+ # engine callbacks — set by engine before calling algo_queue
28
+ _execute_leaf: Callable[..., None] | None = field(default=None, repr=False)
29
+ _allocate_children: Callable[..., None] | None = field(default=None, repr=False)
30
+
31
+
32
+ class Algo(ABC):
33
+ """Base class for all algo-stack components."""
34
+
35
+ @abstractmethod
36
+ def __call__(self, context: Context) -> bool:
37
+ """Return True to continue the AlgoQueue, False to abort."""
38
+
39
+
40
+ class AlgoQueue(Algo):
41
+ """Runs algos sequentially. Short-circuits on the first False."""
42
+
43
+ def __init__(self, algos: list[Algo]) -> None:
44
+ self._algos = algos
45
+
46
+ def __call__(self, context: Context) -> bool:
47
+ return all(algo(context) for algo in self._algos)
48
+
49
+
50
+ class Or(Algo):
51
+ """Returns True when any inner algo returns True (short-circuits on first True)."""
52
+
53
+ def __init__(self, *algos: Algo) -> None:
54
+ self._algos = algos
55
+
56
+ def __call__(self, context: Context) -> bool:
57
+ return any(algo(context) for algo in self._algos)
58
+
59
+
60
+ class And(Algo):
61
+ """Returns True only when all inner algos return True (short-circuits on first False)."""
62
+
63
+ def __init__(self, *algos: Algo) -> None:
64
+ self._algos = algos
65
+
66
+ def __call__(self, context: Context) -> bool:
67
+ return all(algo(context) for algo in self._algos)
68
+
69
+
70
+ class Not(Algo):
71
+ """Returns True when the wrapped algo returns False."""
72
+
73
+ def __init__(self, algo: Algo) -> None:
74
+ self._algo = algo
75
+
76
+ def __call__(self, context: Context) -> bool:
77
+ return not self._algo(context)
@@ -0,0 +1,6 @@
1
+ from tiportfolio.algos.rebalance import Action
2
+ from tiportfolio.algos.select import Select
3
+ from tiportfolio.algos.signal import Signal
4
+ from tiportfolio.algos.weigh import Weigh
5
+
6
+ __all__ = ["Action", "Select", "Signal", "Weigh"]
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from tiportfolio.algo import Algo, Context
4
+ from tiportfolio.portfolio import Portfolio
5
+
6
+
7
+ class Action:
8
+ """Namespace for action algos (execute trades)."""
9
+
10
+ class Rebalance(Algo):
11
+ """Triggers trade execution via engine callbacks on Context."""
12
+
13
+ def __call__(self, context: Context) -> bool:
14
+ children = context.portfolio.children
15
+ is_parent = (
16
+ children is not None
17
+ and len(children) > 0
18
+ and isinstance(children[0], Portfolio)
19
+ )
20
+ if is_parent:
21
+ if context._allocate_children is None:
22
+ raise RuntimeError(
23
+ "Action.Rebalance: _allocate_children callback not set on Context"
24
+ )
25
+ context._allocate_children(context.portfolio, context)
26
+ else:
27
+ if context._execute_leaf is None:
28
+ raise RuntimeError(
29
+ "Action.Rebalance: _execute_leaf callback not set on Context"
30
+ )
31
+ context._execute_leaf(context.portfolio, context)
32
+ return True
33
+
34
+ class PrintInfo(Algo):
35
+ """Prints debug information about the current evaluation. Always returns True."""
36
+
37
+ def __call__(self, context: Context) -> bool:
38
+ print(
39
+ f"[{context.date.date()}] portfolio={context.portfolio.name}"
40
+ f" selected={context.selected} weights={context.weights}"
41
+ )
42
+ return True
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+
5
+ import pandas as pd
6
+
7
+ from tiportfolio.algo import Algo, Context
8
+
9
+
10
+ class Select:
11
+ """Namespace for select algos (what to include)."""
12
+
13
+ class All(Algo):
14
+ """Selects all children from the portfolio."""
15
+
16
+ def __call__(self, context: Context) -> bool:
17
+ context.selected = list(context.portfolio.children or [])
18
+ return True
19
+
20
+ class Momentum(Algo):
21
+ """Selects top-N tickers by cumulative return over a lookback window.
22
+
23
+ Args:
24
+ n: Number of tickers to select.
25
+ lookback: Length of the return lookback window.
26
+ lag: Offset from current date to avoid look-ahead bias.
27
+ sort_descending: True for top performers, False for worst.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ n: int,
33
+ lookback: pd.DateOffset,
34
+ lag: pd.DateOffset = pd.DateOffset(days=1),
35
+ sort_descending: bool = True,
36
+ ) -> None:
37
+ self._n = n
38
+ self._lookback = lookback
39
+ self._lag = lag
40
+ self._sort_descending = sort_descending
41
+
42
+ def __call__(self, context: Context) -> bool:
43
+ end = context.date - self._lag
44
+ start = end - self._lookback
45
+ scores: dict[str, float] = {}
46
+ for item in context.selected:
47
+ if not isinstance(item, str):
48
+ continue
49
+ series = context.prices[item].loc[start:end, "close"]
50
+ scores[item] = series.pct_change().sum()
51
+ ranked = sorted(scores, key=lambda k: scores[k], reverse=self._sort_descending)
52
+ context.selected = ranked[: self._n]
53
+ return True
54
+
55
+ class Filter(Algo):
56
+ """Boolean gate using external data. Returns False to halt the algo queue.
57
+
58
+ Args:
59
+ data: Dict of extra DataFrames (e.g. VIX data).
60
+ condition: Callable receiving current-date rows, returns True/False.
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ data: dict[str, pd.DataFrame],
66
+ condition: Callable[[dict[str, pd.Series]], bool],
67
+ ) -> None:
68
+ self._data = data
69
+ self._condition = condition
70
+
71
+ def __call__(self, context: Context) -> bool:
72
+ row = {ticker: df.loc[context.date] for ticker, df in self._data.items()}
73
+ return bool(self._condition(row))
@@ -0,0 +1,372 @@
1
+ from __future__ import annotations
2
+
3
+ import calendar
4
+ from collections.abc import Callable
5
+
6
+ import pandas as pd
7
+ import pandas_market_calendars as mcal
8
+
9
+ from tiportfolio.algo import Algo, Context, Or
10
+
11
+
12
+ class Signal:
13
+ """Namespace for signal algos (when to rebalance)."""
14
+
15
+ class Schedule(Algo):
16
+ """Returns True when context.date matches the schedule rule.
17
+
18
+ Args:
19
+ day: "start", "mid", "end", or an int (1-31) for a specific day-of-month.
20
+ month: Optional — restrict to a specific month (1-12).
21
+ closest_trading_day: Snap to nearest valid trading day. Default True.
22
+ """
23
+
24
+ def __init__(
25
+ self,
26
+ day: int | str = "end",
27
+ month: int | None = None,
28
+ closest_trading_day: bool = True,
29
+ ) -> None:
30
+ self._day = day
31
+ self._month = month
32
+ self._closest_trading_day = closest_trading_day
33
+ self._nyse = mcal.get_calendar("NYSE")
34
+
35
+ def __call__(self, context: Context) -> bool:
36
+ date = context.date
37
+ if self._month is not None and date.month != self._month:
38
+ return False
39
+ if self._day == "end":
40
+ return self._is_last_trading_day_of_month(date)
41
+ if self._day == "start":
42
+ return self._matches_target_day(date, 1)
43
+ if self._day == "mid":
44
+ return self._matches_target_day(date, 15)
45
+ if isinstance(self._day, int):
46
+ return self._matches_target_day(date, self._day)
47
+ return False
48
+
49
+ def _valid_days_for_month(self, date: pd.Timestamp) -> pd.DatetimeIndex:
50
+ """Get NYSE trading days for the month containing `date`."""
51
+ start = date.replace(day=1)
52
+ end = start + pd.offsets.MonthEnd(0)
53
+ return self._nyse.valid_days(start, end)
54
+
55
+ def _matches_target_day(self, date: pd.Timestamp, target_day: int) -> bool:
56
+ """Forward search: fire on the first trading day >= target_day in the month."""
57
+ month_days = calendar.monthrange(date.year, date.month)[1]
58
+ if target_day > month_days:
59
+ return False
60
+
61
+ valid_days = self._valid_days_for_month(date)
62
+ if len(valid_days) == 0:
63
+ return False
64
+
65
+ target_date = date.replace(day=target_day).normalize()
66
+ if self._closest_trading_day:
67
+ candidates = valid_days[valid_days >= target_date]
68
+ if len(candidates) == 0:
69
+ return False
70
+ return date.normalize() == candidates[0].normalize()
71
+ else:
72
+ return date.normalize() == target_date and target_date in valid_days.normalize()
73
+
74
+ def _is_last_trading_day_of_month(self, date: pd.Timestamp) -> bool:
75
+ """Backward search: fire on the last trading day of the month."""
76
+ valid_days = self._valid_days_for_month(date)
77
+ if len(valid_days) == 0:
78
+ return False
79
+ return date.normalize() == valid_days[-1].normalize()
80
+
81
+ class Once(Algo):
82
+ """Fires True on the first call, False on all subsequent calls.
83
+
84
+ Use for buy-and-hold strategies: buy once, then hold forever.
85
+ """
86
+
87
+ def __init__(self) -> None:
88
+ self._fired = False
89
+
90
+ def __call__(self, context: Context) -> bool:
91
+ if self._fired:
92
+ return False
93
+ self._fired = True
94
+ return True
95
+
96
+ class Monthly(Algo):
97
+ """Fires on a configured day of each month. Delegates to Schedule."""
98
+
99
+ def __init__(self, day: int | str = "end", closest_trading_day: bool = True) -> None:
100
+ self._inner = Signal.Schedule(day=day, closest_trading_day=closest_trading_day)
101
+
102
+ def __call__(self, context: Context) -> bool:
103
+ return self._inner(context)
104
+
105
+ class Quarterly(Algo):
106
+ """Fires on specified months (default Jan, Apr, Jul, Oct). Delegates to Or(Schedule...).
107
+
108
+ Args:
109
+ months: Month numbers (1-12) to fire on. Default [1, 4, 7, 10].
110
+ day: Day parameter passed to each Schedule. Default "end".
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ months: list[int] | None = None,
116
+ day: int | str = "end",
117
+ ) -> None:
118
+ resolved_months = months if months is not None else [1, 4, 7, 10]
119
+ self._inner = Or(*[Signal.Schedule(month=m, day=day) for m in resolved_months])
120
+
121
+ def __call__(self, context: Context) -> bool:
122
+ return self._inner(context)
123
+
124
+ class Weekly(Algo):
125
+ """Fires once per ISO week on a configured day.
126
+
127
+ Args:
128
+ day: "start" (Monday), "mid" (Wednesday), or "end" (Friday). Default "end".
129
+ closest_trading_day: Snap to nearest valid trading day. Default True.
130
+ """
131
+
132
+ _DAY_TARGETS = {"start": 0, "mid": 2, "end": 4} # Monday=0, Wednesday=2, Friday=4
133
+
134
+ def __init__(self, day: str = "end", closest_trading_day: bool = True) -> None:
135
+ self._day = day
136
+ self._closest_trading_day = closest_trading_day
137
+ self._nyse = mcal.get_calendar("NYSE")
138
+
139
+ def __call__(self, context: Context) -> bool:
140
+ date = context.date
141
+ target_weekday = self._DAY_TARGETS.get(self._day, 4)
142
+
143
+ # Get the ISO week boundaries (Monday to Sunday)
144
+ monday = date - pd.Timedelta(days=date.weekday())
145
+ sunday = monday + pd.Timedelta(days=6)
146
+ valid_days = self._nyse.valid_days(monday, sunday)
147
+ if len(valid_days) == 0:
148
+ return False
149
+
150
+ if self._day == "end":
151
+ # Backward search: last trading day of the week
152
+ return date.normalize() == valid_days[-1].normalize()
153
+
154
+ # Forward search for "start" and "mid"
155
+ target_date = (monday + pd.Timedelta(days=target_weekday)).normalize()
156
+ if self._closest_trading_day:
157
+ candidates = valid_days[valid_days >= target_date]
158
+ if len(candidates) == 0:
159
+ return False
160
+ return date.normalize() == candidates[0].normalize()
161
+ else:
162
+ return date.normalize() == target_date and target_date in valid_days.normalize()
163
+
164
+ class Yearly(Algo):
165
+ """Fires once per year. Day resolves at year level.
166
+
167
+ Args:
168
+ day: "start" (Jan), "mid" (Jul), "end" (Dec), or int (day-of-month).
169
+ month: Override target month. Default derived from day mode.
170
+ """
171
+
172
+ _MONTH_TARGETS = {"start": 1, "mid": 7, "end": 12}
173
+
174
+ def __init__(
175
+ self,
176
+ day: int | str = "end",
177
+ month: int | None = None,
178
+ ) -> None:
179
+ if month is not None:
180
+ resolved_month = month
181
+ elif isinstance(day, str) and day in self._MONTH_TARGETS:
182
+ resolved_month = self._MONTH_TARGETS[day]
183
+ else:
184
+ resolved_month = 12
185
+ self._inner = Signal.Schedule(month=resolved_month, day=day)
186
+
187
+ def __call__(self, context: Context) -> bool:
188
+ return self._inner(context)
189
+
190
+ class EveryNPeriods(Algo):
191
+ """Fires every N-th period boundary on a configured day within the period.
192
+
193
+ Args:
194
+ n: Fire every N periods.
195
+ period: "day", "week", "month", or "year".
196
+ day: "start", "mid", "end" — which day within the period. Default "end".
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ n: int,
202
+ period: str,
203
+ day: str = "end",
204
+ ) -> None:
205
+ self._n = n
206
+ self._period = period
207
+ self._day = day
208
+ self._counter = n - 1 # fire on first eligible period
209
+ self._last_period_key: int | tuple[int, int] | None = None
210
+ self._nyse = mcal.get_calendar("NYSE")
211
+
212
+ def __call__(self, context: Context) -> bool:
213
+ date = context.date
214
+ period_key = self._get_period_key(date)
215
+
216
+ if self._period == "day":
217
+ # Every N-th trading day
218
+ self._counter += 1
219
+ if self._counter >= self._n:
220
+ self._counter = 0
221
+ return True
222
+ return False
223
+
224
+ # For week/month/year: detect period boundary
225
+ if period_key != self._last_period_key:
226
+ self._counter += 1
227
+ self._last_period_key = period_key
228
+
229
+ if self._counter >= self._n:
230
+ fired = self._is_target_day(date)
231
+ if fired:
232
+ self._counter = 0
233
+ return fired
234
+
235
+ return False
236
+
237
+ def _get_period_key(self, date: pd.Timestamp) -> int | tuple[int, int]:
238
+ if self._period == "week":
239
+ iso = date.isocalendar()
240
+ return (iso[0], iso[1]) # (year, week)
241
+ if self._period == "month":
242
+ return (date.year, date.month)
243
+ if self._period == "year":
244
+ return date.year
245
+ return 0 # "day" — not used for boundary detection
246
+
247
+ def _is_target_day(self, date: pd.Timestamp) -> bool:
248
+ """Check if date is the target day within its period."""
249
+ if self._period == "week":
250
+ weekly = Signal.Weekly(day=self._day)
251
+ return weekly(Context(
252
+ portfolio=None, prices={}, date=date, # type: ignore[arg-type]
253
+ config=None, # type: ignore[arg-type]
254
+ ))
255
+ if self._period == "month":
256
+ schedule = Signal.Schedule(day=self._day)
257
+ return schedule(Context(
258
+ portfolio=None, prices={}, date=date, # type: ignore[arg-type]
259
+ config=None, # type: ignore[arg-type]
260
+ ))
261
+ if self._period == "year":
262
+ yearly = Signal.Yearly(day=self._day)
263
+ return yearly(Context(
264
+ portfolio=None, prices={}, date=date, # type: ignore[arg-type]
265
+ config=None, # type: ignore[arg-type]
266
+ ))
267
+ return True # "day" period — always the target day
268
+
269
+ class VIX(Algo):
270
+ """Regime switching based on VIX level with hysteresis.
271
+
272
+ Writes to both context.selected and context.weights:
273
+ - VIX < low → children[0] (low-vol / risk-on)
274
+ - VIX > high → children[1] (high-vol / risk-off)
275
+ - Between thresholds → previous selection persists
276
+
277
+ Args:
278
+ high: Upper VIX threshold.
279
+ low: Lower VIX threshold.
280
+ data: Dict containing "^VIX" DataFrame with close prices.
281
+ """
282
+
283
+ def __init__(
284
+ self,
285
+ high: float,
286
+ low: float,
287
+ data: dict[str, pd.DataFrame],
288
+ ) -> None:
289
+ self._high = high
290
+ self._low = low
291
+ self._data = data
292
+ self._active: object | None = None # Portfolio, lazily initialised
293
+
294
+ def __call__(self, context: Context) -> bool:
295
+ if self._active is None:
296
+ self._active = context.portfolio.children[0]
297
+
298
+ vix_now = self._data["^VIX"].loc[context.date, "close"]
299
+ if vix_now > self._high:
300
+ self._active = context.portfolio.children[1]
301
+ elif vix_now < self._low:
302
+ self._active = context.portfolio.children[0]
303
+ # else: between thresholds — hysteresis, keep previous
304
+
305
+ context.selected = [self._active]
306
+ context.weights = {self._active.name: 1.0} # type: ignore[union-attr]
307
+ return True
308
+
309
+ class Indicator(Algo):
310
+ """Fires on technical indicator state transitions (crossovers).
311
+
312
+ The condition function receives a Series of close prices over the
313
+ lookback window and returns a boolean *state* (e.g., SMA50 > SMA200).
314
+ The algo fires True only when that state **transitions**:
315
+ - cross="up": False → True (e.g., golden cross)
316
+ - cross="down": True → False (e.g., death cross)
317
+ - cross="both": any state change
318
+
319
+ Args:
320
+ ticker: Which ticker's prices to evaluate.
321
+ condition: Receives close prices Series, returns bool state.
322
+ lookback: How far back to slice prices for the condition.
323
+ cross: Transition direction — "up", "down", or "both".
324
+ """
325
+
326
+ _VALID_CROSS = {"up", "down", "both"}
327
+
328
+ def __init__(
329
+ self,
330
+ ticker: str,
331
+ condition: Callable[[pd.Series], bool], # type: ignore[type-arg]
332
+ lookback: pd.DateOffset,
333
+ cross: str = "up",
334
+ ) -> None:
335
+ if cross not in self._VALID_CROSS:
336
+ raise ValueError(
337
+ f"cross must be one of {self._VALID_CROSS}, got '{cross}'"
338
+ )
339
+ self._ticker = ticker
340
+ self._condition = condition
341
+ self._lookback = lookback
342
+ self._cross = cross
343
+ self._prev_state: bool | None = None
344
+
345
+ def __call__(self, context: Context) -> bool:
346
+ if self._ticker not in context.prices:
347
+ return False
348
+
349
+ start = context.date - self._lookback
350
+ end = context.date
351
+ series = context.prices[self._ticker].loc[start:end, "close"]
352
+
353
+ current_state = bool(self._condition(series))
354
+
355
+ # First bar: initialise state, don't fire
356
+ if self._prev_state is None:
357
+ self._prev_state = current_state
358
+ return False
359
+
360
+ # Edge detection
361
+ prev = self._prev_state
362
+ self._prev_state = current_state
363
+
364
+ if prev == current_state:
365
+ return False
366
+
367
+ if self._cross == "up":
368
+ return not prev and current_state # False → True
369
+ if self._cross == "down":
370
+ return prev and not current_state # True → False
371
+ # "both"
372
+ return True