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.
Files changed (147) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +388 -0
  3. package/assets/lightweight-charts.standalone.js +7 -0
  4. package/bin/bitbank-lab-mcp.js +20 -0
  5. package/lib/cache.ts +70 -0
  6. package/lib/candle-utils.ts +48 -0
  7. package/lib/candle-validate.ts +434 -0
  8. package/lib/conversions.ts +25 -0
  9. package/lib/datetime.ts +157 -0
  10. package/lib/depth-analysis.ts +51 -0
  11. package/lib/error.ts +15 -0
  12. package/lib/formatter.ts +296 -0
  13. package/lib/get-depth.ts +111 -0
  14. package/lib/http.ts +132 -0
  15. package/lib/indicator-config.ts +39 -0
  16. package/lib/indicator_buffer.ts +41 -0
  17. package/lib/indicators.ts +579 -0
  18. package/lib/logger.ts +120 -0
  19. package/lib/ma-snapshot-utils.ts +277 -0
  20. package/lib/math.ts +89 -0
  21. package/lib/pattern-diagrams.ts +562 -0
  22. package/lib/result.ts +104 -0
  23. package/lib/validate.ts +154 -0
  24. package/lib/volatility.ts +132 -0
  25. package/package.json +79 -0
  26. package/src/env.ts +4 -0
  27. package/src/handlers/analyzeCandlePatternsHandler.ts +383 -0
  28. package/src/handlers/analyzeFibonacciHandler.ts +54 -0
  29. package/src/handlers/analyzeIndicatorsHandler.ts +682 -0
  30. package/src/handlers/analyzeMarketSignalHandler.ts +272 -0
  31. package/src/handlers/analyzeMyPortfolioHandler.ts +800 -0
  32. package/src/handlers/detectPatternsHandler.ts +77 -0
  33. package/src/handlers/detectPatternsViewsHandler.ts +518 -0
  34. package/src/handlers/getTickersJpyHandler.ts +145 -0
  35. package/src/handlers/getVolatilityMetricsHandler.ts +234 -0
  36. package/src/handlers/portfolio/calc.ts +549 -0
  37. package/src/handlers/portfolio/fetch.ts +318 -0
  38. package/src/handlers/portfolio/types.ts +170 -0
  39. package/src/handlers/renderChartSvgHandler.ts +69 -0
  40. package/src/handlers/runBacktestHandler.ts +70 -0
  41. package/src/http.ts +107 -0
  42. package/src/private/auth.ts +104 -0
  43. package/src/private/client.ts +298 -0
  44. package/src/private/config.ts +25 -0
  45. package/src/private/confirmation.ts +185 -0
  46. package/src/private/schemas.ts +866 -0
  47. package/src/prompts.ts +2296 -0
  48. package/src/resources/app-resources.ts +79 -0
  49. package/src/schema/analysis.ts +942 -0
  50. package/src/schema/backtest.ts +100 -0
  51. package/src/schema/base.ts +88 -0
  52. package/src/schema/candle-validate.ts +135 -0
  53. package/src/schema/chart.ts +399 -0
  54. package/src/schema/index.ts +11 -0
  55. package/src/schema/indicators.ts +125 -0
  56. package/src/schema/market-data.ts +298 -0
  57. package/src/schema/patterns.ts +382 -0
  58. package/src/schema/types.ts +97 -0
  59. package/src/schemas.d.ts +37 -0
  60. package/src/schemas.ts +7 -0
  61. package/src/server.ts +405 -0
  62. package/src/tool-definition.ts +44 -0
  63. package/src/tool-registry.ts +174 -0
  64. package/src/types/express-shim.d.ts +9 -0
  65. package/src/types/schemas.generated.d.ts +23 -0
  66. package/tools/analyze_bb_snapshot.ts +385 -0
  67. package/tools/analyze_candle_patterns.ts +810 -0
  68. package/tools/analyze_currency_strength.ts +273 -0
  69. package/tools/analyze_ema_snapshot.ts +183 -0
  70. package/tools/analyze_fibonacci.ts +530 -0
  71. package/tools/analyze_ichimoku_snapshot.ts +606 -0
  72. package/tools/analyze_indicators.ts +691 -0
  73. package/tools/analyze_market_signal.ts +665 -0
  74. package/tools/analyze_mtf_fibonacci.ts +273 -0
  75. package/tools/analyze_mtf_sma.ts +175 -0
  76. package/tools/analyze_sma_snapshot.ts +146 -0
  77. package/tools/analyze_stoch_snapshot.ts +276 -0
  78. package/tools/analyze_support_resistance.ts +817 -0
  79. package/tools/analyze_volume_profile.ts +546 -0
  80. package/tools/chart/ichimoku-cloud.ts +113 -0
  81. package/tools/chart/render-depth.ts +139 -0
  82. package/tools/chart/render-sub-panels.ts +208 -0
  83. package/tools/chart/svg-utils.ts +102 -0
  84. package/tools/detect_macd_cross.ts +691 -0
  85. package/tools/detect_patterns.ts +424 -0
  86. package/tools/detect_whale_events.ts +181 -0
  87. package/tools/get_candles.ts +487 -0
  88. package/tools/get_flow_metrics.ts +596 -0
  89. package/tools/get_orderbook.ts +540 -0
  90. package/tools/get_ticker.ts +132 -0
  91. package/tools/get_tickers_jpy.ts +240 -0
  92. package/tools/get_transactions.ts +209 -0
  93. package/tools/get_volatility_metrics.ts +302 -0
  94. package/tools/patterns/aftermath.ts +212 -0
  95. package/tools/patterns/config.ts +151 -0
  96. package/tools/patterns/detect_doubles.ts +650 -0
  97. package/tools/patterns/detect_hs.ts +635 -0
  98. package/tools/patterns/detect_pennants.ts +373 -0
  99. package/tools/patterns/detect_triangles.ts +820 -0
  100. package/tools/patterns/detect_triples.ts +633 -0
  101. package/tools/patterns/detect_wedges.ts +1072 -0
  102. package/tools/patterns/helpers.ts +517 -0
  103. package/tools/patterns/index.ts +40 -0
  104. package/tools/patterns/regression.ts +153 -0
  105. package/tools/patterns/smoothing.ts +168 -0
  106. package/tools/patterns/swing.ts +91 -0
  107. package/tools/patterns/types.ts +193 -0
  108. package/tools/prepare_chart_data.ts +294 -0
  109. package/tools/prepare_depth_data.ts +189 -0
  110. package/tools/private/analyze_my_portfolio.ts +21 -0
  111. package/tools/private/cancel_order.ts +127 -0
  112. package/tools/private/cancel_orders.ts +121 -0
  113. package/tools/private/create_order.ts +236 -0
  114. package/tools/private/get_margin_positions.ts +134 -0
  115. package/tools/private/get_margin_status.ts +155 -0
  116. package/tools/private/get_margin_trade_history.ts +156 -0
  117. package/tools/private/get_my_assets.ts +207 -0
  118. package/tools/private/get_my_deposit_withdrawal.ts +500 -0
  119. package/tools/private/get_my_orders.ts +157 -0
  120. package/tools/private/get_my_trade_history.ts +229 -0
  121. package/tools/private/get_order.ts +95 -0
  122. package/tools/private/get_orders_info.ts +90 -0
  123. package/tools/private/preview_cancel_order.ts +172 -0
  124. package/tools/private/preview_cancel_orders.ts +137 -0
  125. package/tools/private/preview_order.ts +292 -0
  126. package/tools/render_candle_pattern_diagram.ts +389 -0
  127. package/tools/render_chart_svg.ts +799 -0
  128. package/tools/render_depth_svg.ts +274 -0
  129. package/tools/trading_process/index.ts +7 -0
  130. package/tools/trading_process/lib/backtest_engine.ts +252 -0
  131. package/tools/trading_process/lib/equity.ts +131 -0
  132. package/tools/trading_process/lib/fetch_candles.ts +181 -0
  133. package/tools/trading_process/lib/sma.ts +62 -0
  134. package/tools/trading_process/lib/strategies/bb_breakout.ts +141 -0
  135. package/tools/trading_process/lib/strategies/index.ts +52 -0
  136. package/tools/trading_process/lib/strategies/macd_cross.ts +256 -0
  137. package/tools/trading_process/lib/strategies/rsi.ts +133 -0
  138. package/tools/trading_process/lib/strategies/sma_cross.ts +214 -0
  139. package/tools/trading_process/lib/strategies/types.ts +118 -0
  140. package/tools/trading_process/lib/svg_to_png.ts +64 -0
  141. package/tools/trading_process/render_backtest_chart_generic.ts +729 -0
  142. package/tools/trading_process/run_backtest.ts +243 -0
  143. package/tools/trading_process/types.ts +85 -0
  144. package/tools/validate_candle_data.ts +260 -0
  145. package/tsconfig.json +17 -0
  146. package/ui/cancel-confirm/dist/cancel-confirm.html +99 -0
  147. package/ui/order-confirm/dist/order-confirm.html +99 -0
