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.
Files changed (30) hide show
  1. alpha_visualizer/__init__.py +3 -0
  2. alpha_visualizer/app.py +73 -0
  3. alpha_visualizer/cli.py +66 -0
  4. alpha_visualizer/db.py +61 -0
  5. alpha_visualizer/forge_config.py +133 -0
  6. alpha_visualizer/routers/__init__.py +0 -0
  7. alpha_visualizer/routers/ideas.py +57 -0
  8. alpha_visualizer/routers/results.py +352 -0
  9. alpha_visualizer/routers/strategies.py +272 -0
  10. alpha_visualizer/routers/wfo.py +126 -0
  11. alpha_visualizer/static/assets/index-16K3BxVS.css +1 -0
  12. alpha_visualizer/static/assets/index-dB6krL5g.js +11 -0
  13. alpha_visualizer/static/assets/index-dB6krL5g.js.map +1 -0
  14. alpha_visualizer/static/assets/inter-tight-latin-400-normal-BLrFJfvD.woff +0 -0
  15. alpha_visualizer/static/assets/inter-tight-latin-400-normal-iW8qmuJY.woff2 +0 -0
  16. alpha_visualizer/static/assets/inter-tight-latin-500-normal-BFXNXuvF.woff2 +0 -0
  17. alpha_visualizer/static/assets/inter-tight-latin-500-normal-pobXraBK.woff +0 -0
  18. alpha_visualizer/static/assets/inter-tight-latin-600-normal-BgSTtRxb.woff2 +0 -0
  19. alpha_visualizer/static/assets/inter-tight-latin-600-normal-D7bG6gX1.woff +0 -0
  20. alpha_visualizer/static/assets/jetbrains-mono-latin-500-normal-BWZEU5yA.woff2 +0 -0
  21. alpha_visualizer/static/assets/jetbrains-mono-latin-500-normal-CJOVTJB7.woff +0 -0
  22. alpha_visualizer/static/assets/source-serif-4-latin-600-normal-DMD1h6_f.woff +0 -0
  23. alpha_visualizer/static/assets/source-serif-4-latin-600-normal-DouSKlru.woff2 +0 -0
  24. alpha_visualizer/static/favicon.svg +1 -0
  25. alpha_visualizer/static/icons.svg +24 -0
  26. alpha_visualizer/static/index.html +33 -0
  27. alpha_visualizer-0.1.0.dist-info/METADATA +66 -0
  28. alpha_visualizer-0.1.0.dist-info/RECORD +30 -0
  29. alpha_visualizer-0.1.0.dist-info/WHEEL +4 -0
  30. 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
+ }