prism-quant 1.0.3__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.
- prism_quant/__init__.py +38 -0
- prism_quant/adapters/__init__.py +3 -0
- prism_quant/adapters/data/__init__.py +4 -0
- prism_quant/adapters/data/miniqmt_gateway.py +360 -0
- prism_quant/adapters/data/yfinance_gateway.py +177 -0
- prism_quant/adapters/trade/__init__.py +5 -0
- prism_quant/adapters/trade/miniqmt_client.py +237 -0
- prism_quant/adapters/trade/miniqmt_gateway.py +121 -0
- prism_quant/adapters/trade/paper_gateway.py +31 -0
- prism_quant/backtest/__init__.py +32 -0
- prism_quant/backtest/engine.py +182 -0
- prism_quant/backtest/metrics.py +74 -0
- prism_quant/backtest/performance.py +394 -0
- prism_quant/charts/__init__.py +14 -0
- prism_quant/charts/performance.py +320 -0
- prism_quant/charts/price.py +64 -0
- prism_quant/charts/signals.py +181 -0
- prism_quant/core/__init__.py +14 -0
- prism_quant/core/base.py +20 -0
- prism_quant/core/config.py +80 -0
- prism_quant/core/logger.py +44 -0
- prism_quant/data/__init__.py +14 -0
- prism_quant/data/cleaner.py +39 -0
- prism_quant/data/fetcher.py +99 -0
- prism_quant/data/miniqmt_xtdata.py +101 -0
- prism_quant/data/source_map.py +25 -0
- prism_quant/data/storage.py +247 -0
- prism_quant/indicators/__init__.py +14 -0
- prism_quant/indicators/fundamental.py +45 -0
- prism_quant/indicators/talib_indicators.py +67 -0
- prism_quant/indicators/technical.py +251 -0
- prism_quant/live/__init__.py +28 -0
- prism_quant/live/engine.py +567 -0
- prism_quant/live/event_engine.py +19 -0
- prism_quant/live/gateway_registry.py +27 -0
- prism_quant/live/gateways.py +75 -0
- prism_quant/live/models.py +24 -0
- prism_quant/strategy/__init__.py +12 -0
- prism_quant/strategy/base.py +31 -0
- prism_quant/strategy/signals.py +190 -0
- prism_quant/trader/__init__.py +14 -0
- prism_quant/trader/engine.py +197 -0
- prism_quant/trader/order_manager.py +106 -0
- prism_quant/trader/position_manager.py +88 -0
- prism_quant-1.0.3.dist-info/METADATA +217 -0
- prism_quant-1.0.3.dist-info/RECORD +49 -0
- prism_quant-1.0.3.dist-info/WHEEL +5 -0
- prism_quant-1.0.3.dist-info/licenses/LICENSE +21 -0
- prism_quant-1.0.3.dist-info/top_level.txt +1 -0
prism_quant/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PrismQuant - A comprehensive Python quantitative finance library.
|
|
3
|
+
|
|
4
|
+
This library provides tools for strategy development, backtesting,
|
|
5
|
+
paper trading, and live trading.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
# Read version from VERSION file
|
|
12
|
+
_version_file = Path(__file__).parent.parent / "VERSION"
|
|
13
|
+
if _version_file.exists():
|
|
14
|
+
__version__ = _version_file.read_text().strip()
|
|
15
|
+
else:
|
|
16
|
+
__version__ = "0.9.1"
|
|
17
|
+
|
|
18
|
+
__author__ = "PrismQuant"
|
|
19
|
+
|
|
20
|
+
# Import core modules
|
|
21
|
+
from . import core
|
|
22
|
+
from . import data
|
|
23
|
+
from . import strategy
|
|
24
|
+
from . import backtest
|
|
25
|
+
from . import indicators
|
|
26
|
+
from . import trader
|
|
27
|
+
from . import live
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"core",
|
|
31
|
+
"data",
|
|
32
|
+
"strategy",
|
|
33
|
+
"backtest",
|
|
34
|
+
"indicators",
|
|
35
|
+
"trader",
|
|
36
|
+
"live"
|
|
37
|
+
]
|
|
38
|
+
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
miniQMT 行情(xtdata),类 MiniQmtDataGateway。
|
|
3
|
+
|
|
4
|
+
对外
|
|
5
|
+
connect 加载 xtdata,需 miniQMT 已开
|
|
6
|
+
subscribe 追加标的并 1m 暖机回放
|
|
7
|
+
start 开 daemon:poll 轮询或 push 订分笔
|
|
8
|
+
stop 停线程,push 会退订
|
|
9
|
+
get_today_ohlc 当日开高低(从快照解析)
|
|
10
|
+
get_depths 买卖盘口(从快照解析)
|
|
11
|
+
|
|
12
|
+
私有
|
|
13
|
+
_warm_up 近一日 1m 合成暖机 tick
|
|
14
|
+
_unsubscribe_push push 停时退订
|
|
15
|
+
_run_poll 按间隔拉全快照轮询
|
|
16
|
+
_run_push 订分笔并阻塞 run
|
|
17
|
+
_on_push_datas 分笔回调里组 TickData
|
|
18
|
+
_get_full_tick 封装 get_full_tick
|
|
19
|
+
_bid_ask_from_dict 快照 dict 取买一卖一
|
|
20
|
+
_ts_from_millis_or_now 行情时间转 datetime
|
|
21
|
+
"""
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from datetime import datetime, timedelta
|
|
25
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
26
|
+
|
|
27
|
+
from ...data.miniqmt_xtdata import fetch_miniqmt_bars, import_xtdata as _import_xtdata
|
|
28
|
+
from ...live.gateways import DataGateway
|
|
29
|
+
from ...live.models import TickData
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MiniQmtDataGateway(DataGateway):
|
|
33
|
+
"""poll 定时拉全快照,push 订分笔推送;xt 标的代码,Tick 含最新价及可选买卖盘。"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
interval: float = 3.0,
|
|
38
|
+
dividend_type: str = "none",
|
|
39
|
+
mode: str = "poll",
|
|
40
|
+
**kwargs: Any,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""轮询间隔秒、K 线除权类型、模式 poll 或 push。"""
|
|
43
|
+
super().__init__(**kwargs)
|
|
44
|
+
self.interval = interval
|
|
45
|
+
self.dividend_type = dividend_type
|
|
46
|
+
self.mode = (mode or "poll").strip().lower()
|
|
47
|
+
if self.mode not in ("poll", "push"):
|
|
48
|
+
raise ValueError('mode must be "poll" or "push"')
|
|
49
|
+
self._symbols: List[str] = []
|
|
50
|
+
self._running = False
|
|
51
|
+
self._thread: Optional[threading.Thread] = None
|
|
52
|
+
self._quote_seqs: List[int] = []
|
|
53
|
+
self.logger.info(f"Initialized MiniQmtDataGateway mode={self.mode} interval={self.interval}s")
|
|
54
|
+
|
|
55
|
+
def connect(self) -> bool:
|
|
56
|
+
"""加载 xtdata,本机需已启动 miniQMT。"""
|
|
57
|
+
try:
|
|
58
|
+
_import_xtdata()
|
|
59
|
+
self.logger.info("xtquant xtdata loaded (ensure miniQMT is running)")
|
|
60
|
+
return True
|
|
61
|
+
except Exception as e:
|
|
62
|
+
self.logger.error(f"miniQMT connect failed: {e}")
|
|
63
|
+
return False
|
|
64
|
+
|
|
65
|
+
def subscribe(self, symbols: List[str]) -> bool:
|
|
66
|
+
"""追加订阅;新标的用近一日 1m K 线逐根暖机回调。"""
|
|
67
|
+
new_symbols = [s for s in symbols if s not in self._symbols]
|
|
68
|
+
for symbol in new_symbols:
|
|
69
|
+
self._symbols.append(symbol)
|
|
70
|
+
self._warm_up(symbol)
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
def start(self) -> None:
|
|
74
|
+
"""起后台线程:poll 轮询快照,push 订分笔并跑 xtdata.run。"""
|
|
75
|
+
if self._running:
|
|
76
|
+
return
|
|
77
|
+
self._running = True
|
|
78
|
+
if self.mode == "poll":
|
|
79
|
+
self.logger.info("Starting miniQMT poll loop")
|
|
80
|
+
self._thread = threading.Thread(target=self._run_poll, daemon=True)
|
|
81
|
+
else:
|
|
82
|
+
self.logger.info("Starting miniQMT subscribe_quote + xtdata.run()")
|
|
83
|
+
self._thread = threading.Thread(target=self._run_push, daemon=True)
|
|
84
|
+
self._thread.start()
|
|
85
|
+
|
|
86
|
+
def stop(self) -> None:
|
|
87
|
+
"""停线程;push 会退订并调 stop(若有);join 等线程结束。"""
|
|
88
|
+
self._running = False
|
|
89
|
+
if self.mode == "push":
|
|
90
|
+
self._unsubscribe_push()
|
|
91
|
+
if self._thread:
|
|
92
|
+
self._thread.join(timeout=5.0)
|
|
93
|
+
self._thread = None
|
|
94
|
+
self.logger.info(f"Stopped MiniQmtDataGateway ({self.mode})")
|
|
95
|
+
|
|
96
|
+
def get_today_ohlc(self, symbol: str) -> Optional[Dict[str, float]]:
|
|
97
|
+
"""从快照取当日开、高、低三个 float;缺或错返回 None。"""
|
|
98
|
+
tick, err = self._get_full_tick(symbol)
|
|
99
|
+
if err or not tick:
|
|
100
|
+
self.logger.warning(f"get_today_ohlc: {err}")
|
|
101
|
+
return None
|
|
102
|
+
try:
|
|
103
|
+
o = tick.get("open")
|
|
104
|
+
h = tick.get("high") or tick.get("highPrice")
|
|
105
|
+
l_ = tick.get("low") or tick.get("lowPrice")
|
|
106
|
+
if o is None or h is None or l_ is None:
|
|
107
|
+
return None
|
|
108
|
+
return {"open": float(o), "high": float(h), "low": float(l_)}
|
|
109
|
+
except Exception as e:
|
|
110
|
+
self.logger.error(f"get_today_ohlc parse error: {e}")
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
def get_depths(self, symbol: str, levels: int = 5) -> Dict[str, List[Dict[str, float]]]:
|
|
114
|
+
"""返回买卖盘口深度(价格+委托量)。"""
|
|
115
|
+
tick, err = self._get_full_tick(symbol)
|
|
116
|
+
if err or not tick:
|
|
117
|
+
self.logger.debug(f"get_depths {symbol}: {err}")
|
|
118
|
+
return {"bids": [], "asks": []}
|
|
119
|
+
lv = max(1, min(int(levels), 10))
|
|
120
|
+
bids: List[Dict[str, float]] = []
|
|
121
|
+
asks: List[Dict[str, float]] = []
|
|
122
|
+
|
|
123
|
+
for i in range(1, lv + 1):
|
|
124
|
+
bp = self._level_value(tick, "bid", "price", i)
|
|
125
|
+
bv = self._level_value(tick, "bid", "volume", i)
|
|
126
|
+
ap = self._level_value(tick, "ask", "price", i)
|
|
127
|
+
av = self._level_value(tick, "ask", "volume", i)
|
|
128
|
+
if bp is not None:
|
|
129
|
+
bids.append({"level": float(i), "price": bp, "volume": float(bv or 0.0)})
|
|
130
|
+
if ap is not None:
|
|
131
|
+
asks.append({"level": float(i), "price": ap, "volume": float(av or 0.0)})
|
|
132
|
+
return {"bids": bids, "asks": asks}
|
|
133
|
+
|
|
134
|
+
# ---------- 私有 ----------
|
|
135
|
+
|
|
136
|
+
def _warm_up(self, symbol: str) -> None:
|
|
137
|
+
"""近一日 1m 收盘合成暖机 tick,来源标记 miniqmt_warmup。"""
|
|
138
|
+
self.logger.debug(f"Warming up {symbol} with miniQMT 1m history...")
|
|
139
|
+
try:
|
|
140
|
+
end = datetime.now()
|
|
141
|
+
start = end - timedelta(days=1)
|
|
142
|
+
data = fetch_miniqmt_bars(
|
|
143
|
+
symbol,
|
|
144
|
+
start.strftime("%Y-%m-%d"),
|
|
145
|
+
None,
|
|
146
|
+
interval="1m",
|
|
147
|
+
dividend_type=self.dividend_type,
|
|
148
|
+
)
|
|
149
|
+
if data.empty:
|
|
150
|
+
self.logger.warning(f"No warm-up data for {symbol}")
|
|
151
|
+
return
|
|
152
|
+
pushed = 0
|
|
153
|
+
for timestamp, row in data.iterrows():
|
|
154
|
+
ts = timestamp.to_pydatetime() if hasattr(timestamp, "to_pydatetime") else timestamp
|
|
155
|
+
if getattr(ts, "tzinfo", None) is not None:
|
|
156
|
+
ts = ts.replace(tzinfo=None)
|
|
157
|
+
price = float(row["Close"])
|
|
158
|
+
volume = int(row["Volume"])
|
|
159
|
+
tick = TickData(
|
|
160
|
+
symbol=symbol,
|
|
161
|
+
price=price,
|
|
162
|
+
timestamp=ts,
|
|
163
|
+
volume=volume,
|
|
164
|
+
source="miniqmt_warmup",
|
|
165
|
+
)
|
|
166
|
+
if self._tick_handler:
|
|
167
|
+
self._tick_handler(tick)
|
|
168
|
+
pushed += 1
|
|
169
|
+
self.logger.info(f"Subscribed & warmed up {symbol} ({pushed} bars)")
|
|
170
|
+
except Exception as e:
|
|
171
|
+
self.logger.warning(f"Warm-up failed for {symbol}: {e}")
|
|
172
|
+
|
|
173
|
+
def _unsubscribe_push(self) -> None:
|
|
174
|
+
"""push 停时逐个退订 quote,再调 xtdata.stop(有则调)。"""
|
|
175
|
+
if not self._quote_seqs:
|
|
176
|
+
return
|
|
177
|
+
try:
|
|
178
|
+
xd = _import_xtdata()
|
|
179
|
+
for seq in self._quote_seqs:
|
|
180
|
+
try:
|
|
181
|
+
xd.unsubscribe_quote(seq)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
self.logger.debug(f"unsubscribe_quote {seq}: {e}")
|
|
184
|
+
self._quote_seqs.clear()
|
|
185
|
+
stop_fn = getattr(xd, "stop", None)
|
|
186
|
+
if callable(stop_fn):
|
|
187
|
+
stop_fn()
|
|
188
|
+
except Exception as e:
|
|
189
|
+
self.logger.warning(f"push cleanup: {e}")
|
|
190
|
+
|
|
191
|
+
def _run_poll(self) -> None:
|
|
192
|
+
"""对每个标的拉全快照,组 TickData,调 tick 回调。"""
|
|
193
|
+
while self._running:
|
|
194
|
+
for symbol in self._symbols:
|
|
195
|
+
tick, err = self._get_full_tick(symbol)
|
|
196
|
+
if err or not tick:
|
|
197
|
+
self.logger.debug(f"tick skip {symbol}: {err}")
|
|
198
|
+
continue
|
|
199
|
+
try:
|
|
200
|
+
last = tick.get("lastPrice") or tick.get("last") or tick.get("price")
|
|
201
|
+
vol = tick.get("volume") or tick.get("lastVolume") or 0
|
|
202
|
+
if last is None:
|
|
203
|
+
continue
|
|
204
|
+
ts = self._ts_from_millis_or_now(tick.get("time"))
|
|
205
|
+
bid, ask = self._bid_ask_from_dict(tick)
|
|
206
|
+
t = TickData(
|
|
207
|
+
symbol=symbol,
|
|
208
|
+
price=float(last),
|
|
209
|
+
timestamp=ts,
|
|
210
|
+
volume=int(vol) if vol is not None else None,
|
|
211
|
+
source="miniqmt",
|
|
212
|
+
bid=bid,
|
|
213
|
+
ask=ask,
|
|
214
|
+
)
|
|
215
|
+
if self._tick_handler:
|
|
216
|
+
self._tick_handler(t)
|
|
217
|
+
except Exception as e:
|
|
218
|
+
self.logger.error(f"Error polling {symbol}: {e}")
|
|
219
|
+
time.sleep(self.interval)
|
|
220
|
+
|
|
221
|
+
def _run_push(self) -> None:
|
|
222
|
+
"""等标的有列表后逐只 subscribe_quote,最后阻塞 xd.run。"""
|
|
223
|
+
# 启动可能早于订阅,先空转等到标的非空。
|
|
224
|
+
while self._running and not self._symbols:
|
|
225
|
+
time.sleep(0.1)
|
|
226
|
+
if not self._running:
|
|
227
|
+
return
|
|
228
|
+
xd = _import_xtdata()
|
|
229
|
+
self._quote_seqs = []
|
|
230
|
+
for symbol in list(self._symbols):
|
|
231
|
+
if not self._running:
|
|
232
|
+
break
|
|
233
|
+
seq = xd.subscribe_quote(
|
|
234
|
+
symbol,
|
|
235
|
+
period="tick",
|
|
236
|
+
start_time="",
|
|
237
|
+
end_time="",
|
|
238
|
+
count=0,
|
|
239
|
+
callback=self._on_push_datas,
|
|
240
|
+
)
|
|
241
|
+
if seq < 0:
|
|
242
|
+
self.logger.error(f"subscribe_quote failed {symbol}: {seq}")
|
|
243
|
+
continue
|
|
244
|
+
self._quote_seqs.append(seq)
|
|
245
|
+
if not self._running or not self._quote_seqs:
|
|
246
|
+
return
|
|
247
|
+
try:
|
|
248
|
+
xd.run()
|
|
249
|
+
except Exception as e:
|
|
250
|
+
if self._running:
|
|
251
|
+
self.logger.error(f"xtdata.run: {e}")
|
|
252
|
+
|
|
253
|
+
def _on_push_datas(self, datas: dict) -> None:
|
|
254
|
+
"""分笔推送回调:行转 TickData 再交 tick 回调。"""
|
|
255
|
+
if not self._running:
|
|
256
|
+
return
|
|
257
|
+
for code, rows in (datas or {}).items():
|
|
258
|
+
for row in rows or []:
|
|
259
|
+
if not isinstance(row, dict):
|
|
260
|
+
continue
|
|
261
|
+
last = row.get("lastPrice") or row.get("last_price") or row.get("price")
|
|
262
|
+
if last is None:
|
|
263
|
+
continue
|
|
264
|
+
vol = row.get("volume") or row.get("lastVolume")
|
|
265
|
+
ts = self._ts_from_millis_or_now(row.get("time"))
|
|
266
|
+
bid, ask = self._bid_ask_from_dict(row)
|
|
267
|
+
t = TickData(
|
|
268
|
+
symbol=code,
|
|
269
|
+
price=float(last),
|
|
270
|
+
timestamp=ts,
|
|
271
|
+
volume=int(vol) if vol is not None else None,
|
|
272
|
+
source="miniqmt_push",
|
|
273
|
+
bid=bid,
|
|
274
|
+
ask=ask,
|
|
275
|
+
)
|
|
276
|
+
if self._tick_handler:
|
|
277
|
+
self._tick_handler(t)
|
|
278
|
+
|
|
279
|
+
def _get_full_tick(self, symbol: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
280
|
+
"""调 get_full_tick;成功返回快照和 None,失败返回 None 和错误说明。"""
|
|
281
|
+
try:
|
|
282
|
+
xtdata = _import_xtdata()
|
|
283
|
+
data = xtdata.get_full_tick([symbol])
|
|
284
|
+
if not data or symbol not in data:
|
|
285
|
+
return None, f"{symbol} 无快照"
|
|
286
|
+
return data[symbol], None
|
|
287
|
+
except Exception as e:
|
|
288
|
+
return None, str(e)
|
|
289
|
+
|
|
290
|
+
@staticmethod
|
|
291
|
+
def _bid_ask_from_dict(d: Dict[str, Any]) -> Tuple[Optional[float], Optional[float]]:
|
|
292
|
+
"""从行情 dict 取买一卖一;若买价大于卖价则对调一次。"""
|
|
293
|
+
def _to_f(v: Any) -> Optional[float]:
|
|
294
|
+
if v is None:
|
|
295
|
+
return None
|
|
296
|
+
if isinstance(v, (list, tuple)) and len(v) > 0:
|
|
297
|
+
v = v[0]
|
|
298
|
+
try:
|
|
299
|
+
return float(v)
|
|
300
|
+
except (TypeError, ValueError):
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
bid = d.get("bid1") or d.get("bidPrice") or d.get("bid") or d.get("bidPx")
|
|
304
|
+
ask = d.get("ask1") or d.get("askPrice") or d.get("ask") or d.get("askPx")
|
|
305
|
+
b, a = _to_f(bid), _to_f(ask)
|
|
306
|
+
if b is not None and a is not None and b > a:
|
|
307
|
+
return a, b
|
|
308
|
+
return b, a
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
def _level_value(cls, d: Dict[str, Any], side: str, kind: str, idx: int) -> Optional[float]:
|
|
312
|
+
"""取指定档位字段,兼容数组字段和逐档字段。"""
|
|
313
|
+
if side == "bid":
|
|
314
|
+
arr_keys = ["bidPrice", "bid", "bidPx"] if kind == "price" else ["bidVol", "bidVolume", "bidQty"]
|
|
315
|
+
scalar_keys = (
|
|
316
|
+
[f"bid{idx}", f"bidPrice{idx}", f"bidPx{idx}"]
|
|
317
|
+
if kind == "price"
|
|
318
|
+
else [f"bidVol{idx}", f"bidVolume{idx}", f"bidQty{idx}"]
|
|
319
|
+
)
|
|
320
|
+
else:
|
|
321
|
+
arr_keys = ["askPrice", "ask", "askPx"] if kind == "price" else ["askVol", "askVolume", "askQty"]
|
|
322
|
+
scalar_keys = (
|
|
323
|
+
[f"ask{idx}", f"askPrice{idx}", f"askPx{idx}"]
|
|
324
|
+
if kind == "price"
|
|
325
|
+
else [f"askVol{idx}", f"askVolume{idx}", f"askQty{idx}"]
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
for key in arr_keys:
|
|
329
|
+
arr = d.get(key)
|
|
330
|
+
if isinstance(arr, (list, tuple)) and len(arr) >= idx:
|
|
331
|
+
return cls._to_float(arr[idx - 1])
|
|
332
|
+
for key in scalar_keys:
|
|
333
|
+
if key in d:
|
|
334
|
+
return cls._to_float(d.get(key))
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
@staticmethod
|
|
338
|
+
def _to_float(v: Any) -> Optional[float]:
|
|
339
|
+
"""将任意值转为 float,失败返回 None。"""
|
|
340
|
+
if v is None:
|
|
341
|
+
return None
|
|
342
|
+
if isinstance(v, (list, tuple)) and len(v) > 0:
|
|
343
|
+
v = v[0]
|
|
344
|
+
try:
|
|
345
|
+
return float(v)
|
|
346
|
+
except (TypeError, ValueError):
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
@staticmethod
|
|
350
|
+
def _ts_from_millis_or_now(raw: Any) -> datetime:
|
|
351
|
+
"""时间戳毫秒太长则按毫秒除;坏了或没有就用本机当前时间。"""
|
|
352
|
+
if raw is None:
|
|
353
|
+
return datetime.now().replace(tzinfo=None)
|
|
354
|
+
try:
|
|
355
|
+
n = int(raw)
|
|
356
|
+
if n > 10**12:
|
|
357
|
+
n = n // 1000
|
|
358
|
+
return datetime.fromtimestamp(n)
|
|
359
|
+
except (TypeError, ValueError, OSError):
|
|
360
|
+
return datetime.now().replace(tzinfo=None)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
yfinance 行情,类 YFinanceDataGateway。
|
|
3
|
+
|
|
4
|
+
对外
|
|
5
|
+
connect 探测网络与 yfinance 可用性
|
|
6
|
+
subscribe 追加标的并 1m 暖机回放
|
|
7
|
+
start 开 daemon 线程轮询 fast_info
|
|
8
|
+
stop 停线程
|
|
9
|
+
get_today_ohlc 当日开高低(fast_info)
|
|
10
|
+
get_depths 合成盘口(价由 last_price 铺档,量随机且尺度参考 last_volume)
|
|
11
|
+
|
|
12
|
+
私有
|
|
13
|
+
_warm_up 近一日 1m K 线逐根暖机 tick
|
|
14
|
+
_run 主循环:按 interval 拉 last_price / last_volume
|
|
15
|
+
"""
|
|
16
|
+
import random
|
|
17
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
import yfinance as yf
|
|
23
|
+
|
|
24
|
+
from ...live.gateways import DataGateway
|
|
25
|
+
from ...live.models import TickData
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class YFinanceDataGateway(DataGateway):
|
|
29
|
+
"""轮询 yfinance fast_info;时间戳为 naive UTC;无真实 Level2。"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, interval: float = 60.0, **kwargs: Any) -> None:
|
|
32
|
+
"""轮询间隔(秒)。"""
|
|
33
|
+
super().__init__(**kwargs)
|
|
34
|
+
self.interval = interval
|
|
35
|
+
self._symbols: List[str] = []
|
|
36
|
+
self._running = False
|
|
37
|
+
self._thread: Optional[threading.Thread] = None
|
|
38
|
+
self.logger.info(f"Initialized YFinanceDataGateway with interval: {self.interval}s")
|
|
39
|
+
|
|
40
|
+
def connect(self) -> bool:
|
|
41
|
+
"""验证网络与 yfinance 可访问。"""
|
|
42
|
+
try:
|
|
43
|
+
yf.Ticker("AAPL").fast_info
|
|
44
|
+
self.logger.info("Connected to yfinance")
|
|
45
|
+
return True
|
|
46
|
+
except Exception as e:
|
|
47
|
+
self.logger.error(f"Failed to connect: {e}")
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def subscribe(self, symbols: List[str]) -> bool:
|
|
51
|
+
"""追加订阅;新标的拉近一日 1m 数据暖机并逐根回调。"""
|
|
52
|
+
new_symbols = [s for s in symbols if s not in self._symbols]
|
|
53
|
+
for symbol in new_symbols:
|
|
54
|
+
self._symbols.append(symbol)
|
|
55
|
+
self._warm_up(symbol)
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
def start(self) -> None:
|
|
59
|
+
"""启动轮询线程。"""
|
|
60
|
+
if self._running:
|
|
61
|
+
return
|
|
62
|
+
self.logger.info("Starting yfinance polling")
|
|
63
|
+
self._running = True
|
|
64
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
65
|
+
self._thread.start()
|
|
66
|
+
|
|
67
|
+
def stop(self) -> None:
|
|
68
|
+
"""停止轮询线程。"""
|
|
69
|
+
self._running = False
|
|
70
|
+
if self._thread:
|
|
71
|
+
self._thread.join(timeout=2)
|
|
72
|
+
self.logger.info("Stopped yfinance polling")
|
|
73
|
+
|
|
74
|
+
def get_today_ohlc(self, symbol: str) -> Optional[Dict[str, float]]:
|
|
75
|
+
"""从 fast_info 取当日开、高、低;缺字段返回 None。"""
|
|
76
|
+
try:
|
|
77
|
+
ticker = yf.Ticker(symbol)
|
|
78
|
+
info = ticker.fast_info
|
|
79
|
+
open_price = info.open
|
|
80
|
+
high_price = info.day_high
|
|
81
|
+
low_price = info.day_low
|
|
82
|
+
if open_price is None or high_price is None or low_price is None:
|
|
83
|
+
self.logger.warning(
|
|
84
|
+
f"Incomplete OHLC data for {symbol}: open={open_price}, high={high_price}, low={low_price}"
|
|
85
|
+
)
|
|
86
|
+
return None
|
|
87
|
+
return {"open": open_price, "high": high_price, "low": low_price}
|
|
88
|
+
except Exception as e:
|
|
89
|
+
self.logger.error(f"Failed to get today's OHLC for {symbol}: {e}")
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
def get_depths(self, symbol: str, levels: int = 5) -> Dict[str, List[Dict[str, float]]]:
|
|
93
|
+
"""
|
|
94
|
+
合成盘口(非真实 L2):价由 fast_info.last_price 按相对价差铺档;
|
|
95
|
+
各档成交量在 [~5%·last_volume, last_volume] 内独立随机(无 last_volume 时用默认尺度)。
|
|
96
|
+
"""
|
|
97
|
+
levels = max(1, min(int(levels), 10))
|
|
98
|
+
try:
|
|
99
|
+
info = yf.Ticker(symbol).fast_info
|
|
100
|
+
last = info.last_price
|
|
101
|
+
if last is None:
|
|
102
|
+
return {"bids": [], "asks": []}
|
|
103
|
+
last_f = float(last)
|
|
104
|
+
if last_f <= 0:
|
|
105
|
+
return {"bids": [], "asks": []}
|
|
106
|
+
vol_raw = info.last_volume
|
|
107
|
+
base = int(vol_raw) if vol_raw is not None and int(vol_raw) > 0 else 1000
|
|
108
|
+
except Exception as e:
|
|
109
|
+
self.logger.debug(f"get_depths {symbol}: {e}")
|
|
110
|
+
return {"bids": [], "asks": []}
|
|
111
|
+
|
|
112
|
+
lo = max(1, base // 20)
|
|
113
|
+
hi = max(lo, base)
|
|
114
|
+
step = max(last_f * 1e-4, 0.01)
|
|
115
|
+
bids: List[Dict[str, float]] = []
|
|
116
|
+
asks: List[Dict[str, float]] = []
|
|
117
|
+
for i in range(levels):
|
|
118
|
+
lv = float(i + 1)
|
|
119
|
+
vb = float(random.randint(lo, hi))
|
|
120
|
+
va = float(random.randint(lo, hi))
|
|
121
|
+
bids.append({"level": lv, "price": round(last_f - lv * step, 6), "volume": vb})
|
|
122
|
+
asks.append({"level": lv, "price": round(last_f + lv * step, 6), "volume": va})
|
|
123
|
+
return {"bids": bids, "asks": asks}
|
|
124
|
+
|
|
125
|
+
# ---------- 私有 ----------
|
|
126
|
+
|
|
127
|
+
def _warm_up(self, symbol: str) -> None:
|
|
128
|
+
"""下载近一日 1m K 线,逐根合成暖机 tick(source=yf_warmup)。"""
|
|
129
|
+
self.logger.debug(f"Warming up {symbol} with intraday history...")
|
|
130
|
+
try:
|
|
131
|
+
data = yf.download(symbol, period="1d", interval="1m", progress=False)
|
|
132
|
+
if data.empty:
|
|
133
|
+
self.logger.warning(f"No warm-up data for {symbol}")
|
|
134
|
+
return
|
|
135
|
+
pushed_count = 0
|
|
136
|
+
for timestamp, row in data.iterrows():
|
|
137
|
+
local_ts = timestamp.to_pydatetime().replace(tzinfo=None)
|
|
138
|
+
price = float(row["Close"])
|
|
139
|
+
volume = int(row["Volume"])
|
|
140
|
+
tick = TickData(
|
|
141
|
+
symbol=symbol,
|
|
142
|
+
price=price,
|
|
143
|
+
timestamp=local_ts,
|
|
144
|
+
volume=volume,
|
|
145
|
+
source="yf_warmup",
|
|
146
|
+
)
|
|
147
|
+
if self._tick_handler:
|
|
148
|
+
self._tick_handler(tick)
|
|
149
|
+
pushed_count += 1
|
|
150
|
+
self.logger.info(f"Subscribed & Warmed up {symbol} ({pushed_count} bars)")
|
|
151
|
+
except Exception as e:
|
|
152
|
+
self.logger.warning(f"Warm-up failed for {symbol}: {e}")
|
|
153
|
+
|
|
154
|
+
def _run(self) -> None:
|
|
155
|
+
"""轮询各标的 fast_info,组 TickData 回调。"""
|
|
156
|
+
while self._running:
|
|
157
|
+
for symbol in self._symbols:
|
|
158
|
+
try:
|
|
159
|
+
ticker = yf.Ticker(symbol)
|
|
160
|
+
info = ticker.fast_info
|
|
161
|
+
price = info.last_price
|
|
162
|
+
volume = info.last_volume
|
|
163
|
+
if price is None or volume is None:
|
|
164
|
+
continue
|
|
165
|
+
tick = TickData(
|
|
166
|
+
symbol=symbol,
|
|
167
|
+
price=float(price),
|
|
168
|
+
timestamp=datetime.utcnow(),
|
|
169
|
+
volume=int(volume),
|
|
170
|
+
source="yfinance",
|
|
171
|
+
)
|
|
172
|
+
if self._tick_handler:
|
|
173
|
+
self._tick_handler(tick)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
self.logger.error(f"Error fetching data for {symbol}: {str(e)}")
|
|
176
|
+
continue
|
|
177
|
+
time.sleep(self.interval)
|