@@ -0,0 +1,800 @@
1
+ /**
2
+ * analyzeMyPortfolioHandler — ポートフォリオ分析のメインハンドラ。
3
+ *
4
+ * データ取得・計算ロジックは以下のモジュールに分離:
5
+ * - portfolio/types.ts — 型定義
6
+ * - portfolio/fetch.ts — API データ取得レイヤー
7
+ * - portfolio/calc.ts — 純粋計算ロジック
8
+ */
9
+
10
+ import { dayjs, nowIso } from '../../lib/datetime.js';
11
+ import { formatPair, formatPercent, formatPrice, formatPriceJPY } from '../../lib/formatter.js';
12
+ import { fail, ok } from '../../lib/result.js';
13
+ import { getDefaultClient, PrivateApiError } from '../private/client.js';
14
+ import { AnalyzeMyPortfolioOutputSchema } from '../private/schemas.js';
15
+ import {
16
+ buildEquitySeries,
17
+ calcDepositWithdrawalSummary,
18
+ calcPeriodDWSummary,
19
+ calcPeriodNetFlow,
20
+ calcPeriodRealizedPnl,
21
+ calcPnl,
22
+ calcPortfolioValue,
23
+ getJstPeriodBoundaries,
24
+ reconstructHoldingsAtDate,
25
+ } from './portfolio/calc.js';
26
+ import {
27
+ fetchCandlePriceData,
28
+ fetchDepositWithdrawal,
29
+ fetchTechnical,
30
+ fetchTickerPrices,
31
+ paginateTrades,
32
+ } from './portfolio/fetch.js';
33
+ import type {
34
+ CandlePriceData,
35
+ DepositWithdrawalSummary,
36
+ EquityPoint,
37
+ PeriodDWSummary,
38
+ PeriodPerformance,
39
+ PeriodRealizedPnl,
40
+ RawAsset,
41
+ RawTrade,
42
+ TechnicalSummary,
43
+ } from './portfolio/types.js';
44
+
45
+ export default async function analyzeMyPortfolioHandler(args: {
46
+ include_technical?: boolean;
47
+ include_pnl?: boolean;
48
+ include_deposit_withdrawal?: boolean;
49
+ }) {
50
+ const { include_technical = true, include_pnl = true, include_deposit_withdrawal = true } = args;
51
+ const client = getDefaultClient();
52
+
53
+ try {
54
+ // 1. 保有資産 + ticker を並列取得
55
+ const [rawAssets, prices] = await Promise.all([
56
+ client.get<{ assets: RawAsset[] }>('/v1/user/assets'),
57
+ fetchTickerPrices(),
58
+ ]);
59
+
60
+ // ゼロでない資産(JPY 含む)
61
+ const nonZeroAssets = rawAssets.assets.filter((a) => {
62
+ const amount = Number(a.onhand_amount);
63
+ return Number.isFinite(amount) && amount > 0;
64
+ });
65
+
66
+ // JST 基準の年初来・月初来の境界(API フェッチの since パラメータにも使用)
67
+ const boundaries = getJstPeriodBoundaries();
68
+
69
+ // 2. 約定履歴 + 入出金履歴を並列取得(年初以降のみ)
70
+ // 年次は年初比、月次は月初比の比較のため、年初以降のデータで十分。
71
+ // 全履歴を取得しないことで API 呼び出し回数を削減し、pagination 上限の問題も回避。
72
+ const tradePromise = include_pnl
73
+ ? paginateTrades(client, boundaries.yearStartMs)
74
+ : Promise.resolve({ trades: [] as RawTrade[], truncated: false });
75
+
76
+ const dwPromise = include_deposit_withdrawal
77
+ ? fetchDepositWithdrawal(client, boundaries.yearStartMs)
78
+ : Promise.resolve(null);
79
+
80
+ const [tradeResult, dwData] = await Promise.all([tradePromise, dwPromise]);
81
+ const allTrades = tradeResult.trades;
82
+ const tradesTruncated = tradeResult.truncated;
83
+
84
+ // 期間パフォーマンス用: 全関連ペアのキャンドルデータを早期フェッチ開始
85
+ const allRelevantPairs = new Set<string>();
86
+ for (const a of nonZeroAssets) {
87
+ if (a.asset !== 'jpy') allRelevantPairs.add(`${a.asset}_jpy`);
88
+ }
89
+ for (const t of allTrades) {
90
+ if (t.pair.endsWith('_jpy') && !t.pair.startsWith('jpy_')) {
91
+ allRelevantPairs.add(t.pair);
92
+ }
93
+ }
94
+ const candlePricePromise = include_pnl
95
+ ? fetchCandlePriceData(
96
+ [...allRelevantPairs],
97
+ boundaries.yearStartMs,
98
+ boundaries.monthStartMs,
99
+ boundaries.dayStartMs,
100
+ )
101
+ : Promise.resolve({ boundaryPrices: new Map(), dailyPrices: new Map() } as CandlePriceData);
102
+
103
+ const timestamp = nowIso();
104
+
105
+ // 3. 各保有通貨の損益算出
106
+ let totalJpyValue = 0;
107
+ let _totalCostBasis = 0;
108
+ let totalRealizedPnl = 0;
109
+ let _hasCostData = false;
110
+
111
+ const holdings = nonZeroAssets.map((a) => {
112
+ const amount = a.onhand_amount;
113
+ const isJpy = a.asset === 'jpy';
114
+
115
+ // JPY はそのまま評価額 = 保有量
116
+ const currentPrice = isJpy ? 1 : prices.get(a.asset);
117
+ const jpyValue = isJpy ? Number(amount) : currentPrice ? Number(amount) * currentPrice : undefined;
118
+
119
+ if (jpyValue != null && Number.isFinite(jpyValue)) {
120
+ totalJpyValue += jpyValue;
121
+ }
122
+
123
+ // JPY は損益計算不要
124
+ if (isJpy) {
125
+ return {
126
+ asset: a.asset,
127
+ pair: 'jpy',
128
+ amount,
129
+ avg_buy_price: undefined,
130
+ current_price: undefined,
131
+ jpy_value: jpyValue != null ? Math.round(jpyValue) : undefined,
132
+ cost_basis: undefined,
133
+ unrealized_pnl: undefined,
134
+ unrealized_pnl_pct: undefined,
135
+ realized_pnl: undefined,
136
+ trade_count: undefined,
137
+ };
138
+ }
139
+
140
+ const pair = `${a.asset}_jpy`;
141
+ const pnl = include_pnl ? calcPnl(allTrades, a.asset, dwData?.withdrawals) : undefined;
142
+
143
+ if (pnl?.cost_basis != null) {
144
+ _totalCostBasis += pnl.cost_basis;
145
+ _hasCostData = true;
146
+ }
147
+ if (pnl) {
148
+ totalRealizedPnl += pnl.realized_pnl;
149
+ }
150
+
151
+ const unrealizedPnl =
152
+ jpyValue != null && pnl?.cost_basis != null ? Math.round(jpyValue - pnl.cost_basis) : undefined;
153
+ const unrealizedPnlPct =
154
+ unrealizedPnl != null && pnl?.cost_basis != null && pnl.cost_basis > 0
155
+ ? Math.round((unrealizedPnl / pnl.cost_basis) * 10000) / 100
156
+ : undefined;
157
+
158
+ return {
159
+ asset: a.asset,
160
+ pair,
161
+ amount,
162
+ avg_buy_price: pnl?.avg_buy_price != null ? Math.round(pnl.avg_buy_price) : undefined,
163
+ current_price: currentPrice != null ? Math.round(currentPrice) : undefined,
164
+ jpy_value: jpyValue != null ? Math.round(jpyValue) : undefined,
165
+ cost_basis: pnl?.cost_basis != null ? Math.round(pnl.cost_basis) : undefined,
166
+ unrealized_pnl: unrealizedPnl,
167
+ unrealized_pnl_pct: unrealizedPnlPct,
168
+ realized_pnl: pnl?.realized_pnl,
169
+ trade_count: pnl?.trade_count,
170
+ };
171
+ });
172
+
173
+ // 売り切り銘柄の実現損益を集計(現在保有ゼロだが約定履歴がある通貨)
174
+ if (include_pnl && allTrades.length > 0) {
175
+ const heldAssets = new Set(nonZeroAssets.map((a) => a.asset));
176
+ const tradedAssets = new Set(allTrades.map((t) => t.pair.replace('_jpy', '')).filter((a) => a !== 'jpy'));
177
+ for (const asset of tradedAssets) {
178
+ if (!heldAssets.has(asset)) {
179
+ const pnl = calcPnl(allTrades, asset, dwData?.withdrawals);
180
+ if (pnl.realized_pnl !== 0) {
181
+ totalRealizedPnl += pnl.realized_pnl;
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ // 6.5. 年初来・月初来の実現損益を算出(JST 基準)
188
+ let yearlyRealizedPnl: PeriodRealizedPnl | undefined;
189
+ let monthlyRealizedPnl: PeriodRealizedPnl | undefined;
190
+ if (include_pnl && allTrades.length > 0) {
191
+ yearlyRealizedPnl = calcPeriodRealizedPnl(
192
+ allTrades,
193
+ boundaries.yearStartMs,
194
+ boundaries.yearStartIso,
195
+ boundaries.nowIso,
196
+ );
197
+ monthlyRealizedPnl = calcPeriodRealizedPnl(
198
+ allTrades,
199
+ boundaries.monthStartMs,
200
+ boundaries.monthStartIso,
201
+ boundaries.nowIso,
202
+ );
203
+ }
204
+
205
+ // 6.6. 期間別パフォーマンス(評価額比較)— 主指標
206
+ let yearlyPerformance: PeriodPerformance | undefined;
207
+ let monthlyPerformance: PeriodPerformance | undefined;
208
+ let dailyPerformance: PeriodPerformance | undefined;
209
+ let monthlyEquitySeries: EquityPoint[] | undefined;
210
+ let yearlyEquitySeries: EquityPoint[] | undefined;
211
+ if (include_pnl) {
212
+ const candlePriceData = await candlePricePromise;
213
+ const periodPrices = candlePriceData.boundaryPrices;
214
+ const currentJpyValueRounded = Math.round(totalJpyValue);
215
+ const performanceNote =
216
+ '期初評価額は現在の保有状態から約定・入出金を逆算して復元し、期初時点の始値(1day candle open)で評価。暗号資産の入出庫は現在価格で仮評価。純入出金は元本移動のみ(出金手数料を含まない)。調整後増減 = 単純増減 - 純入出金(市場変動 + 出金手数料コスト)。';
217
+
218
+ // 年初比パフォーマンス
219
+ const yearStartHoldings = reconstructHoldingsAtDate(
220
+ nonZeroAssets.map((a) => ({ asset: a.asset, amount: a.onhand_amount })),
221
+ allTrades,
222
+ boundaries.yearStartMs,
223
+ dwData,
224
+ );
225
+ const yearStartPriceMap = new Map<string, number>();
226
+ for (const [asset, pp] of periodPrices) {
227
+ if (pp.yearStart != null) yearStartPriceMap.set(asset, pp.yearStart);
228
+ }
229
+ const yearStartValue = Math.round(calcPortfolioValue(yearStartHoldings, yearStartPriceMap));
230
+ const yearFlow = calcPeriodNetFlow(dwData, boundaries.yearStartMs, prices);
231
+ const yearChange = currentJpyValueRounded - yearStartValue;
232
+ const yearAdjusted = yearChange - yearFlow.net_flow_jpy;
233
+ yearlyPerformance = {
234
+ start_value_jpy: yearStartValue,
235
+ current_value_jpy: currentJpyValueRounded,
236
+ change_jpy: yearChange,
237
+ change_pct: yearStartValue > 0 ? Math.round((yearChange / yearStartValue) * 10000) / 100 : undefined,
238
+ net_flow_jpy: yearFlow.net_flow_jpy,
239
+ withdrawal_fee_jpy: yearFlow.withdrawal_fee_jpy,
240
+ adjusted_change_jpy: yearAdjusted,
241
+ adjusted_change_pct: yearStartValue > 0 ? Math.round((yearAdjusted / yearStartValue) * 10000) / 100 : undefined,
242
+ period_start: boundaries.yearStartIso,
243
+ period_end: boundaries.nowIso,
244
+ note: performanceNote,
245
+ };
246
+
247
+ // 月初比パフォーマンス
248
+ const monthStartHoldings = reconstructHoldingsAtDate(
249
+ nonZeroAssets.map((a) => ({ asset: a.asset, amount: a.onhand_amount })),
250
+ allTrades,
251
+ boundaries.monthStartMs,
252
+ dwData,
253
+ );
254
+ const monthStartPriceMap = new Map<string, number>();
255
+ for (const [asset, pp] of periodPrices) {
256
+ if (pp.monthStart != null) monthStartPriceMap.set(asset, pp.monthStart);
257
+ }
258
+ const monthStartValue = Math.round(calcPortfolioValue(monthStartHoldings, monthStartPriceMap));
259
+ const monthFlow = calcPeriodNetFlow(dwData, boundaries.monthStartMs, prices);
260
+ const monthChange = currentJpyValueRounded - monthStartValue;
261
+ const monthAdjusted = monthChange - monthFlow.net_flow_jpy;
262
+ monthlyPerformance = {
263
+ start_value_jpy: monthStartValue,
264
+ current_value_jpy: currentJpyValueRounded,
265
+ change_jpy: monthChange,
266
+ change_pct: monthStartValue > 0 ? Math.round((monthChange / monthStartValue) * 10000) / 100 : undefined,
267
+ net_flow_jpy: monthFlow.net_flow_jpy,
268
+ withdrawal_fee_jpy: monthFlow.withdrawal_fee_jpy,
269
+ adjusted_change_jpy: monthAdjusted,
270
+ adjusted_change_pct:
271
+ monthStartValue > 0 ? Math.round((monthAdjusted / monthStartValue) * 10000) / 100 : undefined,
272
+ period_start: boundaries.monthStartIso,
273
+ period_end: boundaries.nowIso,
274
+ note: performanceNote,
275
+ };
276
+
277
+ // 前日比(当日 00:00 JST)パフォーマンス
278
+ const dayStartHoldings = reconstructHoldingsAtDate(
279
+ nonZeroAssets.map((a) => ({ asset: a.asset, amount: a.onhand_amount })),
280
+ allTrades,
281
+ boundaries.dayStartMs,
282
+ dwData,
283
+ );
284
+ const dayStartPriceMap = new Map<string, number>();
285
+ for (const [asset, pp] of periodPrices) {
286
+ if (pp.dayStart != null) dayStartPriceMap.set(asset, pp.dayStart);
287
+ }
288
+ const dayStartValue = Math.round(calcPortfolioValue(dayStartHoldings, dayStartPriceMap));
289
+ const dayFlow = calcPeriodNetFlow(dwData, boundaries.dayStartMs, prices);
290
+ const dayChange = currentJpyValueRounded - dayStartValue;
291
+ const dayAdjusted = dayChange - dayFlow.net_flow_jpy;
292
+ dailyPerformance = {
293
+ start_value_jpy: dayStartValue,
294
+ current_value_jpy: currentJpyValueRounded,
295
+ change_jpy: dayChange,
296
+ change_pct: dayStartValue > 0 ? Math.round((dayChange / dayStartValue) * 10000) / 100 : undefined,
297
+ net_flow_jpy: dayFlow.net_flow_jpy,
298
+ withdrawal_fee_jpy: dayFlow.withdrawal_fee_jpy,
299
+ adjusted_change_jpy: dayAdjusted,
300
+ adjusted_change_pct: dayStartValue > 0 ? Math.round((dayAdjusted / dayStartValue) * 10000) / 100 : undefined,
301
+ period_start: boundaries.dayStartIso,
302
+ period_end: boundaries.nowIso,
303
+ note: performanceNote,
304
+ };
305
+
306
+ // 6.7. 資産推移時系列データの構築(月次: 日次点、年次: 月次点)
307
+ if (candlePriceData.dailyPrices.size > 0) {
308
+ const holdingsForReconstruction = nonZeroAssets.map((a) => ({ asset: a.asset, amount: a.onhand_amount }));
309
+ const nowJst = dayjs().tz('Asia/Tokyo');
310
+
311
+ // Monthly: daily points from month start through today 00:00 JST, + current
312
+ const monthDates: ReturnType<typeof dayjs>[] = [];
313
+ let d = dayjs(boundaries.monthStartMs).tz('Asia/Tokyo');
314
+ const todayStart = nowJst.startOf('day');
315
+ while (!d.isAfter(todayStart)) {
316
+ monthDates.push(d);
317
+ d = d.add(1, 'day');
318
+ }
319
+ monthlyEquitySeries = buildEquitySeries(
320
+ monthDates,
321
+ holdingsForReconstruction,
322
+ allTrades,
323
+ dwData,
324
+ candlePriceData.dailyPrices,
325
+ currentJpyValueRounded,
326
+ boundaries.nowIso,
327
+ );
328
+
329
+ // Yearly: monthly points from year start through current month start, + current
330
+ const yearDates: ReturnType<typeof dayjs>[] = [];
331
+ let m = dayjs(boundaries.yearStartMs).tz('Asia/Tokyo');
332
+ const currentMonthStart = nowJst.startOf('month');
333
+ while (!m.isAfter(currentMonthStart)) {
334
+ yearDates.push(m);
335
+ m = m.add(1, 'month');
336
+ }
337
+ yearlyEquitySeries = buildEquitySeries(
338
+ yearDates,
339
+ holdingsForReconstruction,
340
+ allTrades,
341
+ dwData,
342
+ candlePriceData.dailyPrices,
343
+ currentJpyValueRounded,
344
+ boundaries.nowIso,
345
+ );
346
+ }
347
+ }
348
+
349
+ // JPY 評価額降順ソート
350
+ holdings.sort((a, b) => (b.jpy_value ?? 0) - (a.jpy_value ?? 0));
351
+
352
+ // 暗号資産 / JPY を分離(テクニカル分析・サマリー・評価損益で使い分ける)
353
+ const cryptoHoldings = holdings.filter((h) => h.asset !== 'jpy');
354
+ const jpyHolding = holdings.find((h) => h.asset === 'jpy');
355
+
356
+ // 合計評価損益(暗号資産部分のみ。JPY 残高は cost_basis に含めない)
357
+ // ticker 未取得の銘柄がある場合は totalCostBasis に原価だけ積まれて過大なマイナスになるため、
358
+ // 現在値が取れた銘柄の原価だけを集計し直す
359
+ let validCostBasis = 0;
360
+ let validJpyValue = 0;
361
+ for (const h of cryptoHoldings) {
362
+ if (h.jpy_value != null && h.cost_basis != null) {
363
+ validCostBasis += h.cost_basis;
364
+ validJpyValue += h.jpy_value;
365
+ }
366
+ }
367
+ const hasValidCostData = validCostBasis > 0;
368
+ const totalUnrealizedPnl = hasValidCostData ? Math.round(validJpyValue - validCostBasis) : undefined;
369
+ const totalUnrealizedPnlPct =
370
+ totalUnrealizedPnl != null && validCostBasis > 0
371
+ ? Math.round((totalUnrealizedPnl / validCostBasis) * 10000) / 100
372
+ : undefined;
373
+
374
+ // ticker 未取得の銘柄がある場合は警告
375
+ const missingPriceAssets = cryptoHoldings
376
+ .filter((h) => h.jpy_value == null && h.cost_basis != null)
377
+ .map((h) => h.asset.toUpperCase());
378
+ const hasMissingPrices = missingPriceAssets.length > 0;
379
+
380
+ // 保有銘柄のパフォーマンス(月次・年次の価格騰落率)
381
+ let holdingsPerformance:
382
+ | Array<{
383
+ asset: string;
384
+ pair: string;
385
+ current_price: number | undefined;
386
+ monthly_change_pct: number | undefined;
387
+ yearly_change_pct: number | undefined;
388
+ jpy_value: number | undefined;
389
+ amount: string;
390
+ }>
391
+ | undefined;
392
+ if (include_pnl) {
393
+ const candlePriceData = await candlePricePromise;
394
+ const periodPrices = candlePriceData.boundaryPrices;
395
+ holdingsPerformance = cryptoHoldings.map((h) => {
396
+ const currentPrice = prices.get(h.asset);
397
+ const bp = periodPrices.get(h.asset);
398
+ const monthlyChangePct =
399
+ currentPrice != null && bp?.monthStart != null && bp.monthStart > 0
400
+ ? Math.round(((currentPrice - bp.monthStart) / bp.monthStart) * 10000) / 100
401
+ : undefined;
402
+ const yearlyChangePct =
403
+ currentPrice != null && bp?.yearStart != null && bp.yearStart > 0
404
+ ? Math.round(((currentPrice - bp.yearStart) / bp.yearStart) * 10000) / 100
405
+ : undefined;
406
+ return {
407
+ asset: h.asset,
408
+ pair: h.pair,
409
+ current_price: h.current_price,
410
+ monthly_change_pct: monthlyChangePct,
411
+ yearly_change_pct: yearlyChangePct,
412
+ jpy_value: h.jpy_value,
413
+ amount: h.amount,
414
+ };
415
+ });
416
+ }
417
+
418
+ // 4. 入出金ベースのリターン計算(Phase 4)
419
+ let dwSummary: DepositWithdrawalSummary | undefined;
420
+ let yearlyDWSummary: PeriodDWSummary | undefined;
421
+ let monthlyDWSummary: PeriodDWSummary | undefined;
422
+ const dwWarnings: string[] = [];
423
+ if (dwData) {
424
+ if (dwData.allFailed) {
425
+ // 全リクエスト失敗: trade_only フォールバック + 警告
426
+ dwWarnings.push('入出金履歴の取得に全て失敗したため、約定ベースの分析のみです');
427
+ } else {
428
+ if (dwData.warnings.length > 0) {
429
+ dwWarnings.push(...dwData.warnings.map((w) => `注意: ${w}(部分的なデータで概算)`));
430
+ }
431
+ if (dwData.deposits.length > 0 || dwData.withdrawals.length > 0) {
432
+ dwSummary = calcDepositWithdrawalSummary(dwData, totalJpyValue, prices);
433
+ // 年次・月次の入出金サマリー
434
+ yearlyDWSummary = calcPeriodDWSummary(
435
+ dwData,
436
+ boundaries.yearStartMs,
437
+ boundaries.yearStartIso,
438
+ boundaries.nowIso,
439
+ prices,
440
+ );
441
+ monthlyDWSummary = calcPeriodDWSummary(
442
+ dwData,
443
+ boundaries.monthStartMs,
444
+ boundaries.monthStartIso,
445
+ boundaries.nowIso,
446
+ prices,
447
+ );
448
+ }
449
+ }
450
+ }
451
+
452
+ // 5. テクニカル分析(オプション、暗号資産のみ)
453
+ let technical: TechnicalSummary[] | undefined;
454
+ if (include_technical && cryptoHoldings.length > 0) {
455
+ const jpyPairs = cryptoHoldings.filter((h) => h.jpy_value != null).map((h) => h.pair);
456
+ technical = await fetchTechnical(jpyPairs);
457
+ }
458
+
459
+ // 5.5. depositWithdrawalStatus の判定(summary 生成より先に確定する):
460
+ // - not_requested: include_deposit_withdrawal=false
461
+ // - available: 入出金データ取得成功+分析実行
462
+ // - no_history: API取得成功・警告なし・本当に履歴0件
463
+ // - fallback: API取得失敗・partial failure 等で約定ベースにフォールバック
464
+ let depositWithdrawalStatus: 'available' | 'fallback' | 'no_history' | 'not_requested';
465
+ if (!include_deposit_withdrawal) {
466
+ depositWithdrawalStatus = 'not_requested';
467
+ } else if (dwSummary != null) {
468
+ depositWithdrawalStatus = 'available';
469
+ } else if (
470
+ dwData &&
471
+ !dwData.allFailed &&
472
+ dwData.warnings.length === 0 &&
473
+ dwData.deposits.length === 0 &&
474
+ dwData.withdrawals.length === 0
475
+ ) {
476
+ depositWithdrawalStatus = 'no_history';
477
+ } else {
478
+ depositWithdrawalStatus = 'fallback';
479
+ }
480
+
481
+ // 6. サマリー文字列の生成
482
+ const lines: string[] = [];
483
+ lines.push(`ポートフォリオ分析: 暗号資産 ${cryptoHoldings.length}銘柄${jpyHolding ? ' + JPY' : ''}`);
484
+ lines.push(`取得時刻: ${timestamp}`);
485
+ if (totalJpyValue > 0) {
486
+ lines.push(
487
+ `口座合計: ${formatPrice(Math.round(totalJpyValue))}${jpyHolding ? ` (うち JPY: ${formatPriceJPY(jpyHolding.jpy_value ?? 0)})` : ''}`,
488
+ );
489
+ }
490
+
491
+ // 主指標: 前日比・年初比・月初比の口座評価額増減
492
+ if (dailyPerformance) {
493
+ const dSign = dailyPerformance.change_jpy >= 0 ? '+' : '';
494
+ lines.push(
495
+ `前日比: ${formatPriceJPY(dailyPerformance.start_value_jpy)} → ${formatPriceJPY(dailyPerformance.current_value_jpy)}`,
496
+ );
497
+ lines.push(
498
+ ` 増減: ${dSign}${formatPriceJPY(dailyPerformance.change_jpy)}${dailyPerformance.change_pct != null ? ` (${formatPercent(dailyPerformance.change_pct, { sign: true })})` : ''}`,
499
+ );
500
+ if (dailyPerformance.net_flow_jpy !== 0 || dailyPerformance.withdrawal_fee_jpy > 0) {
501
+ const adjSign = dailyPerformance.adjusted_change_jpy >= 0 ? '+' : '';
502
+ lines.push(
503
+ ` 入出金調整後: ${adjSign}${formatPriceJPY(dailyPerformance.adjusted_change_jpy)}${dailyPerformance.adjusted_change_pct != null ? ` (${formatPercent(dailyPerformance.adjusted_change_pct, { sign: true })})` : ''}`,
504
+ );
505
+ const flowSign = dailyPerformance.net_flow_jpy >= 0 ? '+' : '';
506
+ lines.push(` 純入出金(元本): ${flowSign}${formatPriceJPY(dailyPerformance.net_flow_jpy)}`);
507
+ if (dailyPerformance.withdrawal_fee_jpy > 0) {
508
+ lines.push(` 出金手数料: -${formatPriceJPY(dailyPerformance.withdrawal_fee_jpy)}`);
509
+ }
510
+ }
511
+ }
512
+ if (yearlyPerformance) {
513
+ const ySign = yearlyPerformance.change_jpy >= 0 ? '+' : '';
514
+ lines.push(
515
+ `年初比: ${formatPriceJPY(yearlyPerformance.start_value_jpy)} → ${formatPriceJPY(yearlyPerformance.current_value_jpy)}`,
516
+ );
517
+ lines.push(
518
+ ` 増減: ${ySign}${formatPriceJPY(yearlyPerformance.change_jpy)}${yearlyPerformance.change_pct != null ? ` (${formatPercent(yearlyPerformance.change_pct, { sign: true })})` : ''}`,
519
+ );
520
+ if (yearlyPerformance.net_flow_jpy !== 0 || yearlyPerformance.withdrawal_fee_jpy > 0) {
521
+ const adjSign = yearlyPerformance.adjusted_change_jpy >= 0 ? '+' : '';
522
+ lines.push(
523
+ ` 入出金調整後: ${adjSign}${formatPriceJPY(yearlyPerformance.adjusted_change_jpy)}${yearlyPerformance.adjusted_change_pct != null ? ` (${formatPercent(yearlyPerformance.adjusted_change_pct, { sign: true })})` : ''}`,
524
+ );
525
+ const flowSign = yearlyPerformance.net_flow_jpy >= 0 ? '+' : '';
526
+ lines.push(` 純入出金(元本): ${flowSign}${formatPriceJPY(yearlyPerformance.net_flow_jpy)}`);
527
+ if (yearlyPerformance.withdrawal_fee_jpy > 0) {
528
+ lines.push(` 出金手数料: -${formatPriceJPY(yearlyPerformance.withdrawal_fee_jpy)}`);
529
+ }
530
+ }
531
+ }
532
+ if (monthlyPerformance) {
533
+ const mSign = monthlyPerformance.change_jpy >= 0 ? '+' : '';
534
+ lines.push(
535
+ `月初比: ${formatPriceJPY(monthlyPerformance.start_value_jpy)} → ${formatPriceJPY(monthlyPerformance.current_value_jpy)}`,
536
+ );
537
+ lines.push(
538
+ ` 増減: ${mSign}${formatPriceJPY(monthlyPerformance.change_jpy)}${monthlyPerformance.change_pct != null ? ` (${formatPercent(monthlyPerformance.change_pct, { sign: true })})` : ''}`,
539
+ );
540
+ if (monthlyPerformance.net_flow_jpy !== 0 || monthlyPerformance.withdrawal_fee_jpy > 0) {
541
+ const adjSign = monthlyPerformance.adjusted_change_jpy >= 0 ? '+' : '';
542
+ lines.push(
543
+ ` 入出金調整後: ${adjSign}${formatPriceJPY(monthlyPerformance.adjusted_change_jpy)}${monthlyPerformance.adjusted_change_pct != null ? ` (${formatPercent(monthlyPerformance.adjusted_change_pct, { sign: true })})` : ''}`,
544
+ );
545
+ const flowSign = monthlyPerformance.net_flow_jpy >= 0 ? '+' : '';
546
+ lines.push(` 純入出金(元本): ${flowSign}${formatPriceJPY(monthlyPerformance.net_flow_jpy)}`);
547
+ if (monthlyPerformance.withdrawal_fee_jpy > 0) {
548
+ lines.push(` 出金手数料: -${formatPriceJPY(monthlyPerformance.withdrawal_fee_jpy)}`);
549
+ }
550
+ }
551
+ }
552
+ if (yearlyPerformance || monthlyPerformance) {
553
+ lines.push(`期間基準: JST`);
554
+ lines.push('※ 期初評価額は約定・入出金を逆算して復元、期初始値で評価。暗号資産入出庫は現在価格で仮評価');
555
+ lines.push('※ 出金元本は外部フローとして除外、出金手数料はコストとして performance に含む');
556
+ }
557
+ if (monthlyEquitySeries && monthlyEquitySeries.length > 0) {
558
+ lines.push(`月次資産推移(日次, ${monthlyEquitySeries.length}点):`);
559
+ for (let i = 0; i < monthlyEquitySeries.length; i++) {
560
+ const p = monthlyEquitySeries[i];
561
+ const label = i === monthlyEquitySeries.length - 1 ? '(現在)' : '';
562
+ lines.push(` ${p.timestamp}: ${formatPriceJPY(p.value_jpy)}${label}`);
563
+ }
564
+ }
565
+ if (yearlyEquitySeries && yearlyEquitySeries.length > 0) {
566
+ lines.push(`年次資産推移(月次, ${yearlyEquitySeries.length}点):`);
567
+ for (let i = 0; i < yearlyEquitySeries.length; i++) {
568
+ const p = yearlyEquitySeries[i];
569
+ const label = i === yearlyEquitySeries.length - 1 ? '(現在)' : '';
570
+ lines.push(` ${p.timestamp}: ${formatPriceJPY(p.value_jpy)}${label}`);
571
+ }
572
+ }
573
+
574
+ // 年次・月次の入出金サマリー
575
+ if (yearlyDWSummary) {
576
+ const y = yearlyDWSummary;
577
+ const parts = [
578
+ `年初来入出金: JPY入金 ${formatPriceJPY(y.jpy_deposited)} / JPY出金 ${formatPriceJPY(y.jpy_withdrawn)} / 純入出金 ${formatPriceJPY(y.net_jpy)}`,
579
+ ];
580
+ if (y.crypto_deposit_count > 0)
581
+ parts.push(
582
+ `暗号資産入庫 ${y.crypto_deposit_count}件${y.crypto_deposit_estimated_jpy ? `(概算 ${formatPriceJPY(y.crypto_deposit_estimated_jpy)})` : ''}`,
583
+ );
584
+ if (y.crypto_withdrawal_count > 0)
585
+ parts.push(
586
+ `暗号資産出庫 ${y.crypto_withdrawal_count}件${y.crypto_withdrawal_estimated_jpy ? `(概算 ${formatPriceJPY(y.crypto_withdrawal_estimated_jpy)})` : ''}`,
587
+ );
588
+ lines.push(parts.join(' / '));
589
+ }
590
+ if (monthlyDWSummary) {
591
+ const m = monthlyDWSummary;
592
+ const parts = [
593
+ `月初来入出金: JPY入金 ${formatPriceJPY(m.jpy_deposited)} / JPY出金 ${formatPriceJPY(m.jpy_withdrawn)} / 純入出金 ${formatPriceJPY(m.net_jpy)}`,
594
+ ];
595
+ if (m.crypto_deposit_count > 0)
596
+ parts.push(
597
+ `暗号資産入庫 ${m.crypto_deposit_count}件${m.crypto_deposit_estimated_jpy ? `(概算 ${formatPriceJPY(m.crypto_deposit_estimated_jpy)})` : ''}`,
598
+ );
599
+ if (m.crypto_withdrawal_count > 0)
600
+ parts.push(
601
+ `暗号資産出庫 ${m.crypto_withdrawal_count}件${m.crypto_withdrawal_estimated_jpy ? `(概算 ${formatPriceJPY(m.crypto_withdrawal_estimated_jpy)})` : ''}`,
602
+ );
603
+ lines.push(parts.join(' / '));
604
+ }
605
+
606
+ // 入出金分析状態と分析基準をsummaryに明示(structuredContentを見ないLLM向け)
607
+ if (depositWithdrawalStatus === 'available') {
608
+ lines.push(`入出金分析状態: available`);
609
+ lines.push(`分析基準: deposit_withdrawal`);
610
+ } else if (depositWithdrawalStatus === 'fallback') {
611
+ lines.push(`入出金分析状態: fallback`);
612
+ lines.push(`分析基準: trade_only`);
613
+ if (dwData?.allFailed) {
614
+ lines.push('※ 入出金APIの取得に全て失敗したため、約定ベースの分析のみです');
615
+ } else {
616
+ lines.push('※ API取得失敗またはpartial failureのため、約定ベースの分析にフォールバックしています');
617
+ }
618
+ } else if (depositWithdrawalStatus === 'no_history') {
619
+ lines.push(`入出金分析状態: no_history`);
620
+ lines.push(`分析基準: trade_only`);
621
+ lines.push('※ 入出金履歴が0件のため、入出金ベース分析なし。約定ベースの分析のみです');
622
+ } else {
623
+ // not_requested
624
+ lines.push(`入出金分析状態: not_requested`);
625
+ lines.push(`分析基準: trade_only`);
626
+ lines.push('※ 入出金分析は未リクエスト。約定ベースの分析のみです');
627
+ }
628
+
629
+ // 入出金ベースの口座全体リターン(Phase 4)
630
+ if (dwSummary && dwSummary.account_return_jpy != null) {
631
+ const sign = dwSummary.account_return_jpy >= 0 ? '+' : '';
632
+ const approxLabel = dwSummary.is_complete ? '' : '(概算)';
633
+ lines.push(
634
+ `口座全体リターン${approxLabel}: ${sign}${formatPriceJPY(dwSummary.account_return_jpy)} (${formatPercent(dwSummary.account_return_pct, { sign: true })})`,
635
+ );
636
+ // 内訳を式追跡しやすい形で表示
637
+ lines.push(` JPY入金合計: ${formatPriceJPY(dwSummary.total_jpy_deposited)}`);
638
+ if (dwSummary.total_jpy_withdrawn > 0) {
639
+ lines.push(` JPY出金合計: ${formatPriceJPY(dwSummary.total_jpy_withdrawn)}`);
640
+ }
641
+ const netJpyDeposit = dwSummary.total_jpy_deposited - dwSummary.total_jpy_withdrawn;
642
+ lines.push(` JPY純入金: ${formatPriceJPY(Math.round(netJpyDeposit))}`);
643
+ if (dwSummary.crypto_deposit_estimated_jpy) {
644
+ lines.push(
645
+ ` 暗号資産入庫の仮評価: ${formatPriceJPY(dwSummary.crypto_deposit_estimated_jpy)}(${dwSummary.crypto_deposit_count}件、現在価格ベース)`,
646
+ );
647
+ }
648
+ lines.push(
649
+ ` 純投入額: ${formatPriceJPY(dwSummary.net_jpy_invested)}${dwSummary.crypto_deposit_estimated_jpy ? '(JPY純入金 + 暗号資産入庫の仮評価)' : ''}`,
650
+ );
651
+ if (!dwSummary.is_complete) {
652
+ lines.push(' ※ 入出金履歴が多く全件取得できなかったため、概算値です');
653
+ }
654
+ if (dwSummary.crypto_deposit_count > 0 && !dwSummary.crypto_deposit_estimated_jpy) {
655
+ lines.push(` ※ 暗号資産入庫 ${dwSummary.crypto_deposit_count}件の価格が取得できず仮評価に含まれていません`);
656
+ }
657
+ if (dwSummary.crypto_withdrawal_count > 0) {
658
+ lines.push(` ※ 暗号資産出庫 ${dwSummary.crypto_withdrawal_count}件は送金として損益計算から除外しています`);
659
+ }
660
+ }
661
+
662
+ // 入出金取得の警告
663
+ if (dwWarnings.length > 0) {
664
+ for (const w of dwWarnings) {
665
+ lines.push(` ${w}`);
666
+ }
667
+ }
668
+
669
+ if (totalUnrealizedPnl != null) {
670
+ const sign = totalUnrealizedPnl >= 0 ? '+' : '';
671
+ lines.push(
672
+ `合計評価損益(当年約定ベース、参考値): ${sign}${formatPriceJPY(totalUnrealizedPnl)} (${formatPercent(totalUnrealizedPnlPct, { sign: true })})`,
673
+ );
674
+ }
675
+ lines.push('※ 評価損益は当年(1/1〜)の約定ベース。年初以前の取得原価は含みません');
676
+ if (tradesTruncated) {
677
+ lines.push('※ 約定履歴が上限に達したため一部のみ取得。損益計算が不正確な可能性があります');
678
+ }
679
+ lines.push('');
680
+
681
+ // 保有銘柄のパフォーマンス(月次・年次の価格騰落率)
682
+ if (holdingsPerformance && holdingsPerformance.length > 0) {
683
+ lines.push('保有銘柄のパフォーマンス:');
684
+ for (const hp of holdingsPerformance) {
685
+ const assetUpper = hp.asset.toUpperCase();
686
+ const parts = [`${assetUpper}`];
687
+ if (hp.jpy_value != null) parts.push(formatPriceJPY(hp.jpy_value));
688
+ if (hp.monthly_change_pct != null)
689
+ parts.push(`月次騰落率: ${formatPercent(hp.monthly_change_pct, { sign: true })}`);
690
+ if (hp.yearly_change_pct != null)
691
+ parts.push(`年次騰落率: ${formatPercent(hp.yearly_change_pct, { sign: true })}`);
692
+ lines.push(` ${parts.join(' / ')}`);
693
+ }
694
+ }
695
+
696
+ // ticker 未取得警告
697
+ if (hasMissingPrices) {
698
+ lines.push('');
699
+ lines.push(`注意: ${missingPriceAssets.join(', ')} の現在価格が取得できなかったため、評価損益から除外しています`);
700
+ }
701
+
702
+ // テクニカルサマリー
703
+ if (technical && technical.length > 0) {
704
+ lines.push('');
705
+ lines.push('テクニカル分析:');
706
+ for (const t of technical) {
707
+ const parts = [formatPair(t.pair)];
708
+ if (t.trend) parts.push(`トレンド: ${t.trend}`);
709
+ if (t.rsi_14 != null) parts.push(`RSI: ${t.rsi_14}`);
710
+ if (t.sma_deviation_pct != null) parts.push(`SMA乖離: ${formatPercent(t.sma_deviation_pct, { sign: true })}`);
711
+ if (t.signal) parts.push(`総合判定: ${t.signal}`);
712
+ lines.push(` ${parts.join(' / ')}`);
713
+ }
714
+ }
715
+
716
+ const summary = lines.join('\n');
717
+
718
+ // deposit_withdrawal_summary の出し分け(status に基づく一貫した契約):
719
+ // - available: dwSummary(実データ、analysis_basis='deposit_withdrawal')
720
+ // - fallback: placeholder(analysis_basis='trade_only')— 常に返す
721
+ // - no_history: undefined(API成功だが履歴なし)
722
+ // - not_requested: undefined(未リクエスト)
723
+ const fallbackPlaceholder = {
724
+ total_jpy_deposited: 0,
725
+ total_jpy_withdrawn: 0,
726
+ net_jpy_invested: 0,
727
+ crypto_deposit_count: 0,
728
+ crypto_deposit_estimated_jpy: undefined,
729
+ crypto_withdrawal_count: 0,
730
+ account_return_pct: undefined,
731
+ account_return_jpy: undefined,
732
+ is_complete: false,
733
+ analysis_basis: 'trade_only' as const,
734
+ };
735
+
736
+ const depositWithdrawalSummary =
737
+ depositWithdrawalStatus === 'available'
738
+ ? dwSummary
739
+ : depositWithdrawalStatus === 'fallback'
740
+ ? fallbackPlaceholder
741
+ : undefined;
742
+
743
+ const data = {
744
+ holdings,
745
+ total_jpy_value: totalJpyValue > 0 ? Math.round(totalJpyValue) : undefined,
746
+ total_cost_basis: hasValidCostData ? Math.round(validCostBasis) : undefined,
747
+ total_unrealized_pnl: totalUnrealizedPnl,
748
+ total_unrealized_pnl_pct: totalUnrealizedPnlPct,
749
+ total_realized_pnl: totalRealizedPnl !== 0 ? totalRealizedPnl : undefined,
750
+ daily_performance: dailyPerformance,
751
+ yearly_performance: yearlyPerformance,
752
+ monthly_performance: monthlyPerformance,
753
+ monthly_equity_series: monthlyEquitySeries,
754
+ yearly_equity_series: yearlyEquitySeries,
755
+ yearly_realized_pnl: yearlyRealizedPnl
756
+ ? {
757
+ realized_pnl: yearlyRealizedPnl.realized_pnl,
758
+ sell_count: yearlyRealizedPnl.sell_count,
759
+ period_start: yearlyRealizedPnl.period_start,
760
+ period_end: yearlyRealizedPnl.period_end,
761
+ }
762
+ : undefined,
763
+ monthly_realized_pnl: monthlyRealizedPnl
764
+ ? {
765
+ realized_pnl: monthlyRealizedPnl.realized_pnl,
766
+ sell_count: monthlyRealizedPnl.sell_count,
767
+ period_start: monthlyRealizedPnl.period_start,
768
+ period_end: monthlyRealizedPnl.period_end,
769
+ }
770
+ : undefined,
771
+ deposit_withdrawal_summary: depositWithdrawalSummary,
772
+ yearly_dw_summary: yearlyDWSummary,
773
+ monthly_dw_summary: monthlyDWSummary,
774
+ holdings_performance: holdingsPerformance && holdingsPerformance.length > 0 ? holdingsPerformance : undefined,
775
+ technical: technical && technical.length > 0 ? technical : undefined,
776
+ timestamp,
777
+ };
778
+
779
+ const meta = {
780
+ fetchedAt: timestamp,
781
+ holdingCount: holdings.length,
782
+ hasPnl: include_pnl && allTrades.length > 0,
783
+ hasTechnical: include_technical && (technical?.length ?? 0) > 0,
784
+ depositWithdrawalStatus,
785
+ periodBasis: 'jst' as const,
786
+ };
787
+
788
+ return AnalyzeMyPortfolioOutputSchema.parse(ok(summary, data, meta));
789
+ } catch (err) {
790
+ if (err instanceof PrivateApiError) {
791
+ return AnalyzeMyPortfolioOutputSchema.parse(fail(err.message, err.errorType));
792
+ }
793
+ return AnalyzeMyPortfolioOutputSchema.parse(
794
+ fail(
795
+ err instanceof Error ? err.message : 'ポートフォリオ分析中に予期しないエラーが発生しました',
796
+ 'upstream_error',
797
+ ),
798
+ );
799
+ }
800
+ }