bitbank-lab-mcp 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +388 -0
- package/assets/lightweight-charts.standalone.js +7 -0
- package/bin/bitbank-lab-mcp.js +20 -0
- package/lib/cache.ts +70 -0
- package/lib/candle-utils.ts +48 -0
- package/lib/candle-validate.ts +434 -0
- package/lib/conversions.ts +25 -0
- package/lib/datetime.ts +157 -0
- package/lib/depth-analysis.ts +51 -0
- package/lib/error.ts +15 -0
- package/lib/formatter.ts +296 -0
- package/lib/get-depth.ts +111 -0
- package/lib/http.ts +132 -0
- package/lib/indicator-config.ts +39 -0
- package/lib/indicator_buffer.ts +41 -0
- package/lib/indicators.ts +579 -0
- package/lib/logger.ts +120 -0
- package/lib/ma-snapshot-utils.ts +277 -0
- package/lib/math.ts +89 -0
- package/lib/pattern-diagrams.ts +562 -0
- package/lib/result.ts +104 -0
- package/lib/validate.ts +154 -0
- package/lib/volatility.ts +132 -0
- package/package.json +79 -0
- package/src/env.ts +4 -0
- package/src/handlers/analyzeCandlePatternsHandler.ts +383 -0
- package/src/handlers/analyzeFibonacciHandler.ts +54 -0
- package/src/handlers/analyzeIndicatorsHandler.ts +682 -0
- package/src/handlers/analyzeMarketSignalHandler.ts +272 -0
- package/src/handlers/analyzeMyPortfolioHandler.ts +800 -0
- package/src/handlers/detectPatternsHandler.ts +77 -0
- package/src/handlers/detectPatternsViewsHandler.ts +518 -0
- package/src/handlers/getTickersJpyHandler.ts +145 -0
- package/src/handlers/getVolatilityMetricsHandler.ts +234 -0
- package/src/handlers/portfolio/calc.ts +549 -0
- package/src/handlers/portfolio/fetch.ts +318 -0
- package/src/handlers/portfolio/types.ts +170 -0
- package/src/handlers/renderChartSvgHandler.ts +69 -0
- package/src/handlers/runBacktestHandler.ts +70 -0
- package/src/http.ts +107 -0
- package/src/private/auth.ts +104 -0
- package/src/private/client.ts +298 -0
- package/src/private/config.ts +25 -0
- package/src/private/confirmation.ts +185 -0
- package/src/private/schemas.ts +866 -0
- package/src/prompts.ts +2296 -0
- package/src/resources/app-resources.ts +79 -0
- package/src/schema/analysis.ts +942 -0
- package/src/schema/backtest.ts +100 -0
- package/src/schema/base.ts +88 -0
- package/src/schema/candle-validate.ts +135 -0
- package/src/schema/chart.ts +399 -0
- package/src/schema/index.ts +11 -0
- package/src/schema/indicators.ts +125 -0
- package/src/schema/market-data.ts +298 -0
- package/src/schema/patterns.ts +382 -0
- package/src/schema/types.ts +97 -0
- package/src/schemas.d.ts +37 -0
- package/src/schemas.ts +7 -0
- package/src/server.ts +405 -0
- package/src/tool-definition.ts +44 -0
- package/src/tool-registry.ts +174 -0
- package/src/types/express-shim.d.ts +9 -0
- package/src/types/schemas.generated.d.ts +23 -0
- package/tools/analyze_bb_snapshot.ts +385 -0
- package/tools/analyze_candle_patterns.ts +810 -0
- package/tools/analyze_currency_strength.ts +273 -0
- package/tools/analyze_ema_snapshot.ts +183 -0
- package/tools/analyze_fibonacci.ts +530 -0
- package/tools/analyze_ichimoku_snapshot.ts +606 -0
- package/tools/analyze_indicators.ts +691 -0
- package/tools/analyze_market_signal.ts +665 -0
- package/tools/analyze_mtf_fibonacci.ts +273 -0
- package/tools/analyze_mtf_sma.ts +175 -0
- package/tools/analyze_sma_snapshot.ts +146 -0
- package/tools/analyze_stoch_snapshot.ts +276 -0
- package/tools/analyze_support_resistance.ts +817 -0
- package/tools/analyze_volume_profile.ts +546 -0
- package/tools/chart/ichimoku-cloud.ts +113 -0
- package/tools/chart/render-depth.ts +139 -0
- package/tools/chart/render-sub-panels.ts +208 -0
- package/tools/chart/svg-utils.ts +102 -0
- package/tools/detect_macd_cross.ts +691 -0
- package/tools/detect_patterns.ts +424 -0
- package/tools/detect_whale_events.ts +181 -0
- package/tools/get_candles.ts +487 -0
- package/tools/get_flow_metrics.ts +596 -0
- package/tools/get_orderbook.ts +540 -0
- package/tools/get_ticker.ts +132 -0
- package/tools/get_tickers_jpy.ts +240 -0
- package/tools/get_transactions.ts +209 -0
- package/tools/get_volatility_metrics.ts +302 -0
- package/tools/patterns/aftermath.ts +212 -0
- package/tools/patterns/config.ts +151 -0
- package/tools/patterns/detect_doubles.ts +650 -0
- package/tools/patterns/detect_hs.ts +635 -0
- package/tools/patterns/detect_pennants.ts +373 -0
- package/tools/patterns/detect_triangles.ts +820 -0
- package/tools/patterns/detect_triples.ts +633 -0
- package/tools/patterns/detect_wedges.ts +1072 -0
- package/tools/patterns/helpers.ts +517 -0
- package/tools/patterns/index.ts +40 -0
- package/tools/patterns/regression.ts +153 -0
- package/tools/patterns/smoothing.ts +168 -0
- package/tools/patterns/swing.ts +91 -0
- package/tools/patterns/types.ts +193 -0
- package/tools/prepare_chart_data.ts +294 -0
- package/tools/prepare_depth_data.ts +189 -0
- package/tools/private/analyze_my_portfolio.ts +21 -0
- package/tools/private/cancel_order.ts +127 -0
- package/tools/private/cancel_orders.ts +121 -0
- package/tools/private/create_order.ts +236 -0
- package/tools/private/get_margin_positions.ts +134 -0
- package/tools/private/get_margin_status.ts +155 -0
- package/tools/private/get_margin_trade_history.ts +156 -0
- package/tools/private/get_my_assets.ts +207 -0
- package/tools/private/get_my_deposit_withdrawal.ts +500 -0
- package/tools/private/get_my_orders.ts +157 -0
- package/tools/private/get_my_trade_history.ts +229 -0
- package/tools/private/get_order.ts +95 -0
- package/tools/private/get_orders_info.ts +90 -0
- package/tools/private/preview_cancel_order.ts +172 -0
- package/tools/private/preview_cancel_orders.ts +137 -0
- package/tools/private/preview_order.ts +292 -0
- package/tools/render_candle_pattern_diagram.ts +389 -0
- package/tools/render_chart_svg.ts +799 -0
- package/tools/render_depth_svg.ts +274 -0
- package/tools/trading_process/index.ts +7 -0
- package/tools/trading_process/lib/backtest_engine.ts +252 -0
- package/tools/trading_process/lib/equity.ts +131 -0
- package/tools/trading_process/lib/fetch_candles.ts +181 -0
- package/tools/trading_process/lib/sma.ts +62 -0
- package/tools/trading_process/lib/strategies/bb_breakout.ts +141 -0
- package/tools/trading_process/lib/strategies/index.ts +52 -0
- package/tools/trading_process/lib/strategies/macd_cross.ts +256 -0
- package/tools/trading_process/lib/strategies/rsi.ts +133 -0
- package/tools/trading_process/lib/strategies/sma_cross.ts +214 -0
- package/tools/trading_process/lib/strategies/types.ts +118 -0
- package/tools/trading_process/lib/svg_to_png.ts +64 -0
- package/tools/trading_process/render_backtest_chart_generic.ts +729 -0
- package/tools/trading_process/run_backtest.ts +243 -0
- package/tools/trading_process/types.ts +85 -0
- package/tools/validate_candle_data.ts +260 -0
- package/tsconfig.json +17 -0
- package/ui/cancel-confirm/dist/cancel-confirm.html +99 -0
- package/ui/order-confirm/dist/order-confirm.html +99 -0
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* render_backtest_chart_generic.ts - 汎用バックテストチャート描画
|
|
3
|
+
*
|
|
4
|
+
* 2つのモード:
|
|
5
|
+
* - default: エクイティ + ドローダウンのみ(軽量、トークン節約)
|
|
6
|
+
* - full: 4段チャート(価格 + オーバーレイ + シグナル / エクイティ / ドローダウン / ポジション)
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { dayjs } from '../../lib/datetime.js';
|
|
10
|
+
import type { BacktestEngineSummary } from './lib/backtest_engine.js';
|
|
11
|
+
import type { Overlay } from './lib/strategies/types.js';
|
|
12
|
+
import type { Candle, DrawdownPoint, EquityPoint, Trade } from './types.js';
|
|
13
|
+
|
|
14
|
+
export type ChartDetail = 'default' | 'full';
|
|
15
|
+
|
|
16
|
+
// === 固定配色 ===
|
|
17
|
+
const COLORS = {
|
|
18
|
+
background: '#1a1a2e',
|
|
19
|
+
grid: '#2d2d44',
|
|
20
|
+
text: '#e0e0e0',
|
|
21
|
+
textMuted: '#888899',
|
|
22
|
+
price: '#60a5fa',
|
|
23
|
+
entryMarker: '#22c55e',
|
|
24
|
+
exitMarker: '#ef4444',
|
|
25
|
+
strategy: '#3b82f6',
|
|
26
|
+
buyHold: '#9ca3af',
|
|
27
|
+
drawdown: '#f87171',
|
|
28
|
+
drawdownFill: 'rgba(248, 113, 113, 0.4)',
|
|
29
|
+
positionLong: '#4ade80',
|
|
30
|
+
zeroline: '#4b5563',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// オーバーレイ用のカラーパレット
|
|
34
|
+
const OVERLAY_COLORS = [
|
|
35
|
+
'#fbbf24', // yellow
|
|
36
|
+
'#a78bfa', // purple
|
|
37
|
+
'#34d399', // green
|
|
38
|
+
'#f472b6', // pink
|
|
39
|
+
'#60a5fa', // blue
|
|
40
|
+
'#fb923c', // orange
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
// === 固定レイアウト(full モード)===
|
|
44
|
+
const LAYOUT_FULL = {
|
|
45
|
+
width: 1000,
|
|
46
|
+
margin: { top: 70, right: 120, bottom: 40, left: 80 },
|
|
47
|
+
priceHeight: 250,
|
|
48
|
+
indicatorHeight: 150,
|
|
49
|
+
equityHeight: 180,
|
|
50
|
+
drawdownHeight: 100,
|
|
51
|
+
positionHeight: 50,
|
|
52
|
+
legendHeight: 25,
|
|
53
|
+
gapBetweenPanels: 40,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// === 軽量レイアウト(default モード)===
|
|
57
|
+
const LAYOUT_MINIMAL = {
|
|
58
|
+
width: 700,
|
|
59
|
+
height: 350,
|
|
60
|
+
margin: { top: 60, right: 100, bottom: 40, left: 70 },
|
|
61
|
+
equityHeight: 150,
|
|
62
|
+
drawdownHeight: 80,
|
|
63
|
+
gapBetweenPanels: 30,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export interface GenericBacktestChartData {
|
|
67
|
+
candles: Candle[];
|
|
68
|
+
overlays: Overlay[];
|
|
69
|
+
trades: Trade[];
|
|
70
|
+
equity_curve: EquityPoint[];
|
|
71
|
+
drawdown_curve: DrawdownPoint[];
|
|
72
|
+
input: {
|
|
73
|
+
pair: string;
|
|
74
|
+
timeframe: string;
|
|
75
|
+
period: string;
|
|
76
|
+
strategyName: string;
|
|
77
|
+
strategyParams: Record<string, number>;
|
|
78
|
+
fee_bp: number;
|
|
79
|
+
};
|
|
80
|
+
summary: BacktestEngineSummary;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function formatPrice(price: number): string {
|
|
84
|
+
if (price >= 1000000) return `${(price / 1000000).toFixed(2)}M`;
|
|
85
|
+
if (price >= 1000) return `${(price / 1000).toFixed(1)}K`;
|
|
86
|
+
return price.toFixed(0);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatPct(pct: number): string {
|
|
90
|
+
return `${pct >= 0 ? '+' : ''}${pct.toFixed(1)}%`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* データの期間に応じて適切な日付フォーマットを選択
|
|
95
|
+
* @param spanDays データの期間(日数)
|
|
96
|
+
* @returns 'full' (YYYY-MM-DD), 'month-day' (MM/DD), 'year-month' (YYYY-MM)
|
|
97
|
+
*/
|
|
98
|
+
function getDateFormat(spanDays: number): 'full' | 'month-day' | 'year-month' {
|
|
99
|
+
if (spanDays <= 60) return 'month-day'; // 2ヶ月以下: MM/DD
|
|
100
|
+
if (spanDays <= 180) return 'full'; // 6ヶ月以下: YYYY-MM-DD
|
|
101
|
+
return 'year-month'; // それ以上: YYYY-MM
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function formatDateBySpan(isoTime: string, format: 'full' | 'month-day' | 'year-month'): string {
|
|
105
|
+
const d = dayjs(isoTime);
|
|
106
|
+
switch (format) {
|
|
107
|
+
case 'month-day':
|
|
108
|
+
return d.format('MM/DD');
|
|
109
|
+
case 'full':
|
|
110
|
+
return d.format('YYYY-MM-DD');
|
|
111
|
+
default:
|
|
112
|
+
return d.format('YYYY-MM');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function calculateSpanDays(candles: Candle[]): number {
|
|
117
|
+
if (candles.length < 2) return 1;
|
|
118
|
+
const first = dayjs(candles[0].time).valueOf();
|
|
119
|
+
const last = dayjs(candles[candles.length - 1].time).valueOf();
|
|
120
|
+
return Math.ceil((last - first) / (1000 * 60 * 60 * 24));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function generateYTicks(min: number, max: number, count: number): number[] {
|
|
124
|
+
const range = max - min;
|
|
125
|
+
const step = range / (count - 1);
|
|
126
|
+
return Array.from({ length: count }, (_, i) => min + step * i);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function calculateBuyHoldEquity(candles: Candle[]): number[] {
|
|
130
|
+
if (candles.length === 0) return [];
|
|
131
|
+
const basePrice = candles[0].close;
|
|
132
|
+
return candles.map((c) => ((c.close - basePrice) / basePrice) * 100);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function calculatePositionState(candles: Candle[], trades: Trade[]): ('long' | 'none')[] {
|
|
136
|
+
const result: ('long' | 'none')[] = Array.from<'long' | 'none'>({ length: candles.length }).fill('none');
|
|
137
|
+
for (const trade of trades) {
|
|
138
|
+
const entryIdx = candles.findIndex((c) => c.time === trade.entry_time);
|
|
139
|
+
const exitIdx = candles.findIndex((c) => c.time === trade.exit_time);
|
|
140
|
+
if (entryIdx >= 0 && exitIdx >= 0) {
|
|
141
|
+
for (let i = entryIdx; i <= exitIdx; i++) {
|
|
142
|
+
result[i] = 'long';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 汎用チャート描画
|
|
151
|
+
* @param data チャートデータ
|
|
152
|
+
* @param chartDetail 'default' = エクイティ+DD のみ(軽量), 'full' = 4段チャート
|
|
153
|
+
*/
|
|
154
|
+
export function renderBacktestChartGeneric(data: GenericBacktestChartData, chartDetail: ChartDetail = 'full'): string {
|
|
155
|
+
if (chartDetail === 'default') {
|
|
156
|
+
return renderMinimalChart(data);
|
|
157
|
+
}
|
|
158
|
+
return renderFullChart(data);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 軽量チャート描画(エクイティ + ドローダウンのみ)
|
|
163
|
+
*/
|
|
164
|
+
function renderMinimalChart(data: GenericBacktestChartData): string {
|
|
165
|
+
const { candles, equity_curve, drawdown_curve, input, summary } = data;
|
|
166
|
+
const { width, height, margin, equityHeight, drawdownHeight, gapBetweenPanels } = LAYOUT_MINIMAL;
|
|
167
|
+
|
|
168
|
+
const plotWidth = width - margin.left - margin.right;
|
|
169
|
+
const xScale = (i: number) => margin.left + (i / Math.max(1, candles.length - 1)) * plotWidth;
|
|
170
|
+
|
|
171
|
+
const buyHoldEquity = calculateBuyHoldEquity(candles);
|
|
172
|
+
const finalBuyHold = buyHoldEquity[buyHoldEquity.length - 1] || 0;
|
|
173
|
+
|
|
174
|
+
// パネル位置
|
|
175
|
+
const equityTop = margin.top;
|
|
176
|
+
const equityBottom = equityTop + equityHeight;
|
|
177
|
+
const ddTop = equityBottom + gapBetweenPanels;
|
|
178
|
+
const ddBottom = ddTop + drawdownHeight;
|
|
179
|
+
|
|
180
|
+
// エクイティスケール
|
|
181
|
+
const strategyEquity = equity_curve.map((e) => e.equity_pct);
|
|
182
|
+
const allEquity = [...strategyEquity, ...buyHoldEquity];
|
|
183
|
+
const equityMin = Math.min(...allEquity, 0) * 1.1;
|
|
184
|
+
const equityMax = Math.max(...allEquity, 0) * 1.1;
|
|
185
|
+
const equityRange = Math.max(equityMax - equityMin, 10);
|
|
186
|
+
const equityYScale = (pct: number) => equityBottom - ((pct - equityMin) / equityRange) * equityHeight;
|
|
187
|
+
|
|
188
|
+
// ドローダウンスケール
|
|
189
|
+
const ddValues = drawdown_curve.map((d) => d.drawdown_pct);
|
|
190
|
+
const ddMax = Math.max(...ddValues, 5);
|
|
191
|
+
const ddYScale = (ddPositive: number) => ddTop + (ddPositive / ddMax) * (drawdownHeight - 10);
|
|
192
|
+
|
|
193
|
+
// データ間引き(100ポイント以上なら間引く)
|
|
194
|
+
const step = candles.length > 100 ? Math.ceil(candles.length / 100) : 1;
|
|
195
|
+
|
|
196
|
+
// SVG構築(レスポンシブ: viewBox のみ指定)
|
|
197
|
+
const svg: string[] = [];
|
|
198
|
+
svg.push(
|
|
199
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" style="max-width:100%;height:auto;">`,
|
|
200
|
+
);
|
|
201
|
+
svg.push(`<rect width="${width}" height="${height}" fill="${COLORS.background}"/>`);
|
|
202
|
+
|
|
203
|
+
// タイトル
|
|
204
|
+
const paramsStr = Object.entries(input.strategyParams)
|
|
205
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
206
|
+
.join(', ');
|
|
207
|
+
svg.push(
|
|
208
|
+
`<text x="${width / 2}" y="20" fill="${COLORS.text}" font-size="14" font-weight="bold" text-anchor="middle">`,
|
|
209
|
+
);
|
|
210
|
+
svg.push(`${input.strategyName} (${paramsStr})</text>`);
|
|
211
|
+
|
|
212
|
+
svg.push(`<text x="${width / 2}" y="38" fill="${COLORS.textMuted}" font-size="11" text-anchor="middle">`);
|
|
213
|
+
svg.push(
|
|
214
|
+
`${input.pair.toUpperCase()} | ${input.period} | Win: ${(summary.win_rate * 100).toFixed(0)}% | MaxDD: -${summary.max_drawdown_pct.toFixed(1)}%</text>`,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// 最終確定損益 (Realized P&L)
|
|
218
|
+
const finalConfirmed = equity_curve.length > 0 ? equity_curve[equity_curve.length - 1].confirmed_pct : 0;
|
|
219
|
+
|
|
220
|
+
// === エクイティカーブ ===
|
|
221
|
+
// 凡例: Mark = Mark-to-Market PnL(時価評価損益), Real = Realized PnL(確定損益)
|
|
222
|
+
svg.push(
|
|
223
|
+
`<text x="${margin.left}" y="${equityTop - 8}" fill="${COLORS.text}" font-size="10" font-weight="bold">Equity (%)</text>`,
|
|
224
|
+
);
|
|
225
|
+
svg.push(
|
|
226
|
+
`<text x="${margin.left + 70}" y="${equityTop - 8}" fill="${COLORS.strategy}" font-size="9">Mark: ${formatPct(summary.total_pnl_pct)}</text>`,
|
|
227
|
+
);
|
|
228
|
+
svg.push(
|
|
229
|
+
`<text x="${margin.left + 150}" y="${equityTop - 8}" fill="#22c55e" font-size="9">Real: ${formatPct(finalConfirmed)}</text>`,
|
|
230
|
+
);
|
|
231
|
+
svg.push(
|
|
232
|
+
`<text x="${margin.left + 230}" y="${equityTop - 8}" fill="${COLORS.buyHold}" font-size="9">B&H: ${formatPct(finalBuyHold)}</text>`,
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// グリッド
|
|
236
|
+
const equityTicks = generateYTicks(equityMin, equityMax, 3);
|
|
237
|
+
for (const tick of equityTicks) {
|
|
238
|
+
const y = equityYScale(tick);
|
|
239
|
+
svg.push(
|
|
240
|
+
`<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="${COLORS.grid}" stroke-dasharray="2,2"/>`,
|
|
241
|
+
);
|
|
242
|
+
svg.push(
|
|
243
|
+
`<text x="${margin.left - 5}" y="${y + 3}" fill="${COLORS.textMuted}" font-size="8" text-anchor="end">${tick.toFixed(0)}%</text>`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Buy & Hold ライン(間引き)
|
|
248
|
+
const buyHoldPoints: string[] = [];
|
|
249
|
+
for (let i = 0; i < candles.length; i += step) {
|
|
250
|
+
buyHoldPoints.push(`${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(0)},${equityYScale(buyHoldEquity[i]).toFixed(0)}`);
|
|
251
|
+
}
|
|
252
|
+
svg.push(`<path d="${buyHoldPoints.join(' ')}" fill="none" stroke="${COLORS.buyHold}" stroke-width="1.5"/>`);
|
|
253
|
+
|
|
254
|
+
// 確定損益ライン (Realized) - 点線、間引き
|
|
255
|
+
if (equity_curve.length > 0) {
|
|
256
|
+
const confirmedPoints: string[] = [];
|
|
257
|
+
for (let i = 0; i < equity_curve.length; i += step) {
|
|
258
|
+
confirmedPoints.push(
|
|
259
|
+
`${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(0)},${equityYScale(equity_curve[i].confirmed_pct).toFixed(0)}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
svg.push(
|
|
263
|
+
`<path d="${confirmedPoints.join(' ')}" fill="none" stroke="#22c55e" stroke-width="1.5" stroke-dasharray="4,2"/>`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 評価損益ライン (Mark-to-Market) - 実線、間引き
|
|
268
|
+
if (equity_curve.length > 0) {
|
|
269
|
+
const strategyPoints: string[] = [];
|
|
270
|
+
for (let i = 0; i < equity_curve.length; i += step) {
|
|
271
|
+
strategyPoints.push(
|
|
272
|
+
`${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(0)},${equityYScale(equity_curve[i].equity_pct).toFixed(0)}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
svg.push(`<path d="${strategyPoints.join(' ')}" fill="none" stroke="${COLORS.strategy}" stroke-width="2"/>`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// === ドローダウン ===
|
|
279
|
+
svg.push(
|
|
280
|
+
`<text x="${margin.left}" y="${ddTop - 8}" fill="${COLORS.text}" font-size="10" font-weight="bold">Drawdown</text>`,
|
|
281
|
+
);
|
|
282
|
+
svg.push(
|
|
283
|
+
`<line x1="${margin.left}" y1="${ddTop}" x2="${width - margin.right}" y2="${ddTop}" stroke="${COLORS.zeroline}" stroke-width="1"/>`,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (drawdown_curve.length > 0) {
|
|
287
|
+
const ddPoints: string[] = [];
|
|
288
|
+
for (let i = 0; i < drawdown_curve.length; i += step) {
|
|
289
|
+
ddPoints.push(
|
|
290
|
+
`${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(0)},${ddYScale(drawdown_curve[i].drawdown_pct).toFixed(0)}`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
const lastX = xScale(drawdown_curve.length - 1).toFixed(0);
|
|
294
|
+
const firstX = xScale(0).toFixed(0);
|
|
295
|
+
const fillPath = `${ddPoints.join(' ')} L${lastX},${ddTop} L${firstX},${ddTop} Z`;
|
|
296
|
+
svg.push(`<path d="${fillPath}" fill="${COLORS.drawdownFill}"/>`);
|
|
297
|
+
svg.push(`<path d="${ddPoints.join(' ')}" fill="none" stroke="${COLORS.drawdown}" stroke-width="1"/>`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// X軸ラベル(期間に応じたフォーマット)
|
|
301
|
+
const spanDays = calculateSpanDays(candles);
|
|
302
|
+
const dateFormat = getDateFormat(spanDays);
|
|
303
|
+
const xLabelInterval = Math.max(1, Math.floor(candles.length / 5));
|
|
304
|
+
for (let i = 0; i < candles.length; i += xLabelInterval) {
|
|
305
|
+
svg.push(
|
|
306
|
+
`<text x="${xScale(i)}" y="${ddBottom + 12}" fill="${COLORS.textMuted}" font-size="8" text-anchor="middle">${formatDateBySpan(candles[i].time, dateFormat)}</text>`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
svg.push('</svg>');
|
|
311
|
+
return svg.join('\n');
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* フルチャート描画(4〜5段: インジケータパネルは動的追加)
|
|
316
|
+
*/
|
|
317
|
+
function renderFullChart(data: GenericBacktestChartData): string {
|
|
318
|
+
const { candles, overlays, trades, equity_curve, drawdown_curve, input, summary } = data;
|
|
319
|
+
const {
|
|
320
|
+
width,
|
|
321
|
+
margin,
|
|
322
|
+
priceHeight,
|
|
323
|
+
indicatorHeight,
|
|
324
|
+
equityHeight,
|
|
325
|
+
drawdownHeight,
|
|
326
|
+
positionHeight,
|
|
327
|
+
gapBetweenPanels,
|
|
328
|
+
} = LAYOUT_FULL;
|
|
329
|
+
|
|
330
|
+
const plotWidth = width - margin.left - margin.right;
|
|
331
|
+
const xScale = (i: number) => margin.left + (i / Math.max(1, candles.length - 1)) * plotWidth;
|
|
332
|
+
|
|
333
|
+
const buyHoldEquity = calculateBuyHoldEquity(candles);
|
|
334
|
+
const finalBuyHold = buyHoldEquity[buyHoldEquity.length - 1] || 0;
|
|
335
|
+
const positionState = calculatePositionState(candles, trades);
|
|
336
|
+
|
|
337
|
+
// オーバーレイを price / indicator に分離
|
|
338
|
+
const priceOverlays = overlays.filter((o) => o.panel !== 'indicator');
|
|
339
|
+
const indicatorOverlays = overlays.filter((o) => o.panel === 'indicator');
|
|
340
|
+
const hasIndicatorPanel = indicatorOverlays.length > 0;
|
|
341
|
+
|
|
342
|
+
// トレードマーカー
|
|
343
|
+
const tradeEntryMarkers = trades
|
|
344
|
+
.map((t) => ({
|
|
345
|
+
idx: candles.findIndex((c) => c.time === t.entry_time),
|
|
346
|
+
price: t.entry_price,
|
|
347
|
+
}))
|
|
348
|
+
.filter((m) => m.idx >= 0);
|
|
349
|
+
|
|
350
|
+
const tradeExitMarkers = trades
|
|
351
|
+
.map((t) => ({
|
|
352
|
+
idx: candles.findIndex((c) => c.time === t.exit_time),
|
|
353
|
+
price: t.exit_price,
|
|
354
|
+
}))
|
|
355
|
+
.filter((m) => m.idx >= 0);
|
|
356
|
+
|
|
357
|
+
// パネル位置(インジケータパネルは動的)
|
|
358
|
+
const priceTop = margin.top;
|
|
359
|
+
const priceBottom = priceTop + priceHeight;
|
|
360
|
+
|
|
361
|
+
const indTop = priceBottom + (hasIndicatorPanel ? gapBetweenPanels : 0);
|
|
362
|
+
const indBottom = hasIndicatorPanel ? indTop + indicatorHeight : priceBottom;
|
|
363
|
+
|
|
364
|
+
const equityTop = (hasIndicatorPanel ? indBottom : priceBottom) + gapBetweenPanels;
|
|
365
|
+
const equityBottom = equityTop + equityHeight;
|
|
366
|
+
const ddTop = equityBottom + gapBetweenPanels;
|
|
367
|
+
const ddBottom = ddTop + drawdownHeight;
|
|
368
|
+
const posTop = ddBottom + gapBetweenPanels;
|
|
369
|
+
const posBottom = posTop + positionHeight;
|
|
370
|
+
const totalHeight = posBottom + margin.bottom;
|
|
371
|
+
|
|
372
|
+
// 価格スケール(priceOverlays のみ使用)
|
|
373
|
+
const allPrices = [
|
|
374
|
+
...candles.map((c) => c.close),
|
|
375
|
+
...tradeEntryMarkers.map((m) => m.price),
|
|
376
|
+
...tradeExitMarkers.map((m) => m.price),
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
for (const overlay of priceOverlays) {
|
|
380
|
+
if (overlay.type === 'line') {
|
|
381
|
+
allPrices.push(...overlay.data.filter((v) => !Number.isNaN(v)));
|
|
382
|
+
} else if (overlay.type === 'band') {
|
|
383
|
+
allPrices.push(...overlay.data.upper.filter((v) => !Number.isNaN(v)));
|
|
384
|
+
allPrices.push(...overlay.data.lower.filter((v) => !Number.isNaN(v)));
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const priceMin = Math.min(...allPrices) * 0.995;
|
|
389
|
+
const priceMax = Math.max(...allPrices) * 1.005;
|
|
390
|
+
const priceYScale = (p: number) => priceBottom - ((p - priceMin) / (priceMax - priceMin)) * priceHeight;
|
|
391
|
+
|
|
392
|
+
// エクイティスケール
|
|
393
|
+
const strategyEquity = equity_curve.map((e) => e.equity_pct);
|
|
394
|
+
const allEquity = [...strategyEquity, ...buyHoldEquity];
|
|
395
|
+
const equityMin = Math.min(...allEquity, 0) * 1.1;
|
|
396
|
+
const equityMax = Math.max(...allEquity, 0) * 1.1;
|
|
397
|
+
const equityRange = Math.max(equityMax - equityMin, 10);
|
|
398
|
+
const equityYScale = (pct: number) => equityBottom - ((pct - equityMin) / equityRange) * equityHeight;
|
|
399
|
+
|
|
400
|
+
// ドローダウンスケール
|
|
401
|
+
const ddValues = drawdown_curve.map((d) => d.drawdown_pct);
|
|
402
|
+
const ddMax = Math.max(...ddValues, 5);
|
|
403
|
+
const ddYScale = (ddPositive: number) => ddTop + (ddPositive / ddMax) * (drawdownHeight - 10);
|
|
404
|
+
|
|
405
|
+
// SVG構築(レスポンシブ: viewBox のみ指定、高さは動的)
|
|
406
|
+
const svg: string[] = [];
|
|
407
|
+
svg.push(
|
|
408
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${totalHeight}" style="max-width:100%;height:auto;">`,
|
|
409
|
+
);
|
|
410
|
+
svg.push(`<rect width="${width}" height="${totalHeight}" fill="${COLORS.background}"/>`);
|
|
411
|
+
|
|
412
|
+
// タイトル
|
|
413
|
+
const paramsStr = Object.entries(input.strategyParams)
|
|
414
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
415
|
+
.join(', ');
|
|
416
|
+
svg.push(
|
|
417
|
+
`<text x="${width / 2}" y="25" fill="${COLORS.text}" font-size="16" font-weight="bold" text-anchor="middle">`,
|
|
418
|
+
);
|
|
419
|
+
svg.push(`${input.strategyName} Backtest (${paramsStr})</text>`);
|
|
420
|
+
|
|
421
|
+
svg.push(`<text x="${width / 2}" y="48" fill="${COLORS.textMuted}" font-size="12" text-anchor="middle">`);
|
|
422
|
+
svg.push(
|
|
423
|
+
`${input.pair.toUpperCase()} | ${input.period} | Trades: ${summary.trade_count} | Win Rate: ${(summary.win_rate * 100).toFixed(1)}% | Max DD: -${summary.max_drawdown_pct.toFixed(1)}%</text>`,
|
|
424
|
+
);
|
|
425
|
+
|
|
426
|
+
// === 価格チャート ===
|
|
427
|
+
const priceTicks = generateYTicks(priceMin, priceMax, 5);
|
|
428
|
+
for (const tick of priceTicks) {
|
|
429
|
+
const y = priceYScale(tick);
|
|
430
|
+
svg.push(
|
|
431
|
+
`<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="${COLORS.grid}" stroke-dasharray="2,2"/>`,
|
|
432
|
+
);
|
|
433
|
+
svg.push(
|
|
434
|
+
`<text x="${width - margin.right + 5}" y="${y + 4}" fill="${COLORS.textMuted}" font-size="10">${formatPrice(tick)}</text>`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 終値ライン
|
|
439
|
+
const pricePath = candles
|
|
440
|
+
.map((c, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${priceYScale(c.close).toFixed(1)}`)
|
|
441
|
+
.join(' ');
|
|
442
|
+
svg.push(`<path d="${pricePath}" fill="none" stroke="${COLORS.price}" stroke-width="1.5"/>`);
|
|
443
|
+
|
|
444
|
+
// 価格パネルのオーバーレイ描画(indicator パネルのものは除外)
|
|
445
|
+
let colorIdx = 0;
|
|
446
|
+
const legendItems: { color: string; name: string }[] = [{ color: COLORS.price, name: 'Close' }];
|
|
447
|
+
|
|
448
|
+
for (const overlay of priceOverlays) {
|
|
449
|
+
if (overlay.type === 'histogram') continue; // histogram は indicator パネルのみ
|
|
450
|
+
const color = overlay.color || OVERLAY_COLORS[colorIdx % OVERLAY_COLORS.length];
|
|
451
|
+
colorIdx++;
|
|
452
|
+
|
|
453
|
+
if (overlay.type === 'line') {
|
|
454
|
+
let started = false;
|
|
455
|
+
const pathParts: string[] = [];
|
|
456
|
+
for (let i = 0; i < overlay.data.length; i++) {
|
|
457
|
+
const v = overlay.data[i];
|
|
458
|
+
if (Number.isNaN(v)) {
|
|
459
|
+
started = false;
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const x = xScale(i).toFixed(1);
|
|
463
|
+
const y = priceYScale(v).toFixed(1);
|
|
464
|
+
pathParts.push(`${started ? 'L' : 'M'}${x},${y}`);
|
|
465
|
+
started = true;
|
|
466
|
+
}
|
|
467
|
+
if (pathParts.length > 0) {
|
|
468
|
+
svg.push(`<path d="${pathParts.join(' ')}" fill="none" stroke="${color}" stroke-width="1.5"/>`);
|
|
469
|
+
}
|
|
470
|
+
legendItems.push({ color, name: overlay.name });
|
|
471
|
+
} else if (overlay.type === 'band') {
|
|
472
|
+
const upperPath: string[] = [];
|
|
473
|
+
const lowerPath: string[] = [];
|
|
474
|
+
let started = false;
|
|
475
|
+
|
|
476
|
+
for (let i = 0; i < overlay.data.upper.length; i++) {
|
|
477
|
+
const upper = overlay.data.upper[i];
|
|
478
|
+
const lower = overlay.data.lower[i];
|
|
479
|
+
if (Number.isNaN(upper) || Number.isNaN(lower)) {
|
|
480
|
+
started = false;
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
const x = xScale(i).toFixed(1);
|
|
484
|
+
upperPath.push(`${started ? 'L' : 'M'}${x},${priceYScale(upper).toFixed(1)}`);
|
|
485
|
+
lowerPath.push(`${started ? 'L' : 'M'}${x},${priceYScale(lower).toFixed(1)}`);
|
|
486
|
+
started = true;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (upperPath.length > 0) {
|
|
490
|
+
svg.push(
|
|
491
|
+
`<path d="${upperPath.join(' ')}" fill="none" stroke="${color}" stroke-width="1" stroke-dasharray="3,3"/>`,
|
|
492
|
+
);
|
|
493
|
+
svg.push(
|
|
494
|
+
`<path d="${lowerPath.join(' ')}" fill="none" stroke="${color}" stroke-width="1" stroke-dasharray="3,3"/>`,
|
|
495
|
+
);
|
|
496
|
+
}
|
|
497
|
+
legendItems.push({ color, name: overlay.name });
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// エントリー/エグジットマーカー
|
|
502
|
+
for (const marker of tradeEntryMarkers) {
|
|
503
|
+
const x = xScale(marker.idx);
|
|
504
|
+
const y = priceYScale(marker.price);
|
|
505
|
+
svg.push(`<circle cx="${x}" cy="${y}" r="6" fill="${COLORS.entryMarker}" stroke="#fff" stroke-width="1.5"/>`);
|
|
506
|
+
svg.push(
|
|
507
|
+
`<text x="${x}" y="${y - 12}" fill="${COLORS.entryMarker}" font-size="10" font-weight="bold" text-anchor="middle">▲</text>`,
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
for (const marker of tradeExitMarkers) {
|
|
512
|
+
const x = xScale(marker.idx);
|
|
513
|
+
const y = priceYScale(marker.price);
|
|
514
|
+
svg.push(`<circle cx="${x}" cy="${y}" r="6" fill="${COLORS.exitMarker}" stroke="#fff" stroke-width="1.5"/>`);
|
|
515
|
+
svg.push(
|
|
516
|
+
`<text x="${x}" y="${y + 18}" fill="${COLORS.exitMarker}" font-size="10" font-weight="bold" text-anchor="middle">▼</text>`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// 凡例(パネル上部に配置)
|
|
521
|
+
let legendX = margin.left + 10;
|
|
522
|
+
const legendY = priceTop - 8;
|
|
523
|
+
for (const item of legendItems) {
|
|
524
|
+
svg.push(`<text x="${legendX}" y="${legendY}" fill="${item.color}" font-size="10">● ${item.name}</text>`);
|
|
525
|
+
legendX += item.name.length * 7 + 30;
|
|
526
|
+
}
|
|
527
|
+
svg.push(`<text x="${legendX}" y="${legendY}" fill="${COLORS.entryMarker}" font-size="10">▲ Entry</text>`);
|
|
528
|
+
legendX += 60;
|
|
529
|
+
svg.push(`<text x="${legendX}" y="${legendY}" fill="${COLORS.exitMarker}" font-size="10">▼ Exit</text>`);
|
|
530
|
+
|
|
531
|
+
// === インジケータサブパネル(MACD 等) ===
|
|
532
|
+
if (hasIndicatorPanel) {
|
|
533
|
+
// インジケータ値のスケール計算
|
|
534
|
+
const allIndValues: number[] = [];
|
|
535
|
+
for (const overlay of indicatorOverlays) {
|
|
536
|
+
if (overlay.type === 'line' || overlay.type === 'histogram') {
|
|
537
|
+
allIndValues.push(...overlay.data.filter((v) => !Number.isNaN(v)));
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
// ゼロを含む対称的なスケール(ヒストグラムのため)
|
|
541
|
+
const indAbsMax = Math.max(Math.abs(Math.min(...allIndValues, 0)), Math.abs(Math.max(...allIndValues, 0))) * 1.15;
|
|
542
|
+
const indMin = -indAbsMax;
|
|
543
|
+
const indMax = indAbsMax;
|
|
544
|
+
const indRange = Math.max(indMax - indMin, 1);
|
|
545
|
+
const indYScale = (v: number) => indBottom - ((v - indMin) / indRange) * indicatorHeight;
|
|
546
|
+
|
|
547
|
+
// パネルラベル + 凡例
|
|
548
|
+
const indLines = indicatorOverlays.filter((o) => o.type === 'line');
|
|
549
|
+
const panelTitle = indLines.length > 0 ? 'MACD' : 'Indicator';
|
|
550
|
+
svg.push(
|
|
551
|
+
`<text x="${margin.left}" y="${indTop - 15}" fill="${COLORS.text}" font-size="11" font-weight="bold">${panelTitle}</text>`,
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
let indLegendX = margin.left + 60;
|
|
555
|
+
for (const overlay of indLines) {
|
|
556
|
+
const col = overlay.type === 'line' ? overlay.color : '#888';
|
|
557
|
+
svg.push(`<text x="${indLegendX}" y="${indTop - 15}" fill="${col}" font-size="9">--- ${overlay.name}</text>`);
|
|
558
|
+
indLegendX += overlay.name.length * 7 + 40;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ゼロライン
|
|
562
|
+
const zeroY = indYScale(0);
|
|
563
|
+
svg.push(
|
|
564
|
+
`<line x1="${margin.left}" y1="${zeroY}" x2="${width - margin.right}" y2="${zeroY}" stroke="${COLORS.zeroline}" stroke-width="1"/>`,
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
// グリッド + Y軸ラベル
|
|
568
|
+
const indTicks = generateYTicks(indMin, indMax, 5);
|
|
569
|
+
for (const tick of indTicks) {
|
|
570
|
+
const y = indYScale(tick);
|
|
571
|
+
svg.push(
|
|
572
|
+
`<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="${COLORS.grid}" stroke-dasharray="2,2"/>`,
|
|
573
|
+
);
|
|
574
|
+
svg.push(
|
|
575
|
+
`<text x="${margin.left - 5}" y="${y + 3}" fill="${COLORS.textMuted}" font-size="8" text-anchor="end">${formatPrice(tick)}</text>`,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ヒストグラム棒の描画
|
|
580
|
+
for (const overlay of indicatorOverlays) {
|
|
581
|
+
if (overlay.type === 'histogram') {
|
|
582
|
+
const barW = Math.max((plotWidth / candles.length) * 0.6, 1);
|
|
583
|
+
for (let i = 0; i < overlay.data.length; i++) {
|
|
584
|
+
const v = overlay.data[i];
|
|
585
|
+
if (Number.isNaN(v)) continue;
|
|
586
|
+
const x = xScale(i) - barW / 2;
|
|
587
|
+
const yVal = indYScale(v);
|
|
588
|
+
const yZero = indYScale(0);
|
|
589
|
+
const barColor = v >= 0 ? overlay.positiveColor : overlay.negativeColor;
|
|
590
|
+
const barTop = Math.min(yVal, yZero);
|
|
591
|
+
const barHeight = Math.abs(yVal - yZero);
|
|
592
|
+
svg.push(
|
|
593
|
+
`<rect x="${x.toFixed(1)}" y="${barTop.toFixed(1)}" width="${barW.toFixed(1)}" height="${Math.max(barHeight, 0.5).toFixed(1)}" fill="${barColor}"/>`,
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// インジケータライン描画
|
|
600
|
+
for (const overlay of indicatorOverlays) {
|
|
601
|
+
if (overlay.type === 'line') {
|
|
602
|
+
let started = false;
|
|
603
|
+
const pathParts: string[] = [];
|
|
604
|
+
for (let i = 0; i < overlay.data.length; i++) {
|
|
605
|
+
const v = overlay.data[i];
|
|
606
|
+
if (Number.isNaN(v)) {
|
|
607
|
+
started = false;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
pathParts.push(`${started ? 'L' : 'M'}${xScale(i).toFixed(1)},${indYScale(v).toFixed(1)}`);
|
|
611
|
+
started = true;
|
|
612
|
+
}
|
|
613
|
+
if (pathParts.length > 0) {
|
|
614
|
+
svg.push(`<path d="${pathParts.join(' ')}" fill="none" stroke="${overlay.color}" stroke-width="1.5"/>`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// === エクイティカーブ ===
|
|
621
|
+
const equityLabelY = equityTop - 15;
|
|
622
|
+
svg.push(
|
|
623
|
+
`<text x="${margin.left}" y="${equityLabelY}" fill="${COLORS.text}" font-size="11" font-weight="bold">Equity (%)</text>`,
|
|
624
|
+
);
|
|
625
|
+
svg.push(`<circle cx="${margin.left + 80}" cy="${equityLabelY - 4}" r="4" fill="${COLORS.strategy}"/>`);
|
|
626
|
+
svg.push(
|
|
627
|
+
`<text x="${margin.left + 90}" y="${equityLabelY}" fill="${COLORS.strategy}" font-size="10">Strategy (${formatPct(summary.total_pnl_pct)})</text>`,
|
|
628
|
+
);
|
|
629
|
+
svg.push(`<circle cx="${margin.left + 220}" cy="${equityLabelY - 4}" r="4" fill="${COLORS.buyHold}"/>`);
|
|
630
|
+
svg.push(
|
|
631
|
+
`<text x="${margin.left + 230}" y="${equityLabelY}" fill="${COLORS.buyHold}" font-size="10">Buy&Hold (${formatPct(finalBuyHold)})</text>`,
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
const equityTicks = generateYTicks(equityMin, equityMax, 4);
|
|
635
|
+
for (const tick of equityTicks) {
|
|
636
|
+
const y = equityYScale(tick);
|
|
637
|
+
svg.push(
|
|
638
|
+
`<line x1="${margin.left}" y1="${y}" x2="${width - margin.right}" y2="${y}" stroke="${COLORS.grid}" stroke-dasharray="2,2"/>`,
|
|
639
|
+
);
|
|
640
|
+
svg.push(
|
|
641
|
+
`<text x="${margin.left - 10}" y="${y + 4}" fill="${COLORS.textMuted}" font-size="9" text-anchor="end">${tick.toFixed(0)}%</text>`,
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Buy & Hold ライン
|
|
646
|
+
const buyHoldPath = candles
|
|
647
|
+
.map((_, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${equityYScale(buyHoldEquity[i]).toFixed(1)}`)
|
|
648
|
+
.join(' ');
|
|
649
|
+
svg.push(`<path d="${buyHoldPath}" fill="none" stroke="${COLORS.buyHold}" stroke-width="2"/>`);
|
|
650
|
+
|
|
651
|
+
// Strategy ライン
|
|
652
|
+
if (equity_curve.length > 0) {
|
|
653
|
+
const strategyPath = equity_curve
|
|
654
|
+
.map((e, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${equityYScale(e.equity_pct).toFixed(1)}`)
|
|
655
|
+
.join(' ');
|
|
656
|
+
svg.push(`<path d="${strategyPath}" fill="none" stroke="${COLORS.strategy}" stroke-width="2.5"/>`);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
// === ドローダウン ===
|
|
660
|
+
svg.push(
|
|
661
|
+
`<text x="${margin.left}" y="${ddTop - 15}" fill="${COLORS.text}" font-size="11" font-weight="bold">Drawdown (%)</text>`,
|
|
662
|
+
);
|
|
663
|
+
svg.push(
|
|
664
|
+
`<line x1="${margin.left}" y1="${ddTop}" x2="${width - margin.right}" y2="${ddTop}" stroke="${COLORS.zeroline}" stroke-width="1"/>`,
|
|
665
|
+
);
|
|
666
|
+
|
|
667
|
+
if (drawdown_curve.length > 0) {
|
|
668
|
+
const ddPathPoints = drawdown_curve
|
|
669
|
+
.map((d, i) => `${i === 0 ? 'M' : 'L'}${xScale(i).toFixed(1)},${ddYScale(d.drawdown_pct).toFixed(1)}`)
|
|
670
|
+
.join(' ');
|
|
671
|
+
const lastX = xScale(drawdown_curve.length - 1).toFixed(1);
|
|
672
|
+
const firstX = xScale(0).toFixed(1);
|
|
673
|
+
const fillPath = `${ddPathPoints} L${lastX},${ddTop} L${firstX},${ddTop} Z`;
|
|
674
|
+
svg.push(`<path d="${fillPath}" fill="${COLORS.drawdownFill}"/>`);
|
|
675
|
+
svg.push(`<path d="${ddPathPoints}" fill="none" stroke="${COLORS.drawdown}" stroke-width="1.5"/>`);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const ddTickStep = Math.ceil(ddMax / 3);
|
|
679
|
+
const ddTickValues = [
|
|
680
|
+
0,
|
|
681
|
+
...Array.from({ length: 3 }, (_, i) => (i + 1) * ddTickStep).filter((v) => v <= ddMax * 1.2),
|
|
682
|
+
];
|
|
683
|
+
for (const tick of ddTickValues) {
|
|
684
|
+
const y = ddYScale(tick);
|
|
685
|
+
if (y <= ddBottom && y >= ddTop) {
|
|
686
|
+
svg.push(
|
|
687
|
+
`<text x="${margin.left - 10}" y="${y + 4}" fill="${COLORS.textMuted}" font-size="9" text-anchor="end">${tick === 0 ? '0%' : `-${tick.toFixed(0)}%`}</text>`,
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// === ポジション ===
|
|
693
|
+
svg.push(
|
|
694
|
+
`<text x="${margin.left}" y="${posTop - 15}" fill="${COLORS.text}" font-size="11" font-weight="bold">Position</text>`,
|
|
695
|
+
);
|
|
696
|
+
svg.push(
|
|
697
|
+
`<rect x="${margin.left}" y="${posTop}" width="${plotWidth}" height="${positionHeight}" fill="none" stroke="${COLORS.grid}"/>`,
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
const barWidth = plotWidth / candles.length;
|
|
701
|
+
for (let i = 0; i < candles.length; i++) {
|
|
702
|
+
if (positionState[i] === 'long') {
|
|
703
|
+
const x = margin.left + (i / candles.length) * plotWidth;
|
|
704
|
+
svg.push(
|
|
705
|
+
`<rect x="${x.toFixed(1)}" y="${posTop + 3}" width="${Math.max(barWidth, 1).toFixed(1)}" height="${positionHeight - 6}" fill="${COLORS.positionLong}" opacity="0.7"/>`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
svg.push(
|
|
711
|
+
`<rect x="${width - margin.right + 10}" y="${posTop + 5}" width="12" height="12" fill="${COLORS.positionLong}" opacity="0.7"/>`,
|
|
712
|
+
);
|
|
713
|
+
svg.push(
|
|
714
|
+
`<text x="${width - margin.right + 28}" y="${posTop + 15}" fill="${COLORS.positionLong}" font-size="9">Long</text>`,
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
// X軸ラベル(期間に応じたフォーマット)
|
|
718
|
+
const spanDays = calculateSpanDays(candles);
|
|
719
|
+
const dateFormat = getDateFormat(spanDays);
|
|
720
|
+
const xLabelInterval = Math.max(1, Math.floor(candles.length / 8));
|
|
721
|
+
for (let i = 0; i < candles.length; i += xLabelInterval) {
|
|
722
|
+
svg.push(
|
|
723
|
+
`<text x="${xScale(i)}" y="${posBottom + 15}" fill="${COLORS.textMuted}" font-size="9" text-anchor="middle">${formatDateBySpan(candles[i].time, dateFormat)}</text>`,
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
svg.push('</svg>');
|
|
728
|
+
return svg.join('\n');
|
|
729
|
+
}
|