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 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)