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
package/src/http.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
5
|
+
import express from 'express';
|
|
6
|
+
import rateLimit from 'express-rate-limit';
|
|
7
|
+
import type { NextFunction, Request, RequestHandler, Response } from 'express-serve-static-core';
|
|
8
|
+
|
|
9
|
+
const PORT = Number(process.env.PORT ?? 8787);
|
|
10
|
+
const ENDPOINT = '/mcp';
|
|
11
|
+
|
|
12
|
+
/** レート制限: ウィンドウ(ミリ秒)。デフォルト 60 秒 */
|
|
13
|
+
const RATE_LIMIT_WINDOW_MS = Number(process.env.RATE_LIMIT_WINDOW_MS ?? 60_000);
|
|
14
|
+
/** レート制限: ウィンドウあたり最大リクエスト数。デフォルト 60 */
|
|
15
|
+
const RATE_LIMIT_MAX = Number(process.env.RATE_LIMIT_MAX ?? 60);
|
|
16
|
+
|
|
17
|
+
const app = express();
|
|
18
|
+
app.use(express.json({ limit: '2mb' }));
|
|
19
|
+
|
|
20
|
+
// ngrok Free のブラウザ警告回避用ヘッダ
|
|
21
|
+
app.use((_req: Request, res: Response, next: NextFunction) => {
|
|
22
|
+
res.setHeader('ngrok-skip-browser-warning', '1');
|
|
23
|
+
next();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// 簡易ヘルスチェック
|
|
27
|
+
app.get('/health', (_req: Request, res: Response) => {
|
|
28
|
+
res.json({ ok: true, ts: Date.now() });
|
|
29
|
+
});
|
|
30
|
+
// 最低限の /mcp ルート(メタ確認用)
|
|
31
|
+
app.get(ENDPOINT, (_req: Request, res: Response) => {
|
|
32
|
+
res.json({
|
|
33
|
+
version: '1.0',
|
|
34
|
+
actions: [
|
|
35
|
+
{
|
|
36
|
+
name: 'ping',
|
|
37
|
+
description: 'Health check action',
|
|
38
|
+
parameters: { type: 'object', properties: { message: { type: 'string', description: 'Any message' } } },
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// 最小サーバ(必要に応じて既存の登録ロジックに差し替え可)
|
|
45
|
+
const server = new McpServer({ name: 'bb-mcp', version: '1.0.0' });
|
|
46
|
+
// SDK の registerTool 型が厳密すぎるため、空スキーマ登録にキャストを集約
|
|
47
|
+
(server as unknown as { registerTool: (n: string, s: unknown, h: unknown) => void }).registerTool(
|
|
48
|
+
'ping',
|
|
49
|
+
{
|
|
50
|
+
description: 'Return a ping response',
|
|
51
|
+
inputSchema: { message: { type: 'string', description: 'Any message' } },
|
|
52
|
+
},
|
|
53
|
+
async (args: Record<string, unknown>) => {
|
|
54
|
+
return { content: [{ type: 'text', text: `pong: ${String(args.message ?? '')}` }] };
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
// Streamable HTTP transport
|
|
59
|
+
const allowedHosts = (process.env.ALLOWED_HOSTS ?? 'localhost,127.0.0.1,*.ngrok-free.dev')
|
|
60
|
+
.split(',')
|
|
61
|
+
.map((s) => s.trim())
|
|
62
|
+
.filter(Boolean);
|
|
63
|
+
const allowedOrigins = (process.env.ALLOWED_ORIGINS ?? '')
|
|
64
|
+
.split(',')
|
|
65
|
+
.map((s) => s.trim())
|
|
66
|
+
.filter(Boolean);
|
|
67
|
+
|
|
68
|
+
// StreamableHTTPServerTransport のコンストラクタ型が SDK で正確に export されていないためキャストを集約
|
|
69
|
+
type Transport = Parameters<typeof server.connect>[0];
|
|
70
|
+
const HttpTransport = StreamableHTTPServerTransport as unknown as new (
|
|
71
|
+
opts: Record<string, unknown>,
|
|
72
|
+
) => Transport & {
|
|
73
|
+
handleRequest?: (req: IncomingMessage, res: ServerResponse, body?: unknown) => Promise<void>;
|
|
74
|
+
};
|
|
75
|
+
const transport = new HttpTransport({
|
|
76
|
+
path: ENDPOINT,
|
|
77
|
+
sessionIdGenerator: () => randomUUID(),
|
|
78
|
+
enableDnsRebindingProtection: true,
|
|
79
|
+
...(allowedHosts.length ? { allowedHosts } : {}),
|
|
80
|
+
...(allowedOrigins.length ? { allowedOrigins } : {}),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await server.connect(transport);
|
|
84
|
+
|
|
85
|
+
// /mcp エンドポイントにレート制限を適用(stdio には影響しない)
|
|
86
|
+
const mcpLimiter = rateLimit({
|
|
87
|
+
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
88
|
+
max: RATE_LIMIT_MAX,
|
|
89
|
+
standardHeaders: 'draft-7',
|
|
90
|
+
legacyHeaders: false,
|
|
91
|
+
message: { error: 'Too many requests. Please try again later.' },
|
|
92
|
+
});
|
|
93
|
+
app.use(ENDPOINT, mcpLimiter as unknown as RequestHandler);
|
|
94
|
+
|
|
95
|
+
// SDK 公式の handleRequest を使って HTTP リクエストを処理する
|
|
96
|
+
const mw: RequestHandler =
|
|
97
|
+
typeof transport.handleRequest === 'function'
|
|
98
|
+
? (req, res, next) => {
|
|
99
|
+
transport.handleRequest!(req, res, req.body).catch(next);
|
|
100
|
+
}
|
|
101
|
+
: (_req: Request, _res: Response, next: NextFunction) => next();
|
|
102
|
+
app.use(ENDPOINT, mw);
|
|
103
|
+
|
|
104
|
+
app.listen(PORT, '::', () => {
|
|
105
|
+
// eslint-disable-next-line no-console
|
|
106
|
+
console.log(`MCP HTTP listening on http://localhost:${PORT}${ENDPOINT}`);
|
|
107
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bitbank Private API 認証モジュール。
|
|
3
|
+
*
|
|
4
|
+
* ACCESS-TIME-WINDOW 方式を採用。
|
|
5
|
+
* 署名対象文字列: requestTime + timeWindow + path/body
|
|
6
|
+
* ヘッダー: ACCESS-KEY, ACCESS-REQUEST-TIME, ACCESS-TIME-WINDOW, ACCESS-SIGNATURE
|
|
7
|
+
*
|
|
8
|
+
* @see https://github.com/bitbankinc/bitbank-api-docs/blob/master/rest-api.md
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createHmac } from 'node:crypto';
|
|
12
|
+
import { getPrivateApiConfig } from './config.js';
|
|
13
|
+
|
|
14
|
+
/** デフォルトの TIME-WINDOW(ミリ秒)。最大 60000 まで設定可能 */
|
|
15
|
+
const DEFAULT_TIME_WINDOW = '5000';
|
|
16
|
+
|
|
17
|
+
export interface AuthHeaders {
|
|
18
|
+
'ACCESS-KEY': string;
|
|
19
|
+
'ACCESS-REQUEST-TIME': string;
|
|
20
|
+
'ACCESS-TIME-WINDOW': string;
|
|
21
|
+
'ACCESS-SIGNATURE': string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* GET リクエスト用の署名対象文字列を組み立てる。
|
|
26
|
+
* 形式: requestTime + timeWindow + path(クエリパラメータ含む)
|
|
27
|
+
*/
|
|
28
|
+
export function buildGetMessage(requestTime: string, timeWindow: string, path: string): string {
|
|
29
|
+
return requestTime + timeWindow + path;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* POST リクエスト用の署名対象文字列を組み立てる。
|
|
34
|
+
* 形式: requestTime + timeWindow + JSON body
|
|
35
|
+
*/
|
|
36
|
+
export function buildPostMessage(requestTime: string, timeWindow: string, body: string): string {
|
|
37
|
+
return requestTime + timeWindow + body;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* HMAC-SHA256 署名を生成する。
|
|
42
|
+
* テスト時に秘密鍵とメッセージを直接渡せるよう、純粋関数として分離。
|
|
43
|
+
*/
|
|
44
|
+
export function sign(secret: string, message: string): string {
|
|
45
|
+
return createHmac('sha256', secret).update(message).digest('hex');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* GET リクエスト用の認証ヘッダーを生成する。
|
|
50
|
+
*
|
|
51
|
+
* @param path - リクエストパス(例: '/v1/user/assets')、クエリパラメータ含む
|
|
52
|
+
* @param requestTime - テスト時に固定値を注入可能。省略時はミリ秒タイムスタンプ
|
|
53
|
+
* @param timeWindow - TIME-WINDOW 値(ミリ秒)。省略時は 5000
|
|
54
|
+
*/
|
|
55
|
+
export function createGetAuthHeaders(
|
|
56
|
+
path: string,
|
|
57
|
+
requestTime?: string,
|
|
58
|
+
timeWindow: string = DEFAULT_TIME_WINDOW,
|
|
59
|
+
): AuthHeaders {
|
|
60
|
+
const config = getPrivateApiConfig();
|
|
61
|
+
if (!config) {
|
|
62
|
+
throw new Error('BITBANK_API_KEY / BITBANK_API_SECRET が未設定です');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const rt = requestTime ?? Date.now().toString();
|
|
66
|
+
const message = buildGetMessage(rt, timeWindow, path);
|
|
67
|
+
const signature = sign(config.apiSecret, message);
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
'ACCESS-KEY': config.apiKey,
|
|
71
|
+
'ACCESS-REQUEST-TIME': rt,
|
|
72
|
+
'ACCESS-TIME-WINDOW': timeWindow,
|
|
73
|
+
'ACCESS-SIGNATURE': signature,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* POST リクエスト用の認証ヘッダーを生成する。
|
|
79
|
+
*
|
|
80
|
+
* @param body - JSON.stringify 済みのリクエストボディ
|
|
81
|
+
* @param requestTime - テスト時に固定値を注入可能。省略時はミリ秒タイムスタンプ
|
|
82
|
+
* @param timeWindow - TIME-WINDOW 値(ミリ秒)。省略時は 5000
|
|
83
|
+
*/
|
|
84
|
+
export function createPostAuthHeaders(
|
|
85
|
+
body: string,
|
|
86
|
+
requestTime?: string,
|
|
87
|
+
timeWindow: string = DEFAULT_TIME_WINDOW,
|
|
88
|
+
): AuthHeaders {
|
|
89
|
+
const config = getPrivateApiConfig();
|
|
90
|
+
if (!config) {
|
|
91
|
+
throw new Error('BITBANK_API_KEY / BITBANK_API_SECRET が未設定です');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const rt = requestTime ?? Date.now().toString();
|
|
95
|
+
const message = buildPostMessage(rt, timeWindow, body);
|
|
96
|
+
const signature = sign(config.apiSecret, message);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
'ACCESS-KEY': config.apiKey,
|
|
100
|
+
'ACCESS-REQUEST-TIME': rt,
|
|
101
|
+
'ACCESS-TIME-WINDOW': timeWindow,
|
|
102
|
+
'ACCESS-SIGNATURE': signature,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bitbank Private API HTTP クライアント。
|
|
3
|
+
*
|
|
4
|
+
* - 認証ヘッダーの付与を隠蔽し、ツールから直接認証を意識させない
|
|
5
|
+
* - HTTP 層を注入可能にし、テスト時に mock に差し替えられる
|
|
6
|
+
* - レート制限(429 / エラーコード 10009)は Retry-After に従いリトライ
|
|
7
|
+
* - Base URL: https://api.bitbank.cc(public.bitbank.cc とは別)
|
|
8
|
+
*
|
|
9
|
+
* @see https://github.com/bitbankinc/bitbank-api-docs/blob/master/rest-api.md
|
|
10
|
+
* @see https://github.com/bitbankinc/bitbank-api-docs/blob/master/errors.md
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { extractRateLimit, type RateLimitInfo } from '../../lib/http.js';
|
|
14
|
+
import { createGetAuthHeaders, createPostAuthHeaders } from './auth.js';
|
|
15
|
+
|
|
16
|
+
/** テスト時に差し替え可能な HTTP fetcher 型 */
|
|
17
|
+
export type HttpFetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
18
|
+
|
|
19
|
+
/** bitbank API の標準レスポンス形式 */
|
|
20
|
+
export interface BitbankApiResponse<T = unknown> {
|
|
21
|
+
success: number;
|
|
22
|
+
data: T;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Private API クライアントのエラー */
|
|
26
|
+
export class PrivateApiError extends Error {
|
|
27
|
+
constructor(
|
|
28
|
+
message: string,
|
|
29
|
+
public readonly errorType: string,
|
|
30
|
+
public readonly statusCode?: number,
|
|
31
|
+
public readonly bitbankCode?: number,
|
|
32
|
+
) {
|
|
33
|
+
super(message);
|
|
34
|
+
this.name = 'PrivateApiError';
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* bitbank エラーコードの分類。
|
|
40
|
+
* @see https://github.com/bitbankinc/bitbank-api-docs/blob/master/errors.md
|
|
41
|
+
*/
|
|
42
|
+
// 認証系: 20001〜20005
|
|
43
|
+
const AUTH_ERROR_CODES = new Set([20001, 20002, 20003, 20004, 20005]);
|
|
44
|
+
// レート制限: 10009
|
|
45
|
+
const RATE_LIMIT_CODES = new Set([10009]);
|
|
46
|
+
// メンテナンス/過負荷: 10007, 10008
|
|
47
|
+
const MAINTENANCE_CODES = new Set([10007, 10008]);
|
|
48
|
+
|
|
49
|
+
export interface PrivateClientOptions {
|
|
50
|
+
fetcher?: HttpFetcher;
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
maxRetries?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class BitbankPrivateClient {
|
|
56
|
+
private static readonly BASE_URL = 'https://api.bitbank.cc';
|
|
57
|
+
private readonly fetcher: HttpFetcher;
|
|
58
|
+
private readonly timeoutMs: number;
|
|
59
|
+
private readonly maxRetries: number;
|
|
60
|
+
|
|
61
|
+
/** 直近の成功レスポンスから抽出したレートリミット情報(ヘッダ未提供時は null) */
|
|
62
|
+
lastRateLimit: RateLimitInfo | null = null;
|
|
63
|
+
|
|
64
|
+
constructor(opts: PrivateClientOptions = {}) {
|
|
65
|
+
this.fetcher = opts.fetcher ?? globalThis.fetch.bind(globalThis);
|
|
66
|
+
this.timeoutMs = opts.timeoutMs ?? 5000;
|
|
67
|
+
this.maxRetries = opts.maxRetries ?? 2;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* GET リクエスト
|
|
72
|
+
* @param path - API パス(例: '/v1/user/assets')
|
|
73
|
+
* @param params - クエリパラメータ
|
|
74
|
+
*/
|
|
75
|
+
async get<T>(path: string, params?: Record<string, string>): Promise<T> {
|
|
76
|
+
let fullPath = path;
|
|
77
|
+
if (params) {
|
|
78
|
+
const qs = new URLSearchParams(params).toString();
|
|
79
|
+
if (qs) fullPath = `${path}?${qs}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const url = `${BitbankPrivateClient.BASE_URL}${fullPath}`;
|
|
83
|
+
const headers = createGetAuthHeaders(fullPath);
|
|
84
|
+
|
|
85
|
+
return this.request<T>(url, {
|
|
86
|
+
method: 'GET',
|
|
87
|
+
headers: {
|
|
88
|
+
...headers,
|
|
89
|
+
'Content-Type': 'application/json',
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* POST リクエスト
|
|
96
|
+
* @param path - API パス
|
|
97
|
+
* @param body - リクエストボディ
|
|
98
|
+
*/
|
|
99
|
+
async post<T>(path: string, body: Record<string, unknown>): Promise<T> {
|
|
100
|
+
const url = `${BitbankPrivateClient.BASE_URL}${path}`;
|
|
101
|
+
const jsonBody = JSON.stringify(body);
|
|
102
|
+
const headers = createPostAuthHeaders(jsonBody);
|
|
103
|
+
|
|
104
|
+
return this.request<T>(url, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: {
|
|
107
|
+
...headers,
|
|
108
|
+
'Content-Type': 'application/json',
|
|
109
|
+
},
|
|
110
|
+
body: jsonBody,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 共通リクエスト処理(リトライ・タイムアウト・エラーハンドリング)
|
|
116
|
+
*/
|
|
117
|
+
private async request<T>(url: string, init: RequestInit): Promise<T> {
|
|
118
|
+
let lastErr: unknown;
|
|
119
|
+
|
|
120
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
121
|
+
const ctrl = new AbortController();
|
|
122
|
+
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const res = await this.fetcher(url, { ...init, signal: ctrl.signal });
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
|
|
128
|
+
// 429 Rate Limit(HTTP レベル)
|
|
129
|
+
if (res.status === 429) {
|
|
130
|
+
const retryAfter = res.headers.get('Retry-After');
|
|
131
|
+
const waitMs = retryAfter ? parseInt(retryAfter, 10) * 1000 : 1000;
|
|
132
|
+
if (attempt < this.maxRetries) {
|
|
133
|
+
await new Promise((r) => setTimeout(r, waitMs));
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
throw new PrivateApiError(
|
|
137
|
+
`レート制限超過。${retryAfter ? `${retryAfter}秒` : 'しばらく'}待ってから再試行してください`,
|
|
138
|
+
'rate_limit_error',
|
|
139
|
+
429,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// 5xx Server Error
|
|
144
|
+
if (res.status >= 500) {
|
|
145
|
+
if (attempt < this.maxRetries) {
|
|
146
|
+
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
throw new PrivateApiError(
|
|
150
|
+
`bitbank サーバーエラー (HTTP ${res.status})。一時的な障害の可能性があります`,
|
|
151
|
+
'upstream_error',
|
|
152
|
+
res.status,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// レスポンスボディを取得
|
|
157
|
+
const body = await res.text().catch(() => '');
|
|
158
|
+
|
|
159
|
+
// HTTP エラー(4xx 等)
|
|
160
|
+
if (!res.ok) {
|
|
161
|
+
const errorCode = this.extractErrorCode(body);
|
|
162
|
+
throw this.classifyBitbankError(res.status, errorCode);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Success レスポンスのパース
|
|
166
|
+
let json: BitbankApiResponse<T>;
|
|
167
|
+
try {
|
|
168
|
+
json = JSON.parse(body) as BitbankApiResponse<T>;
|
|
169
|
+
} catch {
|
|
170
|
+
throw new PrivateApiError('レスポンスの JSON パースに失敗しました', 'upstream_error');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// レートリミット情報を抽出(成功時)
|
|
174
|
+
this.lastRateLimit = extractRateLimit(res.headers);
|
|
175
|
+
|
|
176
|
+
// success: 0 の場合(HTTP 200 でもエラー)
|
|
177
|
+
if (json.success !== 1) {
|
|
178
|
+
const errorCode = (json.data as Record<string, unknown>)?.code as number | undefined;
|
|
179
|
+
|
|
180
|
+
// レート制限エラーはリトライ
|
|
181
|
+
if (errorCode != null && RATE_LIMIT_CODES.has(errorCode)) {
|
|
182
|
+
if (attempt < this.maxRetries) {
|
|
183
|
+
await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt));
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
throw this.classifyBitbankError(200, errorCode ?? null);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return json.data;
|
|
192
|
+
} catch (err) {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
if (err instanceof PrivateApiError) throw err;
|
|
195
|
+
|
|
196
|
+
// AbortError = timeout
|
|
197
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
198
|
+
lastErr = new PrivateApiError(`タイムアウト (${this.timeoutMs}ms)`, 'upstream_error');
|
|
199
|
+
} else {
|
|
200
|
+
lastErr = err;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (attempt < this.maxRetries) {
|
|
204
|
+
await new Promise((r) => setTimeout(r, 200 * 2 ** attempt));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (lastErr instanceof PrivateApiError) throw lastErr;
|
|
210
|
+
throw new PrivateApiError(lastErr instanceof Error ? lastErr.message : 'ネットワークエラー', 'upstream_error');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** bitbank エラーレスポンスからエラーコードを抽出 */
|
|
214
|
+
private extractErrorCode(body: string): number | null {
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(body);
|
|
217
|
+
return parsed?.data?.code ?? null;
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* bitbank エラーコードを分類し、適切な PrivateApiError を生成する。
|
|
225
|
+
*
|
|
226
|
+
* エラーコード体系:
|
|
227
|
+
* - 10000 番台: システムエラー(10009 はレート制限)
|
|
228
|
+
* - 20000 番台: 認証エラー
|
|
229
|
+
* - 30000 番台: 必須パラメータ不足
|
|
230
|
+
* - 40000 番台: パラメータ不正
|
|
231
|
+
* - 50000 番台: データエラー
|
|
232
|
+
* - 60000 番台: 数値制限超過
|
|
233
|
+
* - 70000 番台: 取引制限中
|
|
234
|
+
*/
|
|
235
|
+
private classifyBitbankError(httpStatus: number, errorCode: number | null): PrivateApiError {
|
|
236
|
+
// 認証エラー
|
|
237
|
+
if (errorCode != null && AUTH_ERROR_CODES.has(errorCode)) {
|
|
238
|
+
const details: Record<number, string> = {
|
|
239
|
+
20001: 'API 認証に失敗しました',
|
|
240
|
+
20002: 'API キーが無効です',
|
|
241
|
+
20003: 'API キーが見つかりません',
|
|
242
|
+
20004: 'ACCESS-NONCE / ACCESS-REQUEST-TIME が未指定です',
|
|
243
|
+
20005: '署名が無効です。API シークレットを確認してください',
|
|
244
|
+
};
|
|
245
|
+
return new PrivateApiError(details[errorCode] ?? 'API 認証エラー', 'authentication_error', httpStatus, errorCode);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// レート制限
|
|
249
|
+
if (errorCode != null && RATE_LIMIT_CODES.has(errorCode)) {
|
|
250
|
+
return new PrivateApiError(
|
|
251
|
+
'リクエスト頻度が高すぎます。しばらく待ってから再試行してください',
|
|
252
|
+
'rate_limit_error',
|
|
253
|
+
httpStatus,
|
|
254
|
+
errorCode,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// メンテナンス / 過負荷
|
|
259
|
+
if (errorCode != null && MAINTENANCE_CODES.has(errorCode)) {
|
|
260
|
+
return new PrivateApiError(
|
|
261
|
+
errorCode === 10007
|
|
262
|
+
? 'bitbank はメンテナンス中です'
|
|
263
|
+
: 'bitbank サーバーが過負荷状態です。しばらく待ってから再試行してください',
|
|
264
|
+
'upstream_error',
|
|
265
|
+
httpStatus,
|
|
266
|
+
errorCode,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// HTTP 401/403(エラーコードなし)
|
|
271
|
+
if (httpStatus === 401 || httpStatus === 403) {
|
|
272
|
+
return new PrivateApiError(
|
|
273
|
+
'API キーまたは署名が不正です。bitbank 管理画面でキーを確認してください',
|
|
274
|
+
'authentication_error',
|
|
275
|
+
httpStatus,
|
|
276
|
+
errorCode ?? undefined,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// その他
|
|
281
|
+
return new PrivateApiError(
|
|
282
|
+
`bitbank API エラー (HTTP ${httpStatus}${errorCode ? `, code: ${errorCode}` : ''})`,
|
|
283
|
+
'upstream_error',
|
|
284
|
+
httpStatus,
|
|
285
|
+
errorCode ?? undefined,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** デフォルトのシングルトンインスタンス */
|
|
291
|
+
let defaultClient: BitbankPrivateClient | null = null;
|
|
292
|
+
|
|
293
|
+
export function getDefaultClient(): BitbankPrivateClient {
|
|
294
|
+
if (!defaultClient) {
|
|
295
|
+
defaultClient = new BitbankPrivateClient();
|
|
296
|
+
}
|
|
297
|
+
return defaultClient;
|
|
298
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private API の有効化チェック。
|
|
3
|
+
* BITBANK_API_KEY と BITBANK_API_SECRET の両方が設定されている場合のみ有効。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export interface PrivateApiConfig {
|
|
7
|
+
apiKey: string;
|
|
8
|
+
apiSecret: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Private API が有効かどうかを返す */
|
|
12
|
+
export function isPrivateApiEnabled(): boolean {
|
|
13
|
+
return !!(process.env.BITBANK_API_KEY?.trim() && process.env.BITBANK_API_SECRET?.trim());
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 環境変数から Private API 設定を読み込む。
|
|
18
|
+
* 未設定・空白のみの場合は null を返す。
|
|
19
|
+
*/
|
|
20
|
+
export function getPrivateApiConfig(): PrivateApiConfig | null {
|
|
21
|
+
const apiKey = process.env.BITBANK_API_KEY?.trim();
|
|
22
|
+
const apiSecret = process.env.BITBANK_API_SECRET?.trim();
|
|
23
|
+
if (!apiKey || !apiSecret) return null;
|
|
24
|
+
return { apiKey, apiSecret };
|
|
25
|
+
}
|