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.
- tiportfolio/__init__.py +24 -0
- tiportfolio/algo.py +77 -0
- tiportfolio/algos/__init__.py +6 -0
- tiportfolio/algos/rebalance.py +42 -0
- tiportfolio/algos/select.py +73 -0
- tiportfolio/algos/signal.py +372 -0
- tiportfolio/algos/weigh.py +298 -0
- tiportfolio/backtest.py +358 -0
- tiportfolio/config.py +15 -0
- tiportfolio/data.py +180 -0
- tiportfolio/helpers/README.md +11 -0
- tiportfolio/helpers/__init__.py +1 -0
- tiportfolio/helpers/cache.py +244 -0
- tiportfolio/helpers/common.py +383 -0
- tiportfolio/helpers/data.py +570 -0
- tiportfolio/helpers/log.py +473 -0
- tiportfolio/helpers/scope.py +778 -0
- tiportfolio/portfolio.py +24 -0
- tiportfolio/py.typed +0 -0
- tiportfolio/result.py +878 -0
- tiportfolio-1.0.0.dist-info/METADATA +98 -0
- tiportfolio-1.0.0.dist-info/RECORD +25 -0
- tiportfolio-1.0.0.dist-info/WHEEL +4 -0
- tiportfolio-1.0.0.dist-info/entry_points.txt +2 -0
- tiportfolio-1.0.0.dist-info/licenses/LICENSE +201 -0
tiportfolio/__init__.py
ADDED
|
@@ -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,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
|