deltafq 0.4.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.
- deltafq/__init__.py +29 -0
- deltafq/backtest/__init__.py +32 -0
- deltafq/backtest/engine.py +145 -0
- deltafq/backtest/metrics.py +74 -0
- deltafq/backtest/performance.py +350 -0
- deltafq/charts/__init__.py +14 -0
- deltafq/charts/performance.py +319 -0
- deltafq/charts/price.py +64 -0
- deltafq/charts/signals.py +181 -0
- deltafq/core/__init__.py +18 -0
- deltafq/core/base.py +21 -0
- deltafq/core/config.py +62 -0
- deltafq/core/exceptions.py +34 -0
- deltafq/core/logger.py +44 -0
- deltafq/data/__init__.py +16 -0
- deltafq/data/cleaner.py +39 -0
- deltafq/data/fetcher.py +58 -0
- deltafq/data/storage.py +264 -0
- deltafq/data/validator.py +51 -0
- deltafq/indicators/__init__.py +14 -0
- deltafq/indicators/fundamental.py +28 -0
- deltafq/indicators/talib_indicators.py +67 -0
- deltafq/indicators/technical.py +251 -0
- deltafq/live/__init__.py +16 -0
- deltafq/live/connection.py +235 -0
- deltafq/live/data_feed.py +158 -0
- deltafq/live/monitoring.py +191 -0
- deltafq/live/risk_control.py +192 -0
- deltafq/strategy/__init__.py +12 -0
- deltafq/strategy/base.py +34 -0
- deltafq/strategy/signals.py +193 -0
- deltafq/trader/__init__.py +16 -0
- deltafq/trader/broker.py +119 -0
- deltafq/trader/engine.py +174 -0
- deltafq/trader/order_manager.py +110 -0
- deltafq/trader/position_manager.py +92 -0
- deltafq-0.4.0.dist-info/METADATA +115 -0
- deltafq-0.4.0.dist-info/RECORD +42 -0
- deltafq-0.4.0.dist-info/WHEEL +5 -0
- deltafq-0.4.0.dist-info/entry_points.txt +2 -0
- deltafq-0.4.0.dist-info/licenses/LICENSE +21 -0
- deltafq-0.4.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Performance visualization utilities."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import matplotlib.dates as mdates
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from ..core.base import BaseComponent
|
|
11
|
+
|
|
12
|
+
try: # pragma: no cover - optional dependency
|
|
13
|
+
from scipy.stats import gaussian_kde
|
|
14
|
+
except ImportError: # pragma: no cover
|
|
15
|
+
gaussian_kde = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PerformanceChart(BaseComponent):
|
|
19
|
+
"""Visualize backtest performance with stacked panels."""
|
|
20
|
+
|
|
21
|
+
def initialize(self) -> bool:
|
|
22
|
+
self.logger.info("Initializing performance chart")
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
def plot_backtest_charts(
|
|
26
|
+
self,
|
|
27
|
+
values_df: pd.DataFrame,
|
|
28
|
+
benchmark_close: Optional[pd.Series] = None,
|
|
29
|
+
title: Optional[str] = None,
|
|
30
|
+
save_path: Optional[str] = None,
|
|
31
|
+
use_plotly: bool = False,
|
|
32
|
+
) -> None:
|
|
33
|
+
plt.rcParams["font.sans-serif"] = [
|
|
34
|
+
"Microsoft YaHei",
|
|
35
|
+
"SimHei",
|
|
36
|
+
"Heiti TC",
|
|
37
|
+
"Arial Unicode MS",
|
|
38
|
+
"DejaVu Sans",
|
|
39
|
+
]
|
|
40
|
+
plt.rcParams["axes.unicode_minus"] = False
|
|
41
|
+
|
|
42
|
+
df = values_df.copy()
|
|
43
|
+
if "date" in df.columns:
|
|
44
|
+
df["date"] = pd.to_datetime(df["date"])
|
|
45
|
+
df = df.set_index("date")
|
|
46
|
+
df.index = pd.to_datetime(df.index)
|
|
47
|
+
|
|
48
|
+
if "total_value" not in df.columns:
|
|
49
|
+
raise KeyError("values_df must contain 'total_value'")
|
|
50
|
+
|
|
51
|
+
if "returns" not in df.columns:
|
|
52
|
+
df["returns"] = df["total_value"].pct_change().fillna(0.0)
|
|
53
|
+
|
|
54
|
+
has_price = "price" in df.columns
|
|
55
|
+
if not df.empty:
|
|
56
|
+
date_text = f"{df.index.min().date()} — {df.index.max().date()}"
|
|
57
|
+
else:
|
|
58
|
+
date_text = "no data"
|
|
59
|
+
|
|
60
|
+
# Pre-compute series used by both matplotlib and plotly paths
|
|
61
|
+
strategy_nv = df["total_value"] / df["total_value"].iloc[0]
|
|
62
|
+
drawdown = (df["total_value"].expanding().max() - df["total_value"]) / df["total_value"].expanding().max() * -100
|
|
63
|
+
returns_pct = df["returns"] * 100
|
|
64
|
+
price_norm = df["price"].astype(float) / df["price"].iloc[0] if has_price else None
|
|
65
|
+
bench_norm_price = None
|
|
66
|
+
bench_norm_nv = None
|
|
67
|
+
if benchmark_close is not None:
|
|
68
|
+
bench_series = pd.Series(benchmark_close).astype(float)
|
|
69
|
+
bench_series.index = pd.to_datetime(bench_series.index)
|
|
70
|
+
bench_series = bench_series.sort_index().reindex(df.index).fillna(method="ffill").dropna()
|
|
71
|
+
if not bench_series.empty:
|
|
72
|
+
bench_returns = bench_series.pct_change().fillna(0.0)
|
|
73
|
+
bench_nv = (1 + bench_returns).cumprod()
|
|
74
|
+
bench_norm_nv = bench_nv / bench_nv.iloc[0]
|
|
75
|
+
if has_price:
|
|
76
|
+
bench_norm_price = bench_series / bench_series.iloc[0]
|
|
77
|
+
|
|
78
|
+
if use_plotly:
|
|
79
|
+
try:
|
|
80
|
+
import plotly.graph_objects as go
|
|
81
|
+
from plotly.subplots import make_subplots
|
|
82
|
+
except ImportError as exc: # pragma: no cover
|
|
83
|
+
self.logger.warning("Plotly not available (%s); falling back to Matplotlib", exc)
|
|
84
|
+
else:
|
|
85
|
+
rows = 5
|
|
86
|
+
fig = make_subplots(
|
|
87
|
+
rows=rows,
|
|
88
|
+
cols=1,
|
|
89
|
+
shared_xaxes=True,
|
|
90
|
+
vertical_spacing=0.04,
|
|
91
|
+
specs=[[{"type": "scatter"}]] * rows,
|
|
92
|
+
row_heights=[0.18, 0.22, 0.2, 0.2, 0.2],
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
if has_price and price_norm is not None:
|
|
96
|
+
fig.add_trace(
|
|
97
|
+
go.Scatter(x=df.index, y=price_norm, name="策略收盘价", line=dict(color="#2E86AB", width=1.5)),
|
|
98
|
+
row=1,
|
|
99
|
+
col=1,
|
|
100
|
+
)
|
|
101
|
+
if bench_norm_price is not None:
|
|
102
|
+
fig.add_trace(
|
|
103
|
+
go.Scatter(
|
|
104
|
+
x=bench_norm_price.index,
|
|
105
|
+
y=bench_norm_price.values,
|
|
106
|
+
name="基准收盘价",
|
|
107
|
+
line=dict(color="#E63946", width=1.5, dash="dash"),
|
|
108
|
+
),
|
|
109
|
+
row=1,
|
|
110
|
+
col=1,
|
|
111
|
+
)
|
|
112
|
+
fig.add_trace(
|
|
113
|
+
go.Scatter(x=df.index, y=strategy_nv, name="策略净值", line=dict(color="#2E86AB", width=2)),
|
|
114
|
+
row=2,
|
|
115
|
+
col=1,
|
|
116
|
+
)
|
|
117
|
+
if bench_norm_nv is not None:
|
|
118
|
+
fig.add_trace(
|
|
119
|
+
go.Scatter(
|
|
120
|
+
x=bench_norm_nv.index,
|
|
121
|
+
y=bench_norm_nv.values,
|
|
122
|
+
name="基准净值",
|
|
123
|
+
line=dict(color="#E63946", width=2, dash="dash"),
|
|
124
|
+
),
|
|
125
|
+
row=2,
|
|
126
|
+
col=1,
|
|
127
|
+
)
|
|
128
|
+
fig.add_trace(
|
|
129
|
+
go.Scatter(
|
|
130
|
+
x=df.index,
|
|
131
|
+
y=drawdown,
|
|
132
|
+
name="回撤",
|
|
133
|
+
fill="tozeroy",
|
|
134
|
+
line=dict(color="#C1121F"),
|
|
135
|
+
fillcolor="rgba(242,66,54,0.4)",
|
|
136
|
+
),
|
|
137
|
+
row=3,
|
|
138
|
+
col=1,
|
|
139
|
+
)
|
|
140
|
+
fig.add_trace(
|
|
141
|
+
go.Bar(x=df.index, y=returns_pct, name="每日盈亏", marker_color=np.where(returns_pct >= 0, "#ef4444", "#22c55e")),
|
|
142
|
+
row=4,
|
|
143
|
+
col=1,
|
|
144
|
+
)
|
|
145
|
+
returns_for_dist = returns_pct[returns_pct != 0]
|
|
146
|
+
if len(returns_for_dist) > 1:
|
|
147
|
+
if gaussian_kde is not None:
|
|
148
|
+
kde = gaussian_kde(returns_for_dist)
|
|
149
|
+
kde.set_bandwidth(kde.factor * 0.5)
|
|
150
|
+
x_range = np.linspace(returns_for_dist.min(), returns_for_dist.max(), 300)
|
|
151
|
+
density = kde(x_range)
|
|
152
|
+
bin_width = x_range[1] - x_range[0]
|
|
153
|
+
frequency = density * bin_width * len(returns_for_dist)
|
|
154
|
+
fig.add_trace(
|
|
155
|
+
go.Scatter(
|
|
156
|
+
x=x_range,
|
|
157
|
+
y=frequency,
|
|
158
|
+
name="盈亏分布",
|
|
159
|
+
fill="tozeroy",
|
|
160
|
+
line=dict(color="#8B6F5E"),
|
|
161
|
+
fillcolor="rgba(107,76,63,0.6)",
|
|
162
|
+
),
|
|
163
|
+
row=5,
|
|
164
|
+
col=1,
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
fig.add_trace(
|
|
168
|
+
go.Histogram(x=returns_for_dist, name="盈亏分布", nbinsx=40, marker_color="#6B4C3F"),
|
|
169
|
+
row=5,
|
|
170
|
+
col=1,
|
|
171
|
+
)
|
|
172
|
+
else:
|
|
173
|
+
fig.add_trace(
|
|
174
|
+
go.Histogram(x=returns_pct, name="盈亏分布", nbinsx=10, marker_color="#6B4C3F"),
|
|
175
|
+
row=5,
|
|
176
|
+
col=1,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
fig.update_xaxes(title_text="日期", row=5, col=1)
|
|
180
|
+
fig.update_yaxes(title_text="价格(归一化)", row=1, col=1)
|
|
181
|
+
fig.update_yaxes(title_text="净值", row=2, col=1)
|
|
182
|
+
fig.update_yaxes(title_text="回撤 (%)", row=3, col=1)
|
|
183
|
+
fig.update_yaxes(title_text="收益率 (%)", row=4, col=1)
|
|
184
|
+
fig.update_yaxes(title_text="频数", row=5, col=1)
|
|
185
|
+
|
|
186
|
+
base_title = title or "策略表现分析"
|
|
187
|
+
fig.update_layout(
|
|
188
|
+
title=f"{base_title}<br><sup>{date_text}</sup>",
|
|
189
|
+
template="plotly_white",
|
|
190
|
+
showlegend=True,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if save_path:
|
|
194
|
+
html_path = save_path if str(save_path).lower().endswith(".html") else f"{save_path}.html"
|
|
195
|
+
fig.write_html(html_path, include_plotlyjs="cdn")
|
|
196
|
+
self.logger.info("Chart saved to %s", html_path)
|
|
197
|
+
else:
|
|
198
|
+
fig.show()
|
|
199
|
+
return
|
|
200
|
+
|
|
201
|
+
# ------------------------------ Matplotlib branch ------------------------------
|
|
202
|
+
n_panels = 5 if has_price else 4
|
|
203
|
+
fig, axes = plt.subplots(n_panels, 1, figsize=(16, 14 if has_price else 12))
|
|
204
|
+
base_title = title or "策略表现分析"
|
|
205
|
+
fig.suptitle(f"{base_title} | {date_text}", fontsize=16, y=0.995)
|
|
206
|
+
|
|
207
|
+
idx = 0
|
|
208
|
+
if has_price and price_norm is not None:
|
|
209
|
+
self._plot_price_compare(ax=axes[idx], df=df, benchmark_close=benchmark_close, price_norm=price_norm, bench_norm=bench_norm_price)
|
|
210
|
+
idx += 1
|
|
211
|
+
|
|
212
|
+
self._plot_net_value(ax=axes[idx], df=df, strategy_nv=strategy_nv, bench_norm=bench_norm_nv)
|
|
213
|
+
self._plot_drawdown(ax=axes[idx + 1], drawdown=drawdown)
|
|
214
|
+
self._plot_daily_returns(ax=axes[idx + 2], returns_pct=returns_pct)
|
|
215
|
+
self._plot_return_distribution(ax=axes[idx + 3], returns_pct=returns_pct)
|
|
216
|
+
|
|
217
|
+
for ax in axes[:-1]:
|
|
218
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
|
|
219
|
+
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=6))
|
|
220
|
+
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
|
|
221
|
+
|
|
222
|
+
plt.tight_layout(rect=[0, 0, 1, 0.97])
|
|
223
|
+
|
|
224
|
+
if save_path:
|
|
225
|
+
fig.savefig(save_path, dpi=300, bbox_inches="tight")
|
|
226
|
+
self.logger.info("Chart saved to %s", save_path)
|
|
227
|
+
else:
|
|
228
|
+
plt.show()
|
|
229
|
+
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
# Individual panels (Matplotlib)
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _plot_price_compare(
|
|
235
|
+
self,
|
|
236
|
+
ax: plt.Axes,
|
|
237
|
+
df: pd.DataFrame,
|
|
238
|
+
benchmark_close: Optional[pd.Series],
|
|
239
|
+
price_norm: pd.Series,
|
|
240
|
+
bench_norm: Optional[pd.Series],
|
|
241
|
+
) -> None:
|
|
242
|
+
ax.plot(df.index, price_norm, linewidth=1.5, color="#2E86AB", label="策略收盘价")
|
|
243
|
+
|
|
244
|
+
if bench_norm is not None:
|
|
245
|
+
ax.plot(bench_norm.index, bench_norm.values, linewidth=1.5, color="#E63946", linestyle="--", label="基准收盘价")
|
|
246
|
+
ax.legend()
|
|
247
|
+
|
|
248
|
+
ax.set_title("价格对比", fontsize=14)
|
|
249
|
+
ax.set_xlabel("日期", fontsize=11)
|
|
250
|
+
ax.set_ylabel("价格(归一化)", fontsize=11)
|
|
251
|
+
ax.grid(True, alpha=0.3, linestyle="--")
|
|
252
|
+
|
|
253
|
+
def _plot_net_value(
|
|
254
|
+
self,
|
|
255
|
+
ax: plt.Axes,
|
|
256
|
+
df: pd.DataFrame,
|
|
257
|
+
strategy_nv: pd.Series,
|
|
258
|
+
bench_norm: Optional[pd.Series],
|
|
259
|
+
) -> None:
|
|
260
|
+
ax.plot(df.index, strategy_nv, linewidth=2, color="#2E86AB", label="策略净值")
|
|
261
|
+
|
|
262
|
+
if bench_norm is not None:
|
|
263
|
+
ax.plot(bench_norm.index, bench_norm.values, linewidth=2, color="#E63946", linestyle="--", label="基准净值")
|
|
264
|
+
ax.legend()
|
|
265
|
+
|
|
266
|
+
ax.set_title("账户净值", fontsize=14)
|
|
267
|
+
ax.set_xlabel("日期", fontsize=11)
|
|
268
|
+
ax.set_ylabel("净值(起始=1)", fontsize=11)
|
|
269
|
+
ax.grid(True, alpha=0.3, linestyle="--")
|
|
270
|
+
|
|
271
|
+
def _plot_drawdown(self, ax: plt.Axes, drawdown: pd.Series) -> None:
|
|
272
|
+
ax.fill_between(drawdown.index, drawdown, 0, color="#F24236", alpha=0.5)
|
|
273
|
+
ax.plot(drawdown.index, drawdown, color="#C1121F", linewidth=1.5)
|
|
274
|
+
ax.set_title("净值回撤", fontsize=14)
|
|
275
|
+
ax.set_xlabel("日期", fontsize=11)
|
|
276
|
+
ax.set_ylabel("回撤 (%)", fontsize=11)
|
|
277
|
+
ax.grid(True, alpha=0.3, linestyle="--")
|
|
278
|
+
|
|
279
|
+
def _plot_daily_returns(self, ax: plt.Axes, returns_pct: pd.Series) -> None:
|
|
280
|
+
colors = ["#ef4444" if x >= 0 else "#22c55e" for x in returns_pct]
|
|
281
|
+
ax.bar(returns_pct.index, returns_pct, color=colors, alpha=0.7, width=0.8)
|
|
282
|
+
ax.axhline(y=0, color="black", linewidth=0.5)
|
|
283
|
+
|
|
284
|
+
max_return = abs(returns_pct.max()) if returns_pct.max() != 0 else 1
|
|
285
|
+
min_return = abs(returns_pct.min()) if returns_pct.min() != 0 else 1
|
|
286
|
+
y_max = max(max_return, min_return) * 1.1
|
|
287
|
+
ax.set_ylim(-y_max, y_max)
|
|
288
|
+
|
|
289
|
+
ax.set_title("每日盈亏", fontsize=14)
|
|
290
|
+
ax.set_xlabel("日期", fontsize=11)
|
|
291
|
+
ax.set_ylabel("收益率 (%)", fontsize=11)
|
|
292
|
+
ax.grid(True, alpha=0.3, linestyle="--", axis="y")
|
|
293
|
+
|
|
294
|
+
def _plot_return_distribution(self, ax: plt.Axes, returns_pct: pd.Series) -> None:
|
|
295
|
+
returns_for_dist = returns_pct[returns_pct != 0]
|
|
296
|
+
ax.set_title("盈亏分布(已交易日期)", fontsize=14)
|
|
297
|
+
ax.set_xlabel("盈亏值 (%)", fontsize=11)
|
|
298
|
+
ax.set_ylabel("频数", fontsize=11)
|
|
299
|
+
ax.grid(True, alpha=0.3, linestyle="--", axis="y")
|
|
300
|
+
|
|
301
|
+
if len(returns_for_dist) < 2:
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
if gaussian_kde is None:
|
|
305
|
+
ax.hist(returns_for_dist, bins=40, color="#6B4C3F", alpha=0.7, edgecolor="white")
|
|
306
|
+
ax.axvline(x=0, color="gray", linestyle="--", linewidth=1, alpha=0.7)
|
|
307
|
+
return
|
|
308
|
+
|
|
309
|
+
kde = gaussian_kde(returns_for_dist)
|
|
310
|
+
kde.set_bandwidth(kde.factor * 0.5)
|
|
311
|
+
x_range = np.linspace(returns_for_dist.min(), returns_for_dist.max(), 300)
|
|
312
|
+
density = kde(x_range)
|
|
313
|
+
bin_width = x_range[1] - x_range[0]
|
|
314
|
+
frequency = density * bin_width * len(returns_for_dist)
|
|
315
|
+
|
|
316
|
+
ax.fill_between(x_range, 0, frequency, color="#6B4C3F", alpha=0.7)
|
|
317
|
+
ax.plot(x_range, frequency, color="#8B6F5E", linewidth=2)
|
|
318
|
+
ax.axvline(x=0, color="gray", linestyle="--", linewidth=1, alpha=0.7)
|
|
319
|
+
|
deltafq/charts/price.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Minimal price chart helper (Matplotlib or Plotly)."""
|
|
2
|
+
from typing import Dict, Optional, Union
|
|
3
|
+
|
|
4
|
+
import matplotlib.pyplot as plt
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from ..core.base import BaseComponent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PriceChart(BaseComponent):
|
|
11
|
+
def initialize(self) -> bool:
|
|
12
|
+
"""Initialize the price chart."""
|
|
13
|
+
self.logger.info("Initializing price chart")
|
|
14
|
+
return True
|
|
15
|
+
|
|
16
|
+
def plot_prices(
|
|
17
|
+
self,
|
|
18
|
+
data: Union[pd.DataFrame, Dict[str, pd.DataFrame]],
|
|
19
|
+
price_column: Optional[str] = "Close",
|
|
20
|
+
normalize: Optional[bool] = True,
|
|
21
|
+
title: Optional[str] = None,
|
|
22
|
+
use_plotly: Optional[bool] = False,
|
|
23
|
+
) -> None:
|
|
24
|
+
series_map = self._collect(data, price_column, normalize)
|
|
25
|
+
ylabel = "Normalized Price" if normalize else "Price"
|
|
26
|
+
title = title or ("Normalized Price Comparison" if len(series_map) > 1 else "Price Chart")
|
|
27
|
+
|
|
28
|
+
if use_plotly:
|
|
29
|
+
import plotly.graph_objects as go
|
|
30
|
+
|
|
31
|
+
fig = go.Figure()
|
|
32
|
+
for label, series in series_map.items():
|
|
33
|
+
fig.add_trace(go.Scatter(x=series.index, y=series.values, mode="lines", name=label))
|
|
34
|
+
fig.update_layout(title=title, xaxis_title="Date", yaxis_title=ylabel, template="plotly_white")
|
|
35
|
+
fig.show()
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
fig, ax = plt.subplots(figsize=(12, 6))
|
|
39
|
+
for label, series in series_map.items():
|
|
40
|
+
ax.plot(series.index, series.values, label=label)
|
|
41
|
+
ax.set_xlabel("Date")
|
|
42
|
+
ax.set_ylabel(ylabel)
|
|
43
|
+
ax.set_title(title)
|
|
44
|
+
ax.legend()
|
|
45
|
+
ax.grid(True, alpha=0.3)
|
|
46
|
+
plt.xticks(rotation=45)
|
|
47
|
+
plt.tight_layout()
|
|
48
|
+
plt.show()
|
|
49
|
+
|
|
50
|
+
def _collect(
|
|
51
|
+
self,
|
|
52
|
+
data: Union[pd.DataFrame, Dict[str, pd.DataFrame]],
|
|
53
|
+
price_column: str,
|
|
54
|
+
normalize: bool,
|
|
55
|
+
) -> Dict[str, pd.Series]:
|
|
56
|
+
def prep(frame: pd.DataFrame) -> pd.Series:
|
|
57
|
+
series = frame[price_column]
|
|
58
|
+
if normalize:
|
|
59
|
+
series = series / series.iloc[0]
|
|
60
|
+
return series
|
|
61
|
+
|
|
62
|
+
if isinstance(data, pd.DataFrame):
|
|
63
|
+
return {price_column: prep(data)}
|
|
64
|
+
return {name: prep(df) for name, df in data.items()}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Minimal signal chart helper."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Optional
|
|
4
|
+
|
|
5
|
+
import matplotlib.dates as mdates
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pandas as pd
|
|
9
|
+
|
|
10
|
+
from ..core.base import BaseComponent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SignalChart(BaseComponent):
|
|
14
|
+
def initialize(self) -> bool:
|
|
15
|
+
"""Initialize the signal chart."""
|
|
16
|
+
self.logger.info("Initializing signal chart")
|
|
17
|
+
return True
|
|
18
|
+
|
|
19
|
+
def plot_boll_signals(
|
|
20
|
+
self,
|
|
21
|
+
data: pd.DataFrame,
|
|
22
|
+
bands: Dict[str, pd.Series],
|
|
23
|
+
signals: pd.Series,
|
|
24
|
+
title: Optional[str] = None,
|
|
25
|
+
use_plotly: bool = False,
|
|
26
|
+
) -> None:
|
|
27
|
+
required = {"Open", "High", "Low", "Close"}
|
|
28
|
+
if not required.issubset(data.columns):
|
|
29
|
+
raise ValueError(f"data must contain columns: {required}")
|
|
30
|
+
|
|
31
|
+
frame = data.copy()
|
|
32
|
+
frame.index = pd.to_datetime(frame.index)
|
|
33
|
+
signals = signals.reindex(frame.index)
|
|
34
|
+
|
|
35
|
+
upper = bands.get("upper", pd.Series(index=frame.index)).reindex(frame.index)
|
|
36
|
+
middle = bands.get("middle", pd.Series(index=frame.index)).reindex(frame.index)
|
|
37
|
+
lower = bands.get("lower", pd.Series(index=frame.index)).reindex(frame.index)
|
|
38
|
+
|
|
39
|
+
if use_plotly:
|
|
40
|
+
try:
|
|
41
|
+
import plotly.graph_objects as go
|
|
42
|
+
from plotly.subplots import make_subplots
|
|
43
|
+
except ImportError as exc: # pragma: no cover
|
|
44
|
+
self.logger.warning("Plotly not available (%s); falling back to Matplotlib", exc)
|
|
45
|
+
else:
|
|
46
|
+
fig = make_subplots(
|
|
47
|
+
rows=2,
|
|
48
|
+
cols=1,
|
|
49
|
+
shared_xaxes=True,
|
|
50
|
+
row_heights=[0.78, 0.22],
|
|
51
|
+
specs=[[{"type": "candlestick"}], [{"type": "scatter"}]],
|
|
52
|
+
)
|
|
53
|
+
fig.add_trace(
|
|
54
|
+
go.Candlestick(
|
|
55
|
+
x=frame.index,
|
|
56
|
+
open=frame["Open"],
|
|
57
|
+
high=frame["High"],
|
|
58
|
+
low=frame["Low"],
|
|
59
|
+
close=frame["Close"],
|
|
60
|
+
name="Price",
|
|
61
|
+
increasing_line_color="#ef4444",
|
|
62
|
+
decreasing_line_color="#22c55e",
|
|
63
|
+
),
|
|
64
|
+
row=1,
|
|
65
|
+
col=1,
|
|
66
|
+
)
|
|
67
|
+
for name, series, color in (
|
|
68
|
+
("Upper", upper, "#2563eb"),
|
|
69
|
+
("Middle", middle, "#7c3aed"),
|
|
70
|
+
("Lower", lower, "#0ea5e9"),
|
|
71
|
+
):
|
|
72
|
+
fig.add_trace(
|
|
73
|
+
go.Scatter(x=series.index, y=series.values, mode="lines", name=name, line=dict(color=color)),
|
|
74
|
+
row=1,
|
|
75
|
+
col=1,
|
|
76
|
+
)
|
|
77
|
+
buy_mask = signals == 1
|
|
78
|
+
sell_mask = signals == -1
|
|
79
|
+
fig.add_trace(
|
|
80
|
+
go.Scatter(
|
|
81
|
+
x=signals.index[buy_mask],
|
|
82
|
+
y=frame.loc[buy_mask, "Close"],
|
|
83
|
+
mode="markers",
|
|
84
|
+
name="Buy",
|
|
85
|
+
marker=dict(symbol="triangle-up", size=12, color="#ef4444"),
|
|
86
|
+
),
|
|
87
|
+
row=1,
|
|
88
|
+
col=1,
|
|
89
|
+
)
|
|
90
|
+
fig.add_trace(
|
|
91
|
+
go.Scatter(
|
|
92
|
+
x=signals.index[sell_mask],
|
|
93
|
+
y=frame.loc[sell_mask, "Close"],
|
|
94
|
+
mode="markers",
|
|
95
|
+
name="Sell",
|
|
96
|
+
marker=dict(symbol="triangle-down", size=12, color="#22c55e"),
|
|
97
|
+
),
|
|
98
|
+
row=1,
|
|
99
|
+
col=1,
|
|
100
|
+
)
|
|
101
|
+
fig.add_trace(
|
|
102
|
+
go.Scatter(
|
|
103
|
+
x=signals.index,
|
|
104
|
+
y=signals.values,
|
|
105
|
+
name="Signal",
|
|
106
|
+
mode="lines",
|
|
107
|
+
line=dict(color="#6b7280", dash="dot"),
|
|
108
|
+
showlegend=False,
|
|
109
|
+
),
|
|
110
|
+
row=2,
|
|
111
|
+
col=1,
|
|
112
|
+
)
|
|
113
|
+
fig.update_xaxes(title_text="Date", row=2, col=1)
|
|
114
|
+
fig.update_yaxes(title_text="Price", row=1, col=1)
|
|
115
|
+
fig.update_yaxes(
|
|
116
|
+
title_text="Signal",
|
|
117
|
+
row=2,
|
|
118
|
+
col=1,
|
|
119
|
+
tickvals=[-1, 0, 1],
|
|
120
|
+
ticktext=["Sell", "Hold", "Buy"],
|
|
121
|
+
range=[-1.5, 1.5],
|
|
122
|
+
)
|
|
123
|
+
fig.update_layout(
|
|
124
|
+
title=title or "Bollinger Signal Chart",
|
|
125
|
+
template="plotly_white",
|
|
126
|
+
showlegend=True,
|
|
127
|
+
)
|
|
128
|
+
fig.show()
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
fig, (ax_price, ax_signal) = plt.subplots(2, 1, figsize=(12, 6), sharex=True, gridspec_kw={"height_ratios": [4, 1]})
|
|
132
|
+
self._plot_candles(ax_price, frame)
|
|
133
|
+
ax_price.plot(upper.index, upper.values, color="#2563eb", linewidth=1.2, label="Upper", zorder=2)
|
|
134
|
+
ax_price.plot(middle.index, middle.values, color="#7c3aed", linewidth=1.0, linestyle="--", label="Middle", zorder=2)
|
|
135
|
+
ax_price.plot(lower.index, lower.values, color="#0ea5e9", linewidth=1.2, label="Lower", zorder=2)
|
|
136
|
+
self._plot_markers(ax_price, frame["Close"], signals)
|
|
137
|
+
|
|
138
|
+
ax_price.set_title(title or "Bollinger Signal Chart", fontsize=14)
|
|
139
|
+
ax_price.set_ylabel("Price")
|
|
140
|
+
ax_price.grid(True, alpha=0.2, color="#d1d5db")
|
|
141
|
+
handles, labels = ax_price.get_legend_handles_labels()
|
|
142
|
+
unique = dict(zip(labels, handles))
|
|
143
|
+
ax_price.legend(unique.values(), unique.keys(), frameon=False, loc="upper left")
|
|
144
|
+
|
|
145
|
+
ax_signal.plot(signals.index, signals.values, color="#6b7280", linewidth=1.0, linestyle=":")
|
|
146
|
+
ax_signal.set_ylabel("Signal")
|
|
147
|
+
ax_signal.set_xlabel("Date")
|
|
148
|
+
ax_signal.set_yticks([-1, 0, 1])
|
|
149
|
+
ax_signal.set_yticklabels(["Sell", "Hold", "Buy"])
|
|
150
|
+
ax_signal.grid(True, alpha=0.2, color="#d1d5db")
|
|
151
|
+
|
|
152
|
+
for ax in (ax_price, ax_signal):
|
|
153
|
+
ax.xaxis.set_major_locator(mdates.MonthLocator(interval=2))
|
|
154
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m"))
|
|
155
|
+
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha="right")
|
|
156
|
+
plt.tight_layout()
|
|
157
|
+
plt.show()
|
|
158
|
+
|
|
159
|
+
def _plot_candles(self, ax: plt.Axes, frame: pd.DataFrame) -> None:
|
|
160
|
+
x = mdates.date2num(frame.index.to_pydatetime())
|
|
161
|
+
for xi, row in zip(x, frame[["Open", "High", "Low", "Close"]].itertuples(index=False)):
|
|
162
|
+
o, h, l, c = row
|
|
163
|
+
color = "#ef4444" if c >= o else "#22c55e"
|
|
164
|
+
ax.vlines(xi, l, h, color="#6b7280", linewidth=0.8)
|
|
165
|
+
ax.add_patch(
|
|
166
|
+
plt.Rectangle(
|
|
167
|
+
(xi - 0.2, min(o, c)),
|
|
168
|
+
0.4,
|
|
169
|
+
abs(c - o) or 0.2,
|
|
170
|
+
facecolor=color,
|
|
171
|
+
edgecolor=color,
|
|
172
|
+
alpha=0.85,
|
|
173
|
+
)
|
|
174
|
+
)
|
|
175
|
+
ax.xaxis_date()
|
|
176
|
+
|
|
177
|
+
def _plot_markers(self, ax: plt.Axes, price: pd.Series, signals: pd.Series) -> None:
|
|
178
|
+
buy = signals == 1
|
|
179
|
+
sell = signals == -1
|
|
180
|
+
ax.scatter(price.index[buy], price[buy], marker="^", color="#ef4444", edgecolors="white", linewidths=0.6, s=140, label="Buy", zorder=3)
|
|
181
|
+
ax.scatter(price.index[sell], price[sell], marker="v", color="#22c55e", edgecolors="white", linewidths=0.6, s=140, label="Sell", zorder=3)
|
deltafq/core/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core functionality for DeltaFQ.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .config import Config
|
|
6
|
+
from .logger import Logger
|
|
7
|
+
from .exceptions import DeltaFQError, DataError, TradingError
|
|
8
|
+
from .base import BaseComponent
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Config",
|
|
12
|
+
"Logger",
|
|
13
|
+
"DeltaFQError",
|
|
14
|
+
"DataError",
|
|
15
|
+
"TradingError",
|
|
16
|
+
"BaseComponent"
|
|
17
|
+
]
|
|
18
|
+
|
deltafq/core/base.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base classes for DeltaFQ components.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from .logger import Logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseComponent(ABC):
|
|
10
|
+
"""Base class for all DeltaFQ components."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, name: str = None, **kwargs):
|
|
13
|
+
"""Initialize base component."""
|
|
14
|
+
self.name = name or self.__class__.__name__
|
|
15
|
+
self.logger = Logger(self.name)
|
|
16
|
+
|
|
17
|
+
@abstractmethod
|
|
18
|
+
def initialize(self) -> bool:
|
|
19
|
+
"""Initialize the component."""
|
|
20
|
+
pass
|
|
21
|
+
|
deltafq/core/config.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration management for DeltaFQ.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Config:
|
|
11
|
+
"""Configuration manager for DeltaFQ."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, config_file: str = None):
|
|
14
|
+
"""Initialize configuration."""
|
|
15
|
+
self.config = self._load_default_config()
|
|
16
|
+
if config_file and os.path.exists(config_file):
|
|
17
|
+
self._load_config_file(config_file)
|
|
18
|
+
|
|
19
|
+
def _load_default_config(self) -> Dict[str, Any]:
|
|
20
|
+
"""Load default configuration."""
|
|
21
|
+
return {
|
|
22
|
+
"data": {
|
|
23
|
+
"cache_dir": "data_cache",
|
|
24
|
+
"default_source": "yahoo"
|
|
25
|
+
},
|
|
26
|
+
"trading": {
|
|
27
|
+
"initial_capital": 1000000,
|
|
28
|
+
"commission": 0.001,
|
|
29
|
+
"slippage": 0.0005
|
|
30
|
+
},
|
|
31
|
+
"logging": {
|
|
32
|
+
"level": "INFO",
|
|
33
|
+
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
def _load_config_file(self, config_file: str):
|
|
38
|
+
"""Load configuration from file."""
|
|
39
|
+
# Placeholder for config file loading
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
43
|
+
"""Get configuration value."""
|
|
44
|
+
keys = key.split('.')
|
|
45
|
+
value = self.config
|
|
46
|
+
for k in keys:
|
|
47
|
+
if isinstance(value, dict) and k in value:
|
|
48
|
+
value = value[k]
|
|
49
|
+
else:
|
|
50
|
+
return default
|
|
51
|
+
return value
|
|
52
|
+
|
|
53
|
+
def set(self, key: str, value: Any):
|
|
54
|
+
"""Set configuration value."""
|
|
55
|
+
keys = key.split('.')
|
|
56
|
+
config = self.config
|
|
57
|
+
for k in keys[:-1]:
|
|
58
|
+
if k not in config:
|
|
59
|
+
config[k] = {}
|
|
60
|
+
config = config[k]
|
|
61
|
+
config[keys[-1]] = value
|
|
62
|
+
|