tualpha 0.5.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.
- tualpha/__init__.py +76 -0
- tualpha/api.py +127 -0
- tualpha/assets.py +197 -0
- tualpha/broker.py +317 -0
- tualpha/bundle.py +1939 -0
- tualpha/calendar.py +103 -0
- tualpha/cli.py +108 -0
- tualpha/config.py +87 -0
- tualpha/costs.py +139 -0
- tualpha/data.py +881 -0
- tualpha/engine.py +548 -0
- tualpha/exceptions.py +21 -0
- tualpha/metrics.py +114 -0
- tualpha/models.py +268 -0
- tualpha/reporting.py +520 -0
- tualpha/result.py +67 -0
- tualpha/rules.py +106 -0
- tualpha/tushare_fields.py +131 -0
- tualpha/updater.py +923 -0
- tualpha-0.5.0.dist-info/METADATA +297 -0
- tualpha-0.5.0.dist-info/RECORD +23 -0
- tualpha-0.5.0.dist-info/WHEEL +4 -0
- tualpha-0.5.0.dist-info/entry_points.txt +3 -0
tualpha/__init__.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""TuAlpha: a daily A-share stock and ETF backtesting framework."""
|
|
2
|
+
|
|
3
|
+
from .api import (
|
|
4
|
+
cancel_order,
|
|
5
|
+
get_open_orders,
|
|
6
|
+
order,
|
|
7
|
+
order_percent,
|
|
8
|
+
order_target,
|
|
9
|
+
order_target_percent,
|
|
10
|
+
order_target_value,
|
|
11
|
+
order_value,
|
|
12
|
+
record,
|
|
13
|
+
set_commission,
|
|
14
|
+
symbol,
|
|
15
|
+
)
|
|
16
|
+
from .assets import Asset, AssetFinder, AssetType, Board
|
|
17
|
+
from .config import AdjustmentMode, BacktestConfig, ExecutionTime, PlotlyJsMode
|
|
18
|
+
from .costs import ChinaFeeModel, RateSchedule
|
|
19
|
+
from .engine import AlgorithmContext, TradingAlgorithm, run_algorithm
|
|
20
|
+
from .exceptions import (
|
|
21
|
+
ConfigurationError,
|
|
22
|
+
DataError,
|
|
23
|
+
NoActiveAlgorithm,
|
|
24
|
+
SymbolNotFound,
|
|
25
|
+
TualphaError,
|
|
26
|
+
)
|
|
27
|
+
from .models import Order, OrderStatus, Portfolio, RejectReason, Transaction
|
|
28
|
+
from .result import BacktestResult
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"AdjustmentMode",
|
|
32
|
+
"AlgorithmContext",
|
|
33
|
+
"Asset",
|
|
34
|
+
"AssetFinder",
|
|
35
|
+
"AssetType",
|
|
36
|
+
"BacktestConfig",
|
|
37
|
+
"BacktestResult",
|
|
38
|
+
"Board",
|
|
39
|
+
"ChinaFeeModel",
|
|
40
|
+
"ConfigurationError",
|
|
41
|
+
"DataError",
|
|
42
|
+
"ExecutionTime",
|
|
43
|
+
"NoActiveAlgorithm",
|
|
44
|
+
"Order",
|
|
45
|
+
"OrderStatus",
|
|
46
|
+
"PlotlyJsMode",
|
|
47
|
+
"Portfolio",
|
|
48
|
+
"RateSchedule",
|
|
49
|
+
"RejectReason",
|
|
50
|
+
"SymbolNotFound",
|
|
51
|
+
"TradingAlgorithm",
|
|
52
|
+
"Transaction",
|
|
53
|
+
"TualphaError",
|
|
54
|
+
"cancel_order",
|
|
55
|
+
"get_open_orders",
|
|
56
|
+
"order",
|
|
57
|
+
"order_percent",
|
|
58
|
+
"order_target",
|
|
59
|
+
"order_target_percent",
|
|
60
|
+
"order_target_value",
|
|
61
|
+
"order_value",
|
|
62
|
+
"record",
|
|
63
|
+
"run_algorithm",
|
|
64
|
+
"set_commission",
|
|
65
|
+
"symbol",
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
__version__ = "0.5.0"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def main() -> None:
|
|
72
|
+
"""Backward-compatible console entry point."""
|
|
73
|
+
|
|
74
|
+
from .cli import main as cli_main
|
|
75
|
+
|
|
76
|
+
cli_main()
|
tualpha/api.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Zipline-style public functions bound to the active algorithm callback."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from typing import Any, Protocol
|
|
9
|
+
|
|
10
|
+
from .assets import Asset
|
|
11
|
+
from .costs import ChinaFeeModel
|
|
12
|
+
from .exceptions import NoActiveAlgorithm
|
|
13
|
+
from .models import Order
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class _AlgorithmAPI(Protocol):
|
|
17
|
+
def resolve_asset(self, value: Asset | str) -> Asset: ...
|
|
18
|
+
|
|
19
|
+
def submit_order(self, asset: Asset | str, amount: float) -> Order: ...
|
|
20
|
+
|
|
21
|
+
def submit_order_value(self, asset: Asset | str, value: float) -> Order | None: ...
|
|
22
|
+
|
|
23
|
+
def submit_order_target(
|
|
24
|
+
self, asset: Asset | str, target: float
|
|
25
|
+
) -> Order | None: ...
|
|
26
|
+
|
|
27
|
+
def submit_order_target_value(
|
|
28
|
+
self, asset: Asset | str, target: float
|
|
29
|
+
) -> Order | None: ...
|
|
30
|
+
|
|
31
|
+
def cancel_order(self, order: Order) -> None: ...
|
|
32
|
+
|
|
33
|
+
def get_open_orders(self, asset: Asset | str | None = None) -> Any: ...
|
|
34
|
+
|
|
35
|
+
def record(self, values: dict[str, Any]) -> None: ...
|
|
36
|
+
|
|
37
|
+
def set_commission(self, model: ChinaFeeModel) -> None: ...
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def portfolio_value(self) -> float: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_ACTIVE_ALGORITHM: ContextVar[_AlgorithmAPI | None] = ContextVar(
|
|
44
|
+
"tualpha_active_algorithm", default=None
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _active() -> _AlgorithmAPI:
|
|
49
|
+
algorithm = _ACTIVE_ALGORITHM.get()
|
|
50
|
+
if algorithm is None:
|
|
51
|
+
raise NoActiveAlgorithm(
|
|
52
|
+
"this API may only be called from initialize, handle_data, or analyze"
|
|
53
|
+
)
|
|
54
|
+
return algorithm
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def bind_algorithm(algorithm: _AlgorithmAPI) -> Iterator[None]:
|
|
59
|
+
token = _ACTIVE_ALGORITHM.set(algorithm)
|
|
60
|
+
try:
|
|
61
|
+
yield
|
|
62
|
+
finally:
|
|
63
|
+
_ACTIVE_ALGORITHM.reset(token)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def symbol(code: str) -> Asset:
|
|
67
|
+
"""Resolve a stock or ETF by Tushare code or unique six-digit symbol."""
|
|
68
|
+
|
|
69
|
+
return _active().resolve_asset(code)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def order(asset: Asset | str, amount: float) -> Order:
|
|
73
|
+
"""Submit a signed share/ETF-unit market order for the next session."""
|
|
74
|
+
|
|
75
|
+
return _active().submit_order(asset, amount)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def order_value(asset: Asset | str, value: float) -> Order | None:
|
|
79
|
+
"""Trade approximately a signed CNY value, rounded to a valid lot."""
|
|
80
|
+
|
|
81
|
+
return _active().submit_order_value(asset, value)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def order_target(asset: Asset | str, target: float) -> Order | None:
|
|
85
|
+
"""Move a position toward a target quantity."""
|
|
86
|
+
|
|
87
|
+
return _active().submit_order_target(asset, target)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def order_target_value(asset: Asset | str, target: float) -> Order | None:
|
|
91
|
+
"""Move a position toward a target raw market value."""
|
|
92
|
+
|
|
93
|
+
return _active().submit_order_target_value(asset, target)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def order_percent(asset: Asset | str, percent: float) -> Order | None:
|
|
97
|
+
"""Trade a signed fraction of current portfolio value."""
|
|
98
|
+
|
|
99
|
+
return order_value(asset, _active().portfolio_value * percent)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def order_target_percent(asset: Asset | str, target: float) -> Order | None:
|
|
103
|
+
"""Move a position toward a fraction of current portfolio value."""
|
|
104
|
+
|
|
105
|
+
if target < 0:
|
|
106
|
+
raise ValueError("negative targets would create a short position")
|
|
107
|
+
return order_target_value(asset, _active().portfolio_value * target)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cancel_order(order_to_cancel: Order) -> None:
|
|
111
|
+
_active().cancel_order(order_to_cancel)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_open_orders(asset: Asset | str | None = None) -> Any:
|
|
115
|
+
return _active().get_open_orders(asset)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def record(**values: Any) -> None:
|
|
119
|
+
"""Attach custom scalar values to the current daily performance row."""
|
|
120
|
+
|
|
121
|
+
_active().record(values)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def set_commission(model: ChinaFeeModel) -> None:
|
|
125
|
+
"""Replace the fee model, normally during initialize."""
|
|
126
|
+
|
|
127
|
+
_active().set_commission(model)
|
tualpha/assets.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Stock and ETF assets loaded from the official Bundle asset database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
from collections.abc import Iterator
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
from zipline.assets import ASSET_DB_VERSION
|
|
14
|
+
|
|
15
|
+
from .bundle import (
|
|
16
|
+
BUNDLE_NAME,
|
|
17
|
+
acquire_bundle_read_lock,
|
|
18
|
+
latest_bundle_path,
|
|
19
|
+
release_bundle_read_lock,
|
|
20
|
+
)
|
|
21
|
+
from .config import normalize_session
|
|
22
|
+
from .exceptions import DataError, SymbolNotFound
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AssetType(StrEnum):
|
|
26
|
+
STOCK = "stock"
|
|
27
|
+
ETF = "etf"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Board(StrEnum):
|
|
31
|
+
MAIN = "main"
|
|
32
|
+
CHINEXT = "chinext"
|
|
33
|
+
STAR = "star"
|
|
34
|
+
BSE = "bse"
|
|
35
|
+
ETF = "etf"
|
|
36
|
+
UNKNOWN = "unknown"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
40
|
+
class Asset:
|
|
41
|
+
"""A tradable A-share stock or exchange-traded fund."""
|
|
42
|
+
|
|
43
|
+
sid: int
|
|
44
|
+
ts_code: str
|
|
45
|
+
symbol: str
|
|
46
|
+
name: str
|
|
47
|
+
asset_type: AssetType
|
|
48
|
+
exchange: str
|
|
49
|
+
board: Board
|
|
50
|
+
list_date: pd.Timestamp | None = None
|
|
51
|
+
delist_date: pd.Timestamp | None = None
|
|
52
|
+
price_tick: float = 0.01
|
|
53
|
+
settlement_days: int = 1
|
|
54
|
+
|
|
55
|
+
def is_alive_on(self, session: str | pd.Timestamp) -> bool:
|
|
56
|
+
date = normalize_session(session)
|
|
57
|
+
if self.list_date is not None and date < self.list_date:
|
|
58
|
+
return False
|
|
59
|
+
return self.delist_date is None or date <= self.delist_date
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def is_stock(self) -> bool:
|
|
63
|
+
return self.asset_type is AssetType.STOCK
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def is_etf(self) -> bool:
|
|
67
|
+
return self.asset_type is AssetType.ETF
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
return self.ts_code
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AssetFinder:
|
|
74
|
+
"""Resolve stable assets from Zipline's asset SQLite database."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, bundle_root: str | Path, bundle_name: str = BUNDLE_NAME) -> None:
|
|
77
|
+
self.bundle_root = Path(bundle_root).expanduser()
|
|
78
|
+
self.bundle_name = bundle_name
|
|
79
|
+
lock_key, _ = acquire_bundle_read_lock(self.bundle_root, bundle_name)
|
|
80
|
+
try:
|
|
81
|
+
self.bundle_path = latest_bundle_path(self.bundle_root, bundle_name)
|
|
82
|
+
manifest = json.loads(
|
|
83
|
+
(self.bundle_path / "manifest.json").read_text(encoding="utf-8")
|
|
84
|
+
)
|
|
85
|
+
self.bundle_generation = str(manifest["generated_at"])
|
|
86
|
+
asset_db = self.bundle_path / f"assets-{ASSET_DB_VERSION}.sqlite"
|
|
87
|
+
if not asset_db.is_file():
|
|
88
|
+
raise DataError(f"bundle asset database does not exist: {asset_db}")
|
|
89
|
+
uri = f"file:{asset_db.resolve().as_posix()}?mode=ro"
|
|
90
|
+
connection = sqlite3.connect(uri, uri=True)
|
|
91
|
+
try:
|
|
92
|
+
rows = connection.execute(
|
|
93
|
+
"""
|
|
94
|
+
WITH attributes AS (
|
|
95
|
+
SELECT sid,
|
|
96
|
+
max(CASE WHEN field = 'asset_type' THEN value END)
|
|
97
|
+
AS asset_type,
|
|
98
|
+
max(CASE WHEN field = 'board' THEN value END) AS board,
|
|
99
|
+
max(CASE WHEN field = 'price_tick' THEN value END)
|
|
100
|
+
AS price_tick
|
|
101
|
+
FROM equity_supplementary_mappings
|
|
102
|
+
GROUP BY sid
|
|
103
|
+
)
|
|
104
|
+
SELECT e.sid, m.symbol, e.asset_name, e.start_date, e.end_date,
|
|
105
|
+
e.exchange, a.asset_type, a.board, a.price_tick
|
|
106
|
+
FROM equities e
|
|
107
|
+
JOIN equity_symbol_mappings m ON m.sid = e.sid
|
|
108
|
+
LEFT JOIN attributes a ON a.sid = e.sid
|
|
109
|
+
ORDER BY e.sid, m.end_date DESC
|
|
110
|
+
"""
|
|
111
|
+
).fetchall()
|
|
112
|
+
finally:
|
|
113
|
+
connection.close()
|
|
114
|
+
finally:
|
|
115
|
+
release_bundle_read_lock(lock_key)
|
|
116
|
+
if not rows:
|
|
117
|
+
raise DataError(f"bundle contains no stock or ETF assets: {asset_db}")
|
|
118
|
+
|
|
119
|
+
assets = []
|
|
120
|
+
seen: set[int] = set()
|
|
121
|
+
for row in rows:
|
|
122
|
+
sid = int(row[0])
|
|
123
|
+
if sid in seen:
|
|
124
|
+
continue
|
|
125
|
+
seen.add(sid)
|
|
126
|
+
try:
|
|
127
|
+
asset_type = AssetType(str(row[6]))
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
raise DataError(f"unsupported bundled asset type: {row[6]}") from exc
|
|
130
|
+
try:
|
|
131
|
+
board = Board(str(row[7]))
|
|
132
|
+
except ValueError:
|
|
133
|
+
board = Board.UNKNOWN
|
|
134
|
+
code = str(row[1]).upper()
|
|
135
|
+
assets.append(
|
|
136
|
+
Asset(
|
|
137
|
+
sid=sid,
|
|
138
|
+
ts_code=code,
|
|
139
|
+
symbol=code.split(".")[0],
|
|
140
|
+
name=str(row[2] or ""),
|
|
141
|
+
asset_type=asset_type,
|
|
142
|
+
exchange=str(row[5]),
|
|
143
|
+
board=board,
|
|
144
|
+
list_date=pd.Timestamp(int(row[3]), unit="ns").normalize(),
|
|
145
|
+
delist_date=pd.Timestamp(int(row[4]), unit="ns").normalize(),
|
|
146
|
+
price_tick=float(row[8]),
|
|
147
|
+
settlement_days=1,
|
|
148
|
+
)
|
|
149
|
+
)
|
|
150
|
+
self._assets = tuple(assets)
|
|
151
|
+
self._by_sid = {asset.sid: asset for asset in self._assets}
|
|
152
|
+
self._by_code = {asset.ts_code: asset for asset in self._assets}
|
|
153
|
+
self._by_symbol: dict[str, list[Asset]] = {}
|
|
154
|
+
for asset in self._assets:
|
|
155
|
+
self._by_symbol.setdefault(asset.symbol, []).append(asset)
|
|
156
|
+
|
|
157
|
+
def retrieve_asset(
|
|
158
|
+
self,
|
|
159
|
+
code: int | str,
|
|
160
|
+
as_of_date: str | pd.Timestamp | None = None,
|
|
161
|
+
) -> Asset:
|
|
162
|
+
"""Resolve a sid, Tushare code, or unambiguous six-digit symbol."""
|
|
163
|
+
|
|
164
|
+
if isinstance(code, int):
|
|
165
|
+
candidates = [self._by_sid[code]] if code in self._by_sid else []
|
|
166
|
+
else:
|
|
167
|
+
key = code.upper().strip()
|
|
168
|
+
candidates = (
|
|
169
|
+
[self._by_code[key]]
|
|
170
|
+
if key in self._by_code
|
|
171
|
+
else self._by_symbol.get(key, [])
|
|
172
|
+
)
|
|
173
|
+
if as_of_date is not None:
|
|
174
|
+
candidates = [
|
|
175
|
+
asset for asset in candidates if asset.is_alive_on(as_of_date)
|
|
176
|
+
]
|
|
177
|
+
if len(candidates) != 1:
|
|
178
|
+
suffix = (
|
|
179
|
+
f" as of {normalize_session(as_of_date).date()}"
|
|
180
|
+
if as_of_date is not None
|
|
181
|
+
else ""
|
|
182
|
+
)
|
|
183
|
+
raise SymbolNotFound(
|
|
184
|
+
f"unable to resolve unique stock/ETF symbol {code!r}{suffix}"
|
|
185
|
+
)
|
|
186
|
+
return candidates[0]
|
|
187
|
+
|
|
188
|
+
def assets(self, asset_type: AssetType | None = None) -> tuple[Asset, ...]:
|
|
189
|
+
if asset_type is None:
|
|
190
|
+
return self._assets
|
|
191
|
+
return tuple(asset for asset in self._assets if asset.asset_type is asset_type)
|
|
192
|
+
|
|
193
|
+
def __iter__(self) -> Iterator[Asset]:
|
|
194
|
+
return iter(self._assets)
|
|
195
|
+
|
|
196
|
+
def __len__(self) -> int:
|
|
197
|
+
return len(self._assets)
|