alpha-visualizer 0.1.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.
- alpha_visualizer/__init__.py +3 -0
- alpha_visualizer/app.py +73 -0
- alpha_visualizer/cli.py +66 -0
- alpha_visualizer/db.py +61 -0
- alpha_visualizer/forge_config.py +133 -0
- alpha_visualizer/routers/__init__.py +0 -0
- alpha_visualizer/routers/ideas.py +57 -0
- alpha_visualizer/routers/results.py +352 -0
- alpha_visualizer/routers/strategies.py +272 -0
- alpha_visualizer/routers/wfo.py +126 -0
- alpha_visualizer/static/assets/index-16K3BxVS.css +1 -0
- alpha_visualizer/static/assets/index-dB6krL5g.js +11 -0
- alpha_visualizer/static/assets/index-dB6krL5g.js.map +1 -0
- alpha_visualizer/static/assets/inter-tight-latin-400-normal-BLrFJfvD.woff +0 -0
- alpha_visualizer/static/assets/inter-tight-latin-400-normal-iW8qmuJY.woff2 +0 -0
- alpha_visualizer/static/assets/inter-tight-latin-500-normal-BFXNXuvF.woff2 +0 -0
- alpha_visualizer/static/assets/inter-tight-latin-500-normal-pobXraBK.woff +0 -0
- alpha_visualizer/static/assets/inter-tight-latin-600-normal-BgSTtRxb.woff2 +0 -0
- alpha_visualizer/static/assets/inter-tight-latin-600-normal-D7bG6gX1.woff +0 -0
- alpha_visualizer/static/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
- alpha_visualizer/static/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
- alpha_visualizer/static/assets/source-serif-4-latin-600-normal-DMD1h6_f.woff +0 -0
- alpha_visualizer/static/assets/source-serif-4-latin-600-normal-DouSKlru.woff2 +0 -0
- alpha_visualizer/static/favicon.svg +1 -0
- alpha_visualizer/static/icons.svg +24 -0
- alpha_visualizer/static/index.html +33 -0
- alpha_visualizer-0.1.0.dist-info/METADATA +66 -0
- alpha_visualizer-0.1.0.dist-info/RECORD +30 -0
- alpha_visualizer-0.1.0.dist-info/WHEEL +4 -0
- alpha_visualizer-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""バックテスト結果 API ルーター
|
|
2
|
+
|
|
3
|
+
`/api/results` (一覧) と `/api/results/{run_id}` (詳細) を提供する。
|
|
4
|
+
詳細レスポンスはフロントエンド(visualizer/)の BacktestDetail 型と一致するよう整形済み。
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import math
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
15
|
+
from sqlalchemy import create_engine, select
|
|
16
|
+
|
|
17
|
+
from alpha_visualizer.db import backtest_results
|
|
18
|
+
from alpha_visualizer.forge_config import ForgeConfig
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
router = APIRouter()
|
|
23
|
+
|
|
24
|
+
_MONTHS = list(range(1, 13))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_dt(s: str) -> datetime:
|
|
28
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00")).replace(tzinfo=None)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _shape_monthly_returns(raw: dict[str, float] | None) -> dict[int, list[float | None]]:
|
|
32
|
+
if not raw:
|
|
33
|
+
return {}
|
|
34
|
+
by_year: dict[int, list[float | None]] = {}
|
|
35
|
+
for ym, pct in raw.items():
|
|
36
|
+
try:
|
|
37
|
+
year_str, month_str = ym.split("-", 1)
|
|
38
|
+
year = int(year_str)
|
|
39
|
+
month = int(month_str)
|
|
40
|
+
except (ValueError, AttributeError):
|
|
41
|
+
continue
|
|
42
|
+
if month < 1 or month > 12:
|
|
43
|
+
continue
|
|
44
|
+
bucket = by_year.setdefault(year, [None] * 12)
|
|
45
|
+
bucket[month - 1] = float(pct) if pct is not None else None
|
|
46
|
+
return by_year
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _shape_trades(
|
|
50
|
+
raw_trades: list[dict[str, Any]] | None,
|
|
51
|
+
trade_analysis: dict[str, Any] | None,
|
|
52
|
+
) -> list[dict[str, Any]]:
|
|
53
|
+
if not raw_trades:
|
|
54
|
+
return []
|
|
55
|
+
mae_list: list[float] = list(trade_analysis.get("per_trade_mae_pct", [])) if trade_analysis else []
|
|
56
|
+
mfe_list: list[float] = list(trade_analysis.get("per_trade_mfe_pct", [])) if trade_analysis else []
|
|
57
|
+
out: list[dict[str, Any]] = []
|
|
58
|
+
for i, t in enumerate(raw_trades):
|
|
59
|
+
direction = t.get("direction") or t.get("signal") or "long"
|
|
60
|
+
return_pct = float(t.get("return_pct") or 0.0)
|
|
61
|
+
pnl = float(t.get("pnl") or 0.0)
|
|
62
|
+
out.append(
|
|
63
|
+
{
|
|
64
|
+
"id": t.get("id", i),
|
|
65
|
+
"direction": "long" if str(direction).lower().startswith("long") else "short",
|
|
66
|
+
"entry_date": t.get("entry_date") or "",
|
|
67
|
+
"exit_date": t.get("exit_date") or "",
|
|
68
|
+
"entry_price": float(t.get("entry_price") or 0.0),
|
|
69
|
+
"return_pct": return_pct,
|
|
70
|
+
"pnl": pnl,
|
|
71
|
+
"holding_days": int(t.get("holding_days") or 0),
|
|
72
|
+
"mae_pct": float(mae_list[i]) if i < len(mae_list) else float(t.get("mae_pct") or 0.0),
|
|
73
|
+
"mfe_pct": float(mfe_list[i]) if i < len(mfe_list) else float(t.get("mfe_pct") or 0.0),
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _compute_drawdown(values: list[float]) -> list[float]:
|
|
80
|
+
if not values:
|
|
81
|
+
return []
|
|
82
|
+
out: list[float] = []
|
|
83
|
+
peak = values[0]
|
|
84
|
+
for v in values:
|
|
85
|
+
if v > peak:
|
|
86
|
+
peak = v
|
|
87
|
+
out.append(0.0 if peak == 0 else (v - peak) / peak * 100.0)
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _compute_daily_returns(values: list[float]) -> list[float]:
|
|
92
|
+
if len(values) < 2:
|
|
93
|
+
return []
|
|
94
|
+
results: list[float] = []
|
|
95
|
+
for i in range(1, len(values)):
|
|
96
|
+
prev = values[i - 1]
|
|
97
|
+
curr = values[i]
|
|
98
|
+
if prev == 0.0 or not math.isfinite(prev) or not math.isfinite(curr):
|
|
99
|
+
results.append(0.0)
|
|
100
|
+
else:
|
|
101
|
+
results.append(round((curr - prev) / prev * 100.0, 6))
|
|
102
|
+
return results
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _compute_buy_hold_equity(record: dict[str, Any]) -> list[float]:
|
|
106
|
+
raw = record.get("buy_hold_curve")
|
|
107
|
+
if not raw or not isinstance(raw, list):
|
|
108
|
+
return []
|
|
109
|
+
try:
|
|
110
|
+
if isinstance(raw[0], dict):
|
|
111
|
+
vals = [float(item.get("value", 0.0)) for item in raw]
|
|
112
|
+
else:
|
|
113
|
+
vals = [float(v) for v in raw]
|
|
114
|
+
except (TypeError, ValueError):
|
|
115
|
+
return []
|
|
116
|
+
if not vals or vals[0] == 0.0 or not math.isfinite(vals[0]):
|
|
117
|
+
return []
|
|
118
|
+
base = vals[0]
|
|
119
|
+
return [round(v / base * 100.0, 4) if math.isfinite(v) else 0.0 for v in vals]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _shape_equity(raw: list[Any] | None) -> tuple[list[str], list[float]]:
|
|
123
|
+
if not raw:
|
|
124
|
+
return [], []
|
|
125
|
+
dates: list[str] = []
|
|
126
|
+
values: list[float] = []
|
|
127
|
+
for item in raw:
|
|
128
|
+
if isinstance(item, dict):
|
|
129
|
+
dates.append(str(item.get("date", "")))
|
|
130
|
+
values.append(float(item.get("value", 0.0)))
|
|
131
|
+
else:
|
|
132
|
+
try:
|
|
133
|
+
values.append(float(item))
|
|
134
|
+
dates.append("")
|
|
135
|
+
except (TypeError, ValueError):
|
|
136
|
+
continue
|
|
137
|
+
return dates, values
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _is_cutoff(dates: list[str], oos_start: str | None) -> dict[str, Any]:
|
|
141
|
+
if not oos_start or not dates:
|
|
142
|
+
return {"date": None, "index": -1}
|
|
143
|
+
target = oos_start[:10] if len(oos_start) >= 10 else oos_start
|
|
144
|
+
for i, d in enumerate(dates):
|
|
145
|
+
if d >= target:
|
|
146
|
+
prev = dates[i - 1] if i > 0 else None
|
|
147
|
+
return {"date": prev, "index": i}
|
|
148
|
+
return {"date": dates[-1] if dates else None, "index": len(dates)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _split_metrics(
|
|
152
|
+
metrics: dict[str, Any], cutoff_idx: int, total: int
|
|
153
|
+
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
|
|
154
|
+
if cutoff_idx <= 0 or cutoff_idx >= total or not metrics:
|
|
155
|
+
return None, None
|
|
156
|
+
is_ratio = cutoff_idx / total
|
|
157
|
+
oos_ratio = 1.0 - is_ratio
|
|
158
|
+
period_independent = {"sharpe_ratio", "sortino_ratio", "calmar_ratio", "win_rate_pct", "profit_factor"}
|
|
159
|
+
is_m: dict[str, Any] = {}
|
|
160
|
+
oos_m: dict[str, Any] = {}
|
|
161
|
+
for key in (
|
|
162
|
+
"total_return_pct", "cagr_pct", "sharpe_ratio", "sortino_ratio", "calmar_ratio",
|
|
163
|
+
"max_drawdown_pct", "win_rate_pct", "profit_factor", "total_trades",
|
|
164
|
+
):
|
|
165
|
+
v = metrics.get(key)
|
|
166
|
+
if v is None:
|
|
167
|
+
continue
|
|
168
|
+
if key in period_independent:
|
|
169
|
+
is_m[key] = float(v)
|
|
170
|
+
oos_m[key] = float(v)
|
|
171
|
+
elif key == "total_trades":
|
|
172
|
+
is_m[key] = int(round(float(v) * is_ratio))
|
|
173
|
+
oos_m[key] = max(int(v) - is_m[key], 0)
|
|
174
|
+
else:
|
|
175
|
+
is_m[key] = float(v) * is_ratio
|
|
176
|
+
oos_m[key] = float(v) * oos_ratio
|
|
177
|
+
return is_m or None, oos_m or None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _row_to_dict(row: Any) -> dict[str, Any]:
|
|
181
|
+
"""SQLAlchemy Row をフラットな dict に変換する。metrics_json 等を展開する。"""
|
|
182
|
+
metrics: dict[str, Any] = {}
|
|
183
|
+
if row.metrics_json:
|
|
184
|
+
try:
|
|
185
|
+
metrics = json.loads(row.metrics_json)
|
|
186
|
+
except (json.JSONDecodeError, TypeError):
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
equity_curve: list[Any] = []
|
|
190
|
+
if row.equity_curve_json:
|
|
191
|
+
try:
|
|
192
|
+
equity_curve = json.loads(row.equity_curve_json)
|
|
193
|
+
except (json.JSONDecodeError, TypeError):
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
buy_hold_curve: list[Any] = []
|
|
197
|
+
if row.buy_hold_curve_json:
|
|
198
|
+
try:
|
|
199
|
+
buy_hold_curve = json.loads(row.buy_hold_curve_json)
|
|
200
|
+
except (json.JSONDecodeError, TypeError):
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
trades: list[Any] = []
|
|
204
|
+
if row.trades_json:
|
|
205
|
+
try:
|
|
206
|
+
trades = json.loads(row.trades_json)
|
|
207
|
+
except (json.JSONDecodeError, TypeError):
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
# DB のトップレベルカラムを metrics にマージ(元 forge との互換性のため)
|
|
211
|
+
for col in ("sharpe_ratio", "total_return_pct", "cagr_pct", "sortino_ratio",
|
|
212
|
+
"calmar_ratio", "max_drawdown_pct", "total_trades", "win_rate_pct",
|
|
213
|
+
"profit_factor", "avg_holding_days"):
|
|
214
|
+
val = getattr(row, col, None)
|
|
215
|
+
if val is not None and col not in metrics:
|
|
216
|
+
metrics[col] = val
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"run_id": row.run_id,
|
|
220
|
+
"strategy_id": row.strategy_id,
|
|
221
|
+
"symbol": row.symbol,
|
|
222
|
+
"run_at": row.run_at,
|
|
223
|
+
"sharpe_ratio": row.sharpe_ratio,
|
|
224
|
+
"total_return_pct": row.total_return_pct,
|
|
225
|
+
"cagr_pct": row.cagr_pct,
|
|
226
|
+
"max_drawdown_pct": row.max_drawdown_pct,
|
|
227
|
+
"total_trades": row.total_trades,
|
|
228
|
+
"oos_start": row.oos_start,
|
|
229
|
+
"metrics": metrics,
|
|
230
|
+
"equity_curve": equity_curve,
|
|
231
|
+
"buy_hold_curve": buy_hold_curve,
|
|
232
|
+
"trades": trades,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _shape_detail(record: dict[str, Any]) -> dict[str, Any]:
|
|
237
|
+
metrics: dict[str, Any] = dict(record.get("metrics") or {})
|
|
238
|
+
raw_equity = record.get("equity_curve")
|
|
239
|
+
dates, values = _shape_equity(raw_equity)
|
|
240
|
+
drawdown = _compute_drawdown(values)
|
|
241
|
+
cutoff = _is_cutoff(dates, record.get("oos_start"))
|
|
242
|
+
monthly = _shape_monthly_returns(metrics.get("monthly_returns"))
|
|
243
|
+
trade_analysis = metrics.get("trade_analysis") or {}
|
|
244
|
+
trades = _shape_trades(record.get("trades"), trade_analysis)
|
|
245
|
+
is_m, oos_m = _split_metrics(metrics, cutoff["index"], len(values))
|
|
246
|
+
period_start = dates[0][:7] if dates else ""
|
|
247
|
+
period_end = dates[-1][:7] if dates else ""
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
"run_id": record.get("run_id", ""),
|
|
251
|
+
"strategy_id": record.get("strategy_id", ""),
|
|
252
|
+
"strategy_name": record.get("strategy_id", ""),
|
|
253
|
+
"symbol": record.get("symbol", ""),
|
|
254
|
+
"timeframe": record.get("timeframe", "1d"),
|
|
255
|
+
"run_at": record.get("run_at", ""),
|
|
256
|
+
"period": {"start": period_start, "end": period_end},
|
|
257
|
+
"equity": {"dates": dates, "values": values},
|
|
258
|
+
"drawdown": drawdown,
|
|
259
|
+
"daily_returns": _compute_daily_returns(values),
|
|
260
|
+
"buy_hold_equity": _compute_buy_hold_equity(record),
|
|
261
|
+
"is_cutoff": cutoff,
|
|
262
|
+
"metrics": metrics,
|
|
263
|
+
"is_metrics": is_m,
|
|
264
|
+
"oos_metrics": oos_m,
|
|
265
|
+
"monthly_returns": monthly,
|
|
266
|
+
"trades": trades,
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _list_results_from_db(
|
|
271
|
+
config: ForgeConfig,
|
|
272
|
+
strategy_id: str | None,
|
|
273
|
+
since: datetime | None,
|
|
274
|
+
) -> list[dict[str, Any]]:
|
|
275
|
+
db_path = config.forge_db
|
|
276
|
+
if not db_path.exists():
|
|
277
|
+
return []
|
|
278
|
+
engine = create_engine(f"sqlite:///{db_path}", future=True)
|
|
279
|
+
stmt = select(
|
|
280
|
+
backtest_results.c.run_id,
|
|
281
|
+
backtest_results.c.strategy_id,
|
|
282
|
+
backtest_results.c.symbol,
|
|
283
|
+
backtest_results.c.run_at,
|
|
284
|
+
backtest_results.c.sharpe_ratio,
|
|
285
|
+
backtest_results.c.total_return_pct,
|
|
286
|
+
backtest_results.c.max_drawdown_pct,
|
|
287
|
+
backtest_results.c.total_trades,
|
|
288
|
+
).order_by(backtest_results.c.run_at.desc())
|
|
289
|
+
if strategy_id:
|
|
290
|
+
stmt = stmt.where(backtest_results.c.strategy_id == strategy_id)
|
|
291
|
+
rows: list[dict[str, Any]] = []
|
|
292
|
+
with engine.connect() as conn:
|
|
293
|
+
for r in conn.execute(stmt):
|
|
294
|
+
if since is not None:
|
|
295
|
+
try:
|
|
296
|
+
if _parse_dt(r.run_at or "") < since:
|
|
297
|
+
continue
|
|
298
|
+
except ValueError:
|
|
299
|
+
pass
|
|
300
|
+
rows.append({
|
|
301
|
+
"run_id": r.run_id,
|
|
302
|
+
"strategy_id": r.strategy_id,
|
|
303
|
+
"symbol": r.symbol,
|
|
304
|
+
"run_at": r.run_at,
|
|
305
|
+
"sharpe_ratio": r.sharpe_ratio,
|
|
306
|
+
"total_return_pct": r.total_return_pct,
|
|
307
|
+
"max_drawdown_pct": r.max_drawdown_pct,
|
|
308
|
+
"total_trades": r.total_trades,
|
|
309
|
+
})
|
|
310
|
+
return rows
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _get_result_from_db(config: ForgeConfig, run_id: str) -> dict[str, Any] | None:
|
|
314
|
+
db_path = config.forge_db
|
|
315
|
+
if not db_path.exists():
|
|
316
|
+
return None
|
|
317
|
+
engine = create_engine(f"sqlite:///{db_path}", future=True)
|
|
318
|
+
with engine.connect() as conn:
|
|
319
|
+
row = conn.execute(
|
|
320
|
+
backtest_results.select().where(backtest_results.c.run_id == run_id)
|
|
321
|
+
).first()
|
|
322
|
+
if row is None:
|
|
323
|
+
return None
|
|
324
|
+
return _row_to_dict(row)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@router.get("/results")
|
|
328
|
+
async def list_results(
|
|
329
|
+
request: Request,
|
|
330
|
+
strategy_id: str | None = Query(default=None),
|
|
331
|
+
since: str | None = Query(default=None),
|
|
332
|
+
) -> list[dict[str, Any]]:
|
|
333
|
+
config: ForgeConfig = request.app.state.forge_config
|
|
334
|
+
since_dt: datetime | None = None
|
|
335
|
+
if since:
|
|
336
|
+
try:
|
|
337
|
+
since_dt = _parse_dt(since)
|
|
338
|
+
except ValueError as e:
|
|
339
|
+
raise HTTPException(status_code=400, detail=f"since の形式が不正です: {since}") from e
|
|
340
|
+
return _list_results_from_db(config, strategy_id, since_dt)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@router.get("/results/{run_id}")
|
|
344
|
+
async def get_result(run_id: str, request: Request) -> dict[str, Any]:
|
|
345
|
+
config: ForgeConfig = request.app.state.forge_config
|
|
346
|
+
record = _get_result_from_db(config, run_id)
|
|
347
|
+
if record is None:
|
|
348
|
+
raise HTTPException(status_code=404, detail=f"run_id '{run_id}' が見つかりません")
|
|
349
|
+
return _shape_detail(record)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
__all__ = ["router", "_shape_detail"]
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""戦略 API ルーター
|
|
2
|
+
|
|
3
|
+
`/api/strategies`、`/api/strategies/compare`、`/api/strategies/{strategy_id}` を提供する。
|
|
4
|
+
戦略定義の取得元は forge.yaml の ``strategies.use_db`` で切り替わる:
|
|
5
|
+
- ``true``: ``strategies.db`` の ``strategies`` テーブルから読む
|
|
6
|
+
- ``false`` または未設定: ``strategies_dir/*.json`` を glob する(後方互換)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import pathlib
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from fastapi import APIRouter, HTTPException, Query, Request
|
|
16
|
+
from sqlalchemy import create_engine, select
|
|
17
|
+
|
|
18
|
+
from alpha_visualizer.db import backtest_results, optimization_runs, strategies
|
|
19
|
+
from alpha_visualizer.forge_config import ForgeConfig
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load_strategies_from_db(db_path: pathlib.Path) -> list[dict[str, Any]]:
|
|
27
|
+
"""``strategies.db`` から戦略定義を読み取る。"""
|
|
28
|
+
engine = create_engine(f"sqlite:///{db_path}", future=True)
|
|
29
|
+
out: list[dict[str, Any]] = []
|
|
30
|
+
with engine.connect() as conn:
|
|
31
|
+
for row in conn.execute(select(strategies)):
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(row.definition_json)
|
|
34
|
+
except (json.JSONDecodeError, TypeError) as e:
|
|
35
|
+
logger.warning(
|
|
36
|
+
"戦略定義 JSON のパースに失敗: %s (%s)", row.strategy_id, e
|
|
37
|
+
)
|
|
38
|
+
continue
|
|
39
|
+
if not isinstance(data, dict):
|
|
40
|
+
continue
|
|
41
|
+
data.setdefault("strategy_id", row.strategy_id)
|
|
42
|
+
data.setdefault("name", row.name)
|
|
43
|
+
# 戦略定義 JSON に timeframe が無ければ strategies テーブルの列値を使う。
|
|
44
|
+
data.setdefault("timeframe", row.timeframe)
|
|
45
|
+
out.append(data)
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _load_strategies_from_json(strategies_dir: pathlib.Path) -> list[dict[str, Any]]:
|
|
50
|
+
"""``strategies_dir/*.json`` から戦略定義を読み取る(後方互換経路)。"""
|
|
51
|
+
if not strategies_dir.exists():
|
|
52
|
+
return []
|
|
53
|
+
out: list[dict[str, Any]] = []
|
|
54
|
+
for p in sorted(strategies_dir.glob("*.json")):
|
|
55
|
+
try:
|
|
56
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
57
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
58
|
+
logger.warning("戦略ファイルの読み込みをスキップ: %s (%s)", p, e)
|
|
59
|
+
continue
|
|
60
|
+
if not isinstance(data, dict):
|
|
61
|
+
continue
|
|
62
|
+
data.setdefault("strategy_id", p.stem)
|
|
63
|
+
data.setdefault("name", p.stem)
|
|
64
|
+
out.append(data)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_all_strategy_records(config: ForgeConfig) -> list[dict[str, Any]]:
|
|
69
|
+
"""forge.yaml の設定に従って戦略定義の一覧を返す。"""
|
|
70
|
+
if config.strategies_db is not None and config.strategies_db.exists():
|
|
71
|
+
return _load_strategies_from_db(config.strategies_db)
|
|
72
|
+
return _load_strategies_from_json(config.strategies_dir)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _load_strategy_record(
|
|
76
|
+
config: ForgeConfig, strategy_id: str
|
|
77
|
+
) -> dict[str, Any] | None:
|
|
78
|
+
"""指定 strategy_id の戦略定義を取得する。"""
|
|
79
|
+
for record in _load_all_strategy_records(config):
|
|
80
|
+
if record.get("strategy_id") == strategy_id:
|
|
81
|
+
return record
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _get_latest_result(config: ForgeConfig, strategy_id: str) -> dict[str, Any] | None:
|
|
86
|
+
db_path = config.forge_db
|
|
87
|
+
if not db_path.exists():
|
|
88
|
+
return None
|
|
89
|
+
engine = create_engine(f"sqlite:///{db_path}", future=True)
|
|
90
|
+
with engine.connect() as conn:
|
|
91
|
+
row = conn.execute(
|
|
92
|
+
select(
|
|
93
|
+
backtest_results.c.symbol,
|
|
94
|
+
backtest_results.c.sharpe_ratio,
|
|
95
|
+
backtest_results.c.total_return_pct,
|
|
96
|
+
backtest_results.c.max_drawdown_pct,
|
|
97
|
+
backtest_results.c.total_trades,
|
|
98
|
+
backtest_results.c.cagr_pct,
|
|
99
|
+
backtest_results.c.sortino_ratio,
|
|
100
|
+
backtest_results.c.win_rate_pct,
|
|
101
|
+
backtest_results.c.profit_factor,
|
|
102
|
+
backtest_results.c.run_at,
|
|
103
|
+
)
|
|
104
|
+
.where(backtest_results.c.strategy_id == strategy_id)
|
|
105
|
+
.order_by(backtest_results.c.run_at.desc())
|
|
106
|
+
.limit(1)
|
|
107
|
+
).first()
|
|
108
|
+
if row is None:
|
|
109
|
+
return None
|
|
110
|
+
return {
|
|
111
|
+
"symbol": row.symbol,
|
|
112
|
+
"sharpe_ratio": row.sharpe_ratio,
|
|
113
|
+
"total_return_pct": row.total_return_pct,
|
|
114
|
+
"max_drawdown_pct": row.max_drawdown_pct,
|
|
115
|
+
"total_trades": row.total_trades,
|
|
116
|
+
"cagr_pct": row.cagr_pct,
|
|
117
|
+
"sortino_ratio": row.sortino_ratio,
|
|
118
|
+
"win_rate_pct": row.win_rate_pct,
|
|
119
|
+
"profit_factor": row.profit_factor,
|
|
120
|
+
"run_at": row.run_at,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _get_all_results(config: ForgeConfig, strategy_id: str) -> list[dict[str, Any]]:
|
|
125
|
+
db_path = config.forge_db
|
|
126
|
+
if not db_path.exists():
|
|
127
|
+
return []
|
|
128
|
+
engine = create_engine(f"sqlite:///{db_path}", future=True)
|
|
129
|
+
with engine.connect() as conn:
|
|
130
|
+
rows = conn.execute(
|
|
131
|
+
select(
|
|
132
|
+
backtest_results.c.run_id,
|
|
133
|
+
backtest_results.c.symbol,
|
|
134
|
+
backtest_results.c.sharpe_ratio,
|
|
135
|
+
backtest_results.c.total_return_pct,
|
|
136
|
+
backtest_results.c.max_drawdown_pct,
|
|
137
|
+
backtest_results.c.total_trades,
|
|
138
|
+
backtest_results.c.run_at,
|
|
139
|
+
)
|
|
140
|
+
.where(backtest_results.c.strategy_id == strategy_id)
|
|
141
|
+
.order_by(backtest_results.c.run_at.desc())
|
|
142
|
+
).fetchall()
|
|
143
|
+
return [
|
|
144
|
+
{
|
|
145
|
+
"run_id": r.run_id,
|
|
146
|
+
"symbol": r.symbol,
|
|
147
|
+
"sharpe": r.sharpe_ratio,
|
|
148
|
+
"return_pct": r.total_return_pct,
|
|
149
|
+
"max_drawdown_pct": r.max_drawdown_pct,
|
|
150
|
+
"total_trades": r.total_trades,
|
|
151
|
+
"run_at": r.run_at,
|
|
152
|
+
}
|
|
153
|
+
for r in rows
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _get_optimization_history(config: ForgeConfig, strategy_id: str) -> list[dict[str, Any]]:
|
|
158
|
+
db_path = config.forge_db
|
|
159
|
+
if not db_path.exists():
|
|
160
|
+
return []
|
|
161
|
+
engine = create_engine(f"sqlite:///{db_path}", future=True)
|
|
162
|
+
with engine.connect() as conn:
|
|
163
|
+
rows = conn.execute(
|
|
164
|
+
select(
|
|
165
|
+
optimization_runs.c.best_metric_value,
|
|
166
|
+
optimization_runs.c.run_at,
|
|
167
|
+
optimization_runs.c.n_trials,
|
|
168
|
+
)
|
|
169
|
+
.where(optimization_runs.c.strategy_id == strategy_id)
|
|
170
|
+
.order_by(optimization_runs.c.run_at.desc())
|
|
171
|
+
).fetchall()
|
|
172
|
+
history = []
|
|
173
|
+
for i, row in enumerate(reversed(rows), start=1):
|
|
174
|
+
history.append({
|
|
175
|
+
"trial": i,
|
|
176
|
+
"best_sharpe": row.best_metric_value,
|
|
177
|
+
"run_at": row.run_at,
|
|
178
|
+
"n_trials": row.n_trials,
|
|
179
|
+
})
|
|
180
|
+
return history
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@router.get("/strategies")
|
|
184
|
+
async def list_strategies(request: Request) -> list[dict[str, Any]]:
|
|
185
|
+
config: ForgeConfig = request.app.state.forge_config
|
|
186
|
+
result: list[dict[str, Any]] = []
|
|
187
|
+
for record in _load_all_strategy_records(config):
|
|
188
|
+
sid = record.get("strategy_id")
|
|
189
|
+
if not isinstance(sid, str):
|
|
190
|
+
continue
|
|
191
|
+
entry: dict[str, Any] = {
|
|
192
|
+
"strategy_id": sid,
|
|
193
|
+
"name": record.get("name", sid),
|
|
194
|
+
"symbol": None,
|
|
195
|
+
"timeframe": record.get("timeframe"),
|
|
196
|
+
"latest_sharpe": None,
|
|
197
|
+
"latest_return_pct": None,
|
|
198
|
+
"latest_max_drawdown_pct": None,
|
|
199
|
+
"latest_profit_factor": None,
|
|
200
|
+
"latest_win_rate_pct": None,
|
|
201
|
+
"latest_total_trades": None,
|
|
202
|
+
"last_run_at": None,
|
|
203
|
+
}
|
|
204
|
+
latest = _get_latest_result(config, sid)
|
|
205
|
+
if latest:
|
|
206
|
+
entry["symbol"] = latest.get("symbol")
|
|
207
|
+
entry["latest_sharpe"] = latest.get("sharpe_ratio")
|
|
208
|
+
entry["latest_return_pct"] = latest.get("total_return_pct")
|
|
209
|
+
entry["latest_max_drawdown_pct"] = latest.get("max_drawdown_pct")
|
|
210
|
+
entry["latest_profit_factor"] = latest.get("profit_factor")
|
|
211
|
+
entry["latest_win_rate_pct"] = latest.get("win_rate_pct")
|
|
212
|
+
entry["latest_total_trades"] = latest.get("total_trades")
|
|
213
|
+
entry["last_run_at"] = latest.get("run_at")
|
|
214
|
+
result.append(entry)
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@router.get("/strategies/compare")
|
|
219
|
+
async def compare_strategies(
|
|
220
|
+
request: Request,
|
|
221
|
+
ids: str = Query(..., description="カンマ区切りの strategy_id"),
|
|
222
|
+
) -> list[dict[str, Any]]:
|
|
223
|
+
config: ForgeConfig = request.app.state.forge_config
|
|
224
|
+
parsed = [s for s in (i.strip() for i in ids.split(",")) if s]
|
|
225
|
+
if not parsed:
|
|
226
|
+
raise HTTPException(status_code=400, detail="ids が空です")
|
|
227
|
+
out: list[dict[str, Any]] = []
|
|
228
|
+
for idx, sid in enumerate(parsed):
|
|
229
|
+
latest = _get_latest_result(config, sid)
|
|
230
|
+
if not latest:
|
|
231
|
+
continue
|
|
232
|
+
record = _load_strategy_record(config, sid)
|
|
233
|
+
name = record.get("name", sid) if record else sid
|
|
234
|
+
out.append(
|
|
235
|
+
{
|
|
236
|
+
"id": sid,
|
|
237
|
+
"name": name,
|
|
238
|
+
"symbol": latest.get("symbol", ""),
|
|
239
|
+
"total_return_pct": float(latest.get("total_return_pct") or 0.0),
|
|
240
|
+
"cagr_pct": float(latest.get("cagr_pct") or 0.0),
|
|
241
|
+
"sharpe_ratio": float(latest.get("sharpe_ratio") or 0.0),
|
|
242
|
+
"sortino_ratio": float(latest.get("sortino_ratio") or 0.0),
|
|
243
|
+
"max_drawdown_pct": float(latest.get("max_drawdown_pct") or 0.0),
|
|
244
|
+
"win_rate_pct": float(latest.get("win_rate_pct") or 0.0),
|
|
245
|
+
"profit_factor": float(latest.get("profit_factor") or 0.0),
|
|
246
|
+
"total_trades": int(latest.get("total_trades") or 0),
|
|
247
|
+
"is_baseline": idx == 0,
|
|
248
|
+
}
|
|
249
|
+
)
|
|
250
|
+
if not out:
|
|
251
|
+
raise HTTPException(
|
|
252
|
+
status_code=404,
|
|
253
|
+
detail=f"指定した戦略のバックテスト結果が見つかりません: {parsed}",
|
|
254
|
+
)
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
@router.get("/strategies/{strategy_id}")
|
|
259
|
+
async def get_strategy(strategy_id: str, request: Request) -> dict[str, Any]:
|
|
260
|
+
config: ForgeConfig = request.app.state.forge_config
|
|
261
|
+
record = _load_strategy_record(config, strategy_id)
|
|
262
|
+
if record is None:
|
|
263
|
+
raise HTTPException(
|
|
264
|
+
status_code=404, detail=f"strategy_id '{strategy_id}' が見つかりません"
|
|
265
|
+
)
|
|
266
|
+
return {
|
|
267
|
+
"strategy_id": record.get("strategy_id", strategy_id),
|
|
268
|
+
"name": record.get("name", strategy_id),
|
|
269
|
+
"parameters": record.get("parameters", {}),
|
|
270
|
+
"results": _get_all_results(config, strategy_id),
|
|
271
|
+
"optimization_history": _get_optimization_history(config, strategy_id),
|
|
272
|
+
}
|