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,3 @@
1
+ """alpha-visualizer: AlphaForge バックテスト結果の Web 可視化ツール"""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,73 @@
1
+ """FastAPI アプリケーションファクトリ"""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+
6
+ from fastapi import FastAPI
7
+ from fastapi.responses import FileResponse, JSONResponse
8
+
9
+ from alpha_visualizer.forge_config import ForgeConfig
10
+ from alpha_visualizer.routers import ideas as ideas_router
11
+ from alpha_visualizer.routers import results as results_router
12
+ from alpha_visualizer.routers import strategies as strategies_router
13
+ from alpha_visualizer.routers import wfo as wfo_router
14
+
15
+
16
+ def create_app(
17
+ forge_dir: pathlib.Path | None = None,
18
+ *,
19
+ config: ForgeConfig | None = None,
20
+ ) -> FastAPI:
21
+ """FastAPI アプリを生成する。
22
+
23
+ 引数は次のいずれかを満たす必要がある:
24
+ - ``config``: 解決済みの ``ForgeConfig`` を直接渡す(推奨)
25
+ - ``forge_dir``: ディレクトリパスを渡し、内部で ``ForgeConfig.from_forge_dir`` を呼ぶ
26
+ (後方互換)
27
+
28
+ 両方渡された場合は ``config`` が優先される。
29
+ """
30
+ if config is None:
31
+ if forge_dir is None:
32
+ raise ValueError("forge_dir または config のいずれかを指定してください")
33
+ config = ForgeConfig.from_forge_dir(pathlib.Path(forge_dir))
34
+
35
+ app = FastAPI(
36
+ title="alpha-visualizer",
37
+ description="AlphaForge バックテスト結果の Web 可視化ツール",
38
+ version="0.1.0",
39
+ )
40
+ app.state.forge_config = config
41
+
42
+ app.include_router(results_router.router, prefix="/api")
43
+ app.include_router(strategies_router.router, prefix="/api")
44
+ app.include_router(ideas_router.router, prefix="/api")
45
+ app.include_router(wfo_router.router, prefix="/api")
46
+
47
+ forge_dir_str = str(config.forge_dir)
48
+
49
+ @app.get("/health")
50
+ async def health() -> JSONResponse:
51
+ return JSONResponse({"status": "ok", "forge_dir": forge_dir_str})
52
+
53
+ static_dir = pathlib.Path(__file__).parent / "static"
54
+ if static_dir.exists():
55
+ index_html = static_dir / "index.html"
56
+ static_root = static_dir.resolve()
57
+
58
+ @app.get("/{full_path:path}")
59
+ async def spa_fallback(full_path: str) -> FileResponse:
60
+ """SPA ルート対応: /api 配下以外で実ファイルがあればそれを返し、
61
+ 無ければ index.html を返して history mode の直アクセス・リロードに対応する。
62
+ """
63
+ requested = (static_dir / full_path).resolve()
64
+ # ディレクトリトラバーサル対策
65
+ try:
66
+ requested.relative_to(static_root)
67
+ except ValueError:
68
+ return FileResponse(index_html)
69
+ if requested.is_file():
70
+ return FileResponse(requested)
71
+ return FileResponse(index_html)
72
+
73
+ return app
@@ -0,0 +1,66 @@
1
+ """vis CLI エントリーポイント"""
2
+
3
+ import click
4
+
5
+ from alpha_visualizer import __version__
6
+
7
+
8
+ @click.group()
9
+ @click.version_option(version=__version__, prog_name="alpha-visualizer")
10
+ def cli() -> None:
11
+ """alpha-visualizer - AlphaForge バックテスト結果の Web 可視化ツール"""
12
+
13
+
14
+ @cli.command("serve")
15
+ @click.option("--host", default="127.0.0.1", show_default=True, help="バインドするホスト名")
16
+ @click.option("--port", default=8000, show_default=True, help="ポート番号")
17
+ @click.option(
18
+ "--forge-dir",
19
+ default=".",
20
+ show_default=True,
21
+ type=click.Path(exists=True, file_okay=False, dir_okay=True),
22
+ help="forge が生成するデータディレクトリのパス",
23
+ )
24
+ @click.option(
25
+ "--forge-config",
26
+ "forge_config",
27
+ default=None,
28
+ type=click.Path(exists=False, file_okay=True, dir_okay=False),
29
+ help="forge.yaml のパス。未指定なら $FORGE_CONFIG → <forge-dir>/forge.yaml の順で探索",
30
+ )
31
+ @click.option("--no-open", "no_open", is_flag=True, default=False, help="ブラウザを自動で開かない")
32
+ def serve(
33
+ host: str,
34
+ port: int,
35
+ forge_dir: str,
36
+ forge_config: str | None,
37
+ no_open: bool,
38
+ ) -> None:
39
+ """Web ダッシュボードを起動する"""
40
+ import pathlib
41
+
42
+ import uvicorn
43
+
44
+ from alpha_visualizer.app import create_app
45
+ from alpha_visualizer.forge_config import ForgeConfig
46
+
47
+ forge_path = pathlib.Path(forge_dir).resolve()
48
+ config_path = pathlib.Path(forge_config).resolve() if forge_config else None
49
+ config = ForgeConfig.from_forge_dir(forge_path, config_path=config_path)
50
+ app = create_app(config=config)
51
+
52
+ url = f"http://{host}:{port}"
53
+ click.echo(f"vis serve: {url} (Ctrl+C で停止)")
54
+ click.echo(f"forge-dir: {forge_path}")
55
+ click.echo(f"forge-db: {config.forge_db}")
56
+ if config.strategies_db is not None:
57
+ click.echo(f"strategies-db: {config.strategies_db}")
58
+ else:
59
+ click.echo(f"strategies-dir: {config.strategies_dir} (JSON モード)")
60
+
61
+ if not no_open:
62
+ import webbrowser
63
+ webbrowser.open(url)
64
+
65
+ uvicorn.run(app, host=host, port=port)
66
+ click.echo("vis serve を停止しました。")
alpha_visualizer/db.py ADDED
@@ -0,0 +1,61 @@
1
+ """forge.db / strategies.db の SQLAlchemy テーブル定義(読み取り専用)"""
2
+
3
+ from sqlalchemy import REAL, Column, Integer, MetaData, Table, Text
4
+
5
+ metadata = MetaData()
6
+
7
+ strategies = Table(
8
+ "strategies",
9
+ metadata,
10
+ Column("id", Integer, primary_key=True),
11
+ Column("strategy_id", Text, nullable=False, unique=True),
12
+ Column("name", Text, nullable=False),
13
+ Column("version", Text),
14
+ Column("asset_type", Text),
15
+ Column("timeframe", Text),
16
+ Column("tags", Text),
17
+ Column("notes", Text),
18
+ Column("definition_json", Text, nullable=False),
19
+ Column("source_file", Text),
20
+ Column("created_at", Text),
21
+ Column("updated_at", Text),
22
+ )
23
+
24
+ backtest_results = Table(
25
+ "backtest_results",
26
+ metadata,
27
+ Column("run_id", Text, primary_key=True),
28
+ Column("strategy_id", Text),
29
+ Column("symbol", Text),
30
+ Column("run_at", Text),
31
+ Column("total_return_pct", REAL),
32
+ Column("cagr_pct", REAL),
33
+ Column("sharpe_ratio", REAL),
34
+ Column("sortino_ratio", REAL),
35
+ Column("calmar_ratio", REAL),
36
+ Column("max_drawdown_pct", REAL),
37
+ Column("total_trades", Integer),
38
+ Column("win_rate_pct", REAL),
39
+ Column("profit_factor", REAL),
40
+ Column("avg_holding_days", REAL),
41
+ Column("metrics_json", Text),
42
+ Column("equity_curve_json", Text),
43
+ Column("buy_hold_curve_json", Text),
44
+ Column("trades_json", Text),
45
+ Column("oos_start", Text),
46
+ )
47
+
48
+ optimization_runs = Table(
49
+ "optimization_runs",
50
+ metadata,
51
+ Column("run_id", Text, primary_key=True),
52
+ Column("strategy_id", Text),
53
+ Column("symbol", Text),
54
+ Column("run_at", Text),
55
+ Column("n_trials", Integer),
56
+ Column("best_metric_name", Text),
57
+ Column("best_metric_value", REAL),
58
+ Column("best_params_json", Text),
59
+ Column("duration_seconds", REAL),
60
+ Column("all_trials_json", Text),
61
+ )
@@ -0,0 +1,133 @@
1
+ """forge.yaml を読み取り、forge が生成するディレクトリ構造からパスを解決するデータクラス
2
+
3
+ forge.yaml の探索順序:
4
+ 1. 引数 ``config_path`` が明示指定されていればそれ
5
+ 2. 環境変数 ``FORGE_CONFIG`` が指すパス(存在する場合のみ)
6
+ 3. ``<forge_dir>/forge.yaml``
7
+ 4. 見つからなければ既存ハードコード値(後方互換)
8
+
9
+ forge.yaml 内の相対パスは、forge.yaml ファイルの親ディレクトリ基準で絶対化される。
10
+ これは alpha-forge の ``alpha_forge/config.py:_resolve_paths`` と同じ規約。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import pathlib
17
+ from dataclasses import dataclass
18
+ from typing import Any
19
+
20
+ import yaml
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ForgeConfig:
25
+ """forge プロジェクトのパス解決結果。
26
+
27
+ すべてのパスは ``from_forge_dir`` ファクトリで構築時に解決済みの絶対パスになる。
28
+ """
29
+
30
+ forge_dir: pathlib.Path
31
+ forge_db: pathlib.Path
32
+ strategies_dir: pathlib.Path
33
+ strategies_db: pathlib.Path | None
34
+ ideas_json: pathlib.Path
35
+
36
+ @classmethod
37
+ def from_forge_dir(
38
+ cls,
39
+ forge_dir: pathlib.Path,
40
+ config_path: pathlib.Path | None = None,
41
+ ) -> ForgeConfig:
42
+ """forge.yaml を読み込んで設定を構築する。
43
+
44
+ Args:
45
+ forge_dir: forge プロジェクトのルートディレクトリ。
46
+ config_path: 明示指定する forge.yaml のパス(最優先)。
47
+
48
+ Returns:
49
+ 解決済みパスを持つ ``ForgeConfig`` インスタンス。
50
+ """
51
+ forge_dir = pathlib.Path(forge_dir).resolve()
52
+ yaml_path = _resolve_yaml_path(forge_dir, config_path)
53
+ raw = _load_yaml(yaml_path) if yaml_path is not None else {}
54
+
55
+ # 相対パス解決の基準は forge.yaml の親ディレクトリ。
56
+ # forge.yaml が見つからない場合は forge_dir を基準にする(後方互換)。
57
+ base = yaml_path.parent if yaml_path is not None else forge_dir
58
+
59
+ report = raw.get("report") or {}
60
+ strategies = raw.get("strategies") or {}
61
+ ideas = raw.get("ideas") or {}
62
+
63
+ report_output = _resolve_path(
64
+ base, report.get("output_path"), default=forge_dir / "data" / "results"
65
+ )
66
+ forge_db_filename = report.get("db_filename") or "forge.db"
67
+ forge_db = report_output / forge_db_filename
68
+
69
+ strategies_path = _resolve_path(
70
+ base, strategies.get("path"), default=forge_dir / "data" / "strategies"
71
+ )
72
+ strategies_db: pathlib.Path | None = None
73
+ if bool(strategies.get("use_db", False)):
74
+ strategies_db_filename = strategies.get("db_filename") or "strategies.db"
75
+ strategies_db = strategies_path / strategies_db_filename
76
+
77
+ ideas_path = _resolve_path(
78
+ base, ideas.get("ideas_path"), default=forge_dir / "data" / "ideas"
79
+ )
80
+ ideas_json = ideas_path / "ideas.json"
81
+
82
+ return cls(
83
+ forge_dir=forge_dir,
84
+ forge_db=forge_db,
85
+ strategies_dir=strategies_path,
86
+ strategies_db=strategies_db,
87
+ ideas_json=ideas_json,
88
+ )
89
+
90
+
91
+ def _resolve_yaml_path(
92
+ forge_dir: pathlib.Path, explicit: pathlib.Path | None
93
+ ) -> pathlib.Path | None:
94
+ """forge.yaml の最終的な探索結果を返す(見つからなければ None)。"""
95
+ if explicit is not None:
96
+ path = pathlib.Path(explicit).resolve()
97
+ return path if path.is_file() else None
98
+
99
+ env_value = os.environ.get("FORGE_CONFIG")
100
+ if env_value:
101
+ path = pathlib.Path(env_value).expanduser().resolve()
102
+ if path.is_file():
103
+ return path
104
+
105
+ candidate = (forge_dir / "forge.yaml").resolve()
106
+ return candidate if candidate.is_file() else None
107
+
108
+
109
+ def _load_yaml(path: pathlib.Path) -> dict[str, Any]:
110
+ """forge.yaml を安全にロードする。空ファイルや非辞書ルートは ``{}`` 扱い。"""
111
+ try:
112
+ text = path.read_text(encoding="utf-8")
113
+ except OSError:
114
+ return {}
115
+ raw = yaml.safe_load(text)
116
+ if isinstance(raw, dict):
117
+ return raw
118
+ return {}
119
+
120
+
121
+ def _resolve_path(
122
+ base: pathlib.Path, value: Any, *, default: pathlib.Path
123
+ ) -> pathlib.Path:
124
+ """forge.yaml の相対パス文字列を ``base`` 基準で絶対化する。
125
+
126
+ 値が ``None`` または空のときは ``default`` をそのまま返す。
127
+ """
128
+ if value in (None, ""):
129
+ return default.resolve()
130
+ candidate = pathlib.Path(str(value)).expanduser()
131
+ if candidate.is_absolute():
132
+ return candidate.resolve()
133
+ return (base / candidate).resolve()
File without changes
@@ -0,0 +1,57 @@
1
+ """アイデア API ルーター
2
+
3
+ `/api/ideas` と `/api/ideas/{idea_id}` を提供する。
4
+ ForgeConfig.ideas_json から JSON を直接読み取る。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import logging
10
+ from typing import Any
11
+
12
+ from fastapi import APIRouter, HTTPException, Query, Request
13
+
14
+ from alpha_visualizer.forge_config import ForgeConfig
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ router = APIRouter()
19
+
20
+
21
+ def _load_ideas(config: ForgeConfig) -> list[dict[str, Any]]:
22
+ ideas_path = config.ideas_json
23
+ if not ideas_path.exists():
24
+ return []
25
+ try:
26
+ data = json.loads(ideas_path.read_text(encoding="utf-8"))
27
+ except (json.JSONDecodeError, OSError) as e:
28
+ logger.warning("ideas.json の読み込みに失敗: %s", e)
29
+ return []
30
+ if isinstance(data, list):
31
+ return data
32
+ # {"ideas": [...]} 形式にも対応
33
+ if isinstance(data, dict):
34
+ return data.get("ideas", [])
35
+ return []
36
+
37
+
38
+ @router.get("/ideas")
39
+ async def list_ideas(
40
+ request: Request,
41
+ status: str | None = Query(default=None),
42
+ ) -> list[dict[str, Any]]:
43
+ config: ForgeConfig = request.app.state.forge_config
44
+ ideas = _load_ideas(config)
45
+ if status is not None:
46
+ ideas = [i for i in ideas if isinstance(i, dict) and i.get("status") == status]
47
+ return ideas
48
+
49
+
50
+ @router.get("/ideas/{idea_id}")
51
+ async def get_idea(idea_id: str, request: Request) -> dict[str, Any]:
52
+ config: ForgeConfig = request.app.state.forge_config
53
+ ideas = _load_ideas(config)
54
+ for idea in ideas:
55
+ if isinstance(idea, dict) and idea.get("idea_id") == idea_id:
56
+ return idea
57
+ raise HTTPException(status_code=404, detail=f"idea_id '{idea_id}' が見つかりません")