This commit is contained in:
Shotaro Nakamura
2026-06-19 21:39:59 +09:00
parent 5a15172513
commit b843a5ff38
36 changed files with 3891 additions and 1008 deletions
+11
View File
@@ -13,6 +13,16 @@ export type {
SbiClientOptions,
StoredWebAuthnCredential,
StoredWebAuthnCredentialSecret,
AccountAssetsValuationDetail,
AccountAssetsValuationSummary,
AccountAssetsValuations,
ExchangeAccountKind,
ExchangeOrderPreview,
ExchangeOrderReceipt,
ExchangeOrderSide,
ExchangeRateInfo,
ExchangeSellMethod,
ExchangeSpecificMethod,
WebAuthnAlgorithm,
WebAuthnJwk,
WebAuthnTransport,
@@ -23,6 +33,7 @@ export type {
IssueSearchItem,
IssueSearchResult,
IssueSearchStatus,
MarketCode,
OrderCorrectionPreOrder,
OrderCorrectionPreOrderDetail,
OrderPreview,
+50
View File
@@ -0,0 +1,50 @@
import type { MarketCode } from './types'
export type MarketRegion = 'domestic' | 'us'
const DOMESTIC_TO_MTS = {
XTKS: 'TKY',
} as const satisfies Record<string, string>
const MTS_TO_DOMESTIC = Object.fromEntries(
Object.entries(DOMESTIC_TO_MTS).map(([mic, mts]) => [mts, mic]),
) as Record<string, MarketCode>
const US_MARKETS = new Set<MarketCode>(['XNAS', 'XNYS', 'ARCX'])
export const marketRegion = (market: MarketCode | undefined, methodName: string): MarketRegion => {
if (!market) throw new Error(`${methodName} requires market`)
if (market in DOMESTIC_TO_MTS) return 'domestic'
if (market === 'STK') return 'domestic'
if (US_MARKETS.has(market)) return 'us'
throw new Error(`${methodName} does not support market: ${market}`)
}
export const isUsMarket = (market: MarketCode | undefined) =>
market != null && US_MARKETS.has(market)
export const domesticMarketToMts = (market: MarketCode | undefined, methodName: string) => {
if (!market) throw new Error(`${methodName} requires market`)
const mts = DOMESTIC_TO_MTS[market as keyof typeof DOMESTIC_TO_MTS]
if (!mts) throw new Error(`${methodName} does not support domestic market: ${market}`)
return mts
}
export const mtsMarketToDomestic = (market: string | undefined): MarketCode | undefined => {
if (!market) return undefined
return MTS_TO_DOMESTIC[market]
}
export const requireDomesticMarket = (market: MarketCode | undefined, methodName: string) => {
const region = marketRegion(market, methodName)
if (region !== 'domestic') {
throw new Error(`${methodName} supports only domestic markets`)
}
}
export const requireUsMarket = (market: MarketCode | undefined, methodName: string) => {
const region = marketRegion(market, methodName)
if (region !== 'us') {
throw new Error(`${methodName} supports only US stock markets`)
}
}
File diff suppressed because it is too large Load Diff
+68 -4
View File
@@ -1,11 +1,19 @@
import type {
AccountProfile,
AccountAssetsValuations,
AccountType,
Board,
BuyingPower,
CashPositionList,
ChartPeriod,
DepositType,
ExchangeAccountKind,
ExchangeOrderPreview,
ExchangeOrderReceipt,
ExchangeOrderSide,
ExchangeRateInfo,
ExchangeSellMethod,
ExchangeSpecificMethod,
DomesticMarket,
IssueCode,
IssueChart,
@@ -50,7 +58,7 @@ export type IssueOptions = {
/** Issue code to request. */
issueCode: IssueCode
/** Market code to request. */
market?: MarketCode
market: MarketCode
}
export type MarketIssueBoardPollingOptions = IssueOptions & {
@@ -72,8 +80,8 @@ export type IssueChartOptions = IssueOptions & {
export type IssueSearchOptions = {
/** Search text, such as an issue code, name, or keyword. */
query: string
/** Filters returned issues by market code on the client side. */
market?: MarketCode
/** Market code to search. */
market: MarketCode
/** Maximum number of returned issues after client-side filtering. */
limit?: number
}
@@ -138,6 +146,8 @@ export type StockOrderBaseOptions = {
quantity: number
/** Deposit type used for the order. */
depositType?: DepositType
/** US stock settlement method. Defaults to yen settlement for foreign stock orders. */
foreignStockSettlementMethod?: 'yen' | 'foreign'
}
export type CashOrderPriceCondition =
@@ -387,6 +397,37 @@ export type PlaceOrderCancelOptions = OrderCancelOptions & {
allowTrading?: true
}
export type ExchangeOrderOptions = {
/** Currency code, such as USD. */
currencyCode: string
/** Buy or sell the foreign currency. */
side: ExchangeOrderSide
/** Quantity entered on the SBI exchange order screen. */
tradeQuantity: number | string
/** `foreign` means foreign-currency quantity; `domestic` means yen amount. */
specificMethod?: ExchangeSpecificMethod
/** SBI account kind. Defaults to GENERAL. */
accountKind?: ExchangeAccountKind
/** Required for sell orders when using the exchange web flow. */
sellMethod?: ExchangeSellMethod
/** Hidden order amount posted to SBI. Defaults to tradeQuantity for foreign quantity orders. */
orderAmount?: number | string
/** Trading password used by SBI. Defaults to session tradePassword. */
tradePassword?: string
}
export type ExchangeRateOptions = {
/** Currency code, such as USD. */
currencyCode: string
/** Buy or sell the foreign currency. */
side: ExchangeOrderSide
}
export type PlaceExchangeOrderOptions = ExchangeOrderOptions & {
/** Explicitly allows sending a live exchange order. */
allowTrading?: true
}
export type MarginCloseOrderOptions = StandardCashOrderOptions & {
/** Position ID to close. */
positionId?: PositionId
@@ -504,6 +545,11 @@ export type AccountPowerOptions = {
includeMarginAccount?: boolean
}
export type ProfitLossOptions = {
/** Market to fetch profit/loss for. Omit for domestic cash/margin summary. */
market?: MarketCode
}
export interface SbiClientMethodSession {
/** Returns the current authenticated session profile. */
profile(): Promise<AccountProfile>
@@ -541,12 +587,19 @@ export interface SbiClientMethodAccountPositions {
export interface SbiClientMethodAccountProfitLoss {
/** Fetches the unrealized profit and loss summary for cash and margin positions. */
unrealized(): Promise<ProfitLossSummary>
unrealized(options?: ProfitLossOptions): Promise<ProfitLossSummary>
}
export interface SbiClientMethodAccountAssets {
/** Fetches current My Assets valuations from the SBI main site. */
current(): Promise<AccountAssetsValuations>
}
export interface SbiClientMethodAccount {
/** Returns the current account profile. */
profile(): Promise<AccountProfile>
/** Methods for fetching My Assets values from the SBI main site. */
assets: SbiClientMethodAccountAssets
/** Methods for fetching buying power and collateral information. */
power: SbiClientMethodAccountPower
/** Methods for fetching cash and margin positions. */
@@ -688,6 +741,15 @@ export interface SbiClientMethodThemeInvestmentOrder {
place(options: PlaceThemeInvestmentOrderOptions): Promise<OrderReceipt>
}
export interface SbiClientMethodExchangeOrder {
/** Fetches the current exchange-order input rate and limits. */
rate(options: ExchangeRateOptions): Promise<ExchangeRateInfo>
/** Estimates an exchange order without submitting a live order. */
estimate(options: ExchangeOrderOptions): Promise<ExchangeOrderPreview>
/** Places a live exchange order. Requires `allowTrading: true`. */
place(options: PlaceExchangeOrderOptions): Promise<ExchangeOrderReceipt>
}
export interface SbiClientMethodOrders {
/** Methods for order inquiries. */
inquiry: SbiClientMethodOrderInquiry
@@ -699,6 +761,8 @@ export interface SbiClientMethodOrders {
ifd: SbiClientMethodIfdOrder
/** Methods for estimating and placing theme investment orders. */
themeInvestment: SbiClientMethodThemeInvestmentOrder
/** Methods for estimating and placing exchange orders. */
exchange: SbiClientMethodExchangeOrder
}
export interface SbiClientMethods {
+810
View File
@@ -0,0 +1,810 @@
import { randomUUID } from 'node:crypto'
import type {
Board,
CashPosition,
CashPositionList,
ChartPeriod,
ChartPrice,
CurrencyAmount,
IssueChart,
IssueSearchResult,
MarketCode,
Order,
OrderList,
OrderPreview,
OrderReceipt,
SbiSession,
SignedTextValue,
StockOrderPreOrder,
TradeSide,
} from '../types'
import type {
BoardOptions,
CashOrderOptions,
CashOrderPreOrderOptions,
IssueChartOptions,
IssueOptions,
IssueSearchOptions,
OrderInquiryOptions,
PlaceCashOrderOptions,
PlaceOrderCancelOptions,
} from './types'
import { requireUsMarket } from '../markets'
const COUNTRY_US = 'US'
const DEFAULT_CHART_COUNT = 120
const US_CHART_INTERVALS: Record<ChartPeriod, Record<number, string>> = {
minute: { 1: '1', 5: '2', 10: '3', 15: '4' },
day: { 1: '7' },
week: { 1: '8' },
month: { 1: '9' },
}
export const createUsStockAdapter = (session: SbiSession) => ({
search: async (options: IssueSearchOptions): Promise<IssueSearchResult> => {
requireUsMarket(options.market, 'market.issue.search')
const data = await callUsGraphql(session, 'SearchStocks', SEARCH_STOCKS, {
input: {
countryCode: COUNTRY_US,
searchKeyword: options.query,
matchType: 'CONTAINS',
marketCode: usGraphqlMarketCode(options.market),
page: { pageNum: 1, pageSize: options.limit ?? 20 },
},
})
const stocks = arrayAt(data, ['listForeignStockSecurities', 'foreignStocks'])
return {
statusText: 'success',
issues: stocks.map((stock) => {
const securities = objectAt(stock, ['securities'])
const market = usMarketFromGraphql(objectAt(stock, ['market']), options.market)
return {
code: stringAt(securities, ['securitiesCode']) ?? '',
market,
name:
stringAt(securities, ['securitiesName']) ??
stringAt(securities, ['securitiesShortName']),
}
}),
}
},
board: async (options: IssueOptions): Promise<Board> => {
const detail = await fetchStockDetail(session, options, 'market.issue.board')
const quote = quoteFromDetail(detail, options)
return {
issue: quote.issue,
bids: priceLevels(detail.marketPrice, 'bid', 'bidSize'),
asks: priceLevels(detail.marketPrice, 'ask', 'askSize'),
quote,
}
},
chart: async (options: IssueChartOptions): Promise<IssueChart> => {
requireUsMarket(options.market, 'market.issue.chart')
const normalized = normalizedUsChartOptions(options)
const data = await callUsRest(
session,
`information/chart/rics/${encodeURIComponent(usRic(options.issueCode, options.market))}/candles:listLatestCandles`,
{
count: String(normalized.count),
interval: normalized.interval,
countryCode: COUNTRY_US,
},
{ hash: 'candle' },
)
const prices = arrayAt(data, ['candles'])
.map(usChartPrice)
.filter((price): price is ChartPrice => price != null)
.reverse()
const detail = await fetchStockDetail(session, options, 'market.issue.chart')
const quote = quoteFromDetail(detail, options)
return {
issue: quote.issue,
period: normalized.period,
unit: normalized.unit,
prices,
previousClose: quote.previousClose,
currentPrice: quote.price,
highPrice: quote.high,
lowPrice: quote.low,
latestDateTime: prices.at(-1)?.dateTime,
}
},
openOrders: async (options: IssueOptions): Promise<OrderList> => {
requireUsMarket(options.market, 'market.issue.openOrders')
const list = await fetchUsOrders(session, {
issueCode: options.issueCode,
market: options.market,
})
return { orders: list.orders.filter((order) => order.issue.code === options.issueCode) }
},
tradingInfo: async (options: BoardOptions): Promise<Board> => {
const detail = await fetchStockDetail(session, options, 'market.issue.tradingInfo')
const quote = quoteFromDetail(detail, options)
return {
issue: quote.issue,
bids: priceLevels(detail.marketPrice, 'bid', 'bidSize'),
asks: priceLevels(detail.marketPrice, 'ask', 'askSize'),
quote,
}
},
positions: async (): Promise<CashPositionList> => fetchUsCashPositions(session),
unrealized: async () => {
const positions = await fetchUsCashPositions(session)
return {
cash: positions.totalProfitLoss,
total: positions.totalProfitLoss,
totalRate: positions.totalProfitLossRate,
error: positions.error,
}
},
orders: async (options?: OrderInquiryOptions): Promise<OrderList> =>
fetchUsOrders(session, options),
preOrder: async (options: CashOrderPreOrderOptions): Promise<StockOrderPreOrder> => {
requireUsMarket(options.market, 'orders.cash.preOrder')
const data = await callUsGraphql(session, 'GetOrderCreatingInitialData', ORDER_INITIAL_DATA, {
buySellCode: buySellCode(options.side),
countryCode: COUNTRY_US,
securitiesCode: options.issueCode,
rics: [usRic(options.issueCode, options.market)],
})
const init = objectAt(data, ['getForeignStockCreatedOrderInitialization'])
const securities =
objectAt(init, ['securities']) ?? objectAt(data, ['getForeignStockSecurities', 'securities'])
return {
issue: {
code: stringAt(securities, ['securitiesCode']) ?? options.issueCode,
market: options.market,
name:
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
},
market: options.market,
currentPrice: usd(
stringAt(arrayAt(data, ['listMarketPrices', 'marketPrices'])[0], ['price', 'last']),
),
priceSteps: arrayAt(init, ['tickSizes']).map((tick) => ({
from: usd(stringAt(tick, ['tickSize'])),
})),
orderTerms: stringArrayAt(init, ['orderTerms']),
orderTermDates: [],
paymentLimits: stringArrayAt(init, ['settlementMethodCodes']).map((code) => ({ code })),
}
},
estimate: async (options: CashOrderOptions): Promise<OrderPreview> => {
requireUsMarket(options.market, 'orders.cash.estimate')
assertUsCashOrderOptions(options, 'orders.cash.estimate')
const orderInput = await resolveUsOrderInput(session, options, 'orders.cash.estimate')
const data = await callUsGraphql(
session,
'ConfirmOrderCreating',
CONFIRM_ORDER,
{ input: { order: orderInput } },
{ tradePassword: requireUsTradePassword(session, 'orders.cash.estimate') },
)
return orderPreviewFromConfirmation(data, options)
},
place: async (options: PlaceCashOrderOptions): Promise<OrderReceipt> => {
requireUsMarket(options.market, 'orders.cash.place')
if (options.allowTrading !== true) {
throw new Error('orders.cash.place requires allowTrading: true')
}
assertUsCashOrderOptions(options, 'orders.cash.place')
const orderInput = await resolveUsOrderInput(session, options, 'orders.cash.place')
const data = await callUsGraphql(
session,
'SubmitOrderCreating',
SUBMIT_ORDER,
{ input: { order: orderInput } },
{ tradePassword: requireUsTradePassword(session, 'orders.cash.place') },
)
const order = objectAt(data, ['createForeignStockOrder', 'order'])
return {
accepted: true,
orderId: stringAt(order, ['orderNo']) ?? stringAt(order, ['orderSubNo']),
acceptedAt: stringAt(order, ['orderInputDatetime']),
message: stringAt(data, ['createForeignStockOrder', 'message']),
}
},
placeCancel: async (options: PlaceOrderCancelOptions): Promise<OrderReceipt> => {
if (options.allowTrading !== true) {
throw new Error('orders.cash.placeCancel requires allowTrading: true')
}
throw new Error('orders.cash.placeCancel is not implemented for US stock markets')
},
})
const fetchStockDetail = async (session: SbiSession, options: IssueOptions, methodName: string) => {
requireUsMarket(options.market, methodName)
const data = await callUsGraphql(session, 'GetStockDetail', STOCK_DETAIL, {
countryCode: COUNTRY_US,
securitiesCode: options.issueCode,
rics: [usRic(options.issueCode, options.market)],
})
const stock = objectAt(data, ['getForeignStockSecurities'])
const marketPrice = arrayAt(data, ['listMarketPrices', 'marketPrices'])[0]
return { stock, marketPrice }
}
const fetchUsCashPositions = async (session: SbiSession): Promise<CashPositionList> => {
const data = await callUsGraphql(session, 'GetSecuritiesBalanceList', SECURITIES_BALANCES, {
input: { countryCode: COUNTRY_US, page: { pageNum: 1, pageSize: 999 } },
})
const balances = arrayAt(data, ['listSecuritiesBalances', 'securitiesBalances'])
const positions = balances.map((balance): CashPosition => {
const securities = objectAt(balance, ['securities'])
const evaluation = objectAt(balance, ['evaluationProfitLoss'])
const market = usMarketFromGraphql(objectAt(balance, ['market']))
return {
issue: {
code: stringAt(securities, ['securitiesCode']) ?? '',
market,
name:
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
},
accountType: mapUsSpecificAccount(stringAt(balance, ['specificAccountCode'])),
depositType: mapUsSpecificAccount(stringAt(balance, ['specificAccountCode'])),
quantity: numberAt(balance, ['securitiesQuantity']),
currentPrice: usd(stringAt(balance, ['stockPrice', 'last'])),
averagePrice: usd(stringAt(balance, ['frnAcquisitionPrice'])),
purchasePrice: usd(stringAt(balance, ['frnAcquisitionPrice'])),
marketValue: usd(stringAt(evaluation, ['frnEvaluationAmount'])),
valuationPrice: usd(stringAt(evaluation, ['frnEvaluationAmount'])),
profitLoss: signed(stringAt(evaluation, ['frnEvaluationProfitLoss'])),
profitLossRate: percent(stringAt(evaluation, ['frnEvaluationProfitLossPercent'])),
}
})
return {
positions,
totalCount: positions.length,
totalMarketValue: sumAmounts(positions.map((position) => position.marketValue)),
totalProfitLoss: sumSigned(positions.map((position) => position.profitLoss)),
}
}
const fetchUsOrders = async (
session: SbiSession,
options?: OrderInquiryOptions,
): Promise<OrderList> => {
const data = await callUsGraphql(session, 'GetOrderList', ORDER_LIST, {
input: { countryCode: COUNTRY_US, page: { pageNum: 1, pageSize: options?.limit ?? 999 } },
})
const orders = arrayAt(data, ['listForeignStockOrders', 'orderDecodes']).map(orderFromGraphql)
return {
orders: orders.filter((order) => {
if (options?.issueCode && order.issue.code !== options.issueCode) return false
if (options?.market && order.issue.market !== options.market) return false
if (options?.status && order.status !== options.status) return false
return true
}),
}
}
const callUsGraphql = async (
session: SbiSession,
operationName: string,
query: string,
variables: Record<string, unknown>,
options: { tradePassword?: string } = {},
) => {
const us = session.foreignStock
if (!us) {
throw new Error(
'foreign stock session is not configured; pass foreignStockBaseUrl/usStockBaseUrl to loginWithPasskey',
)
}
if (!us.sessionId || !us.accountId || us.loginAuthenticated !== true) {
throw new Error('foreign stock session is not authenticated')
}
const response = await fetch(us.endpoints.graphqlIntUrl, {
method: 'POST',
headers: {
accept: 'application/json',
authorization: `Bearer ${us.sessionId}`,
'account-id': us.accountId,
'content-type': 'application/json',
...(us.endpoints.userAgent ? { 'user-agent': us.endpoints.userAgent } : {}),
...(us.marketPriceHash ? { hash_token: us.marketPriceHash } : {}),
...(options.tradePassword
? {
request_id: randomUUID(),
trade_password: Buffer.from(options.tradePassword, 'utf8').toString('base64'),
}
: {}),
},
body: JSON.stringify({ operationName, query, variables }),
})
const text = await response.text()
if (!response.ok) {
throw new Error(
`foreign stock GraphQL ${operationName} failed with HTTP ${response.status}: ${text}`,
)
}
const json = JSON.parse(text) as { data?: unknown; errors?: unknown }
if (json.errors) {
throw new Error(
`foreign stock GraphQL ${operationName} returned errors: ${JSON.stringify(json.errors)}`,
)
}
if (!json.data || typeof json.data !== 'object') {
throw new Error(`foreign stock GraphQL ${operationName} returned no data`)
}
return json.data as Record<string, unknown>
}
const callUsRest = async (
session: SbiSession,
path: string,
params: Record<string, string | undefined>,
options: { hash?: 'marketPrice' | 'candle' } = {},
) => {
const us = session.foreignStock
if (!us) {
throw new Error(
'foreign stock session is not configured; pass foreignStockBaseUrl/usStockBaseUrl to loginWithPasskey',
)
}
if (!us.sessionId || !us.accountId || us.loginAuthenticated !== true) {
throw new Error('foreign stock session is not authenticated')
}
const url = new URL(path, us.endpoints.restUrl)
for (const [key, value] of Object.entries(params)) {
if (value != null) url.searchParams.set(key, value)
}
const hashToken = options.hash === 'candle' ? us.candleHash : us.marketPriceHash
if (options.hash && !hashToken) {
throw new Error(`foreign stock ${options.hash} hash token is not available`)
}
const response = await fetch(url, {
headers: {
accept: 'application/json',
authorization: `Bearer ${us.sessionId}`,
'account-id': us.accountId,
...(us.endpoints.userAgent ? { 'user-agent': us.endpoints.userAgent } : {}),
...(hashToken ? { hash_token: hashToken } : {}),
},
})
const text = await response.text()
if (!response.ok) {
throw new Error(`foreign stock REST ${path} failed with HTTP ${response.status}: ${text}`)
}
try {
const json = text ? JSON.parse(text) : undefined
if (!json || typeof json !== 'object') {
throw new Error(`foreign stock REST ${path} returned no data`)
}
return json as Record<string, unknown>
} catch (error) {
if (error instanceof SyntaxError) {
throw new Error(`foreign stock REST ${path} returned non-JSON response`)
}
throw error
}
}
const quoteFromDetail = (
detail: { stock: unknown; marketPrice: unknown },
options: IssueOptions,
) => {
const securities = objectAt(detail.stock, ['securities'])
const price = objectAt(detail.marketPrice, ['price'])
return {
issue: {
code: stringAt(securities, ['securitiesCode']) ?? options.issueCode,
market: options.market,
name:
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
},
price: usd(stringAt(price, ['last'])),
change: signed(stringAt(price, ['change'])),
changeRate: percent(stringAt(price, ['changePercent'])),
open: usd(stringAt(price, ['open'])),
high: usd(stringAt(price, ['high'])),
low: usd(stringAt(price, ['low'])),
previousClose: usd(stringAt(price, ['prevClose'])),
volume: numberAt(price, ['volume']),
timestamp: stringAt(price, ['lastDatetime']),
}
}
const usChartPrice = (value: unknown): ChartPrice | undefined => {
const candle = value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
if (!candle) return undefined
const dateTime = stringAt(candle, ['startDatetime'])
const close = usd(stringAt(candle, ['close']))
if (!dateTime || close.value == null) return undefined
return {
dateTime,
open: usd(stringAt(candle, ['open'])),
high: usd(stringAt(candle, ['high'])),
low: usd(stringAt(candle, ['low'])),
close,
volume: numberAt(candle, ['volume']),
}
}
const priceLevels = (source: unknown, priceKey: string, quantityKey: string) => {
const price = stringAt(source, [priceKey])
if (price == null) return []
return [{ price: usd(price), quantity: numberAt(source, [quantityKey]) }]
}
const orderPreviewFromConfirmation = (
data: Record<string, unknown>,
options: CashOrderOptions,
): OrderPreview => {
const confirmation = objectAt(data, ['confirmForeignStockCreatedOrder'])
const order = objectAt(confirmation, ['order'])
return {
issue: { code: options.issueCode, market: options.market },
side: options.side,
quantity: options.quantity,
price: usd(String(options.price ?? '')),
estimatedAmount: usd(
stringAt(confirmation, ['estimatePrice']) ?? stringAt(order, ['frnNetAmount']),
),
commission: usd(stringAt(order, ['frnCommissionAmount'])),
tax: usd(stringAt(order, ['frnCommissionCtax'])),
warnings: stringArrayAt(confirmation, ['warningStatuses']),
confirmationId: stringAt(order, ['orderNo']) ?? stringAt(order, ['orderSubNo']),
}
}
const orderFromGraphql = (value: unknown): Order => {
const market = usMarketFromGraphql(objectAt(value, ['market']))
const securities = objectAt(value, ['securities'])
return {
id: stringAt(value, ['orderNo']) ?? stringAt(value, ['orderSubNo']) ?? '',
issue: {
code: stringAt(securities, ['securitiesCode']) ?? '',
market,
name:
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
},
side: stringAt(value, ['buySellCode']) === 'SELL' ? 'sell' : 'buy',
status: mapOrderStatus(stringAt(value, ['orderStatus'])),
statusText: stringAt(value, ['orderStatus']),
quantity: numberAt(value, ['orderQuantity']),
unexecutedQuantity: numberAt(value, ['unexecutedQuantity']),
executedQuantity: numberAt(value, ['executionQuantity']),
price: usd(stringAt(value, ['orderPrice'])),
executedPrice: usd(stringAt(value, ['executionAveragePrice'])),
orderedAt: stringAt(value, ['orderInputDatetime']),
orderNumber: stringAt(value, ['orderNo']),
}
}
const normalizedUsChartOptions = (options: IssueChartOptions) => {
const period = options.period ?? 'day'
const unit = options.unit ?? 1
const count = options.count ?? DEFAULT_CHART_COUNT
if (!(period in US_CHART_INTERVALS)) {
throw new Error('period must be minute, day, week, or month')
}
if (!Number.isInteger(unit) || unit <= 0) {
throw new Error('unit must be a positive integer')
}
const interval = US_CHART_INTERVALS[period][unit]
if (!interval) {
if (period === 'minute') throw new Error('minute chart unit must be 1, 5, 10, or 15')
throw new Error('day, week, and month chart unit must be 1')
}
if (!Number.isInteger(count) || count <= 0 || count > 9999) {
throw new Error('count must be an integer between 1 and 9999')
}
return { period, unit, count, interval }
}
const resolveUsOrderInput = async (
session: SbiSession,
options: CashOrderOptions | PlaceCashOrderOptions,
methodName: string,
) => {
const orderDate = usOrderDate(options)
if (orderDate) return usOrderInput(options, orderDate)
if ('orderTerm' in options && options.orderTerm === 'day') return usOrderInput(options)
const preOrder = await createUsStockAdapter(session).preOrder({
issueCode: options.issueCode,
market: options.market,
side: options.side,
})
const firstOrderDate = preOrder.orderTerms.find((term) => /^\d{4}-\d{2}-\d{2}$/.test(term))
if (!firstOrderDate) {
throw new Error(`${methodName} could not determine a valid US stock order term`)
}
return usOrderInput(options, firstOrderDate)
}
const usOrderInput = (options: CashOrderOptions | PlaceCashOrderOptions, orderDate?: string) => ({
...('allowTrading' in options ? { orderSubNo: options.confirmationId ?? '' } : {}),
countryCode: COUNTRY_US,
marketCode: usGraphqlMarketCode(options.market),
securitiesCode: options.issueCode,
buySellCode: buySellCode(options.side),
orderQuantity: String(options.quantity),
orderPrice: usOrderIsMarket(options) ? undefined : String(options.price),
orderPriceKindCode: usOrderIsMarket(options) ? 'MARKET' : 'LIMIT',
orderLimitCode: orderDate ? 'CARRY_OVER_ORDER' : 'TODAY_ORDER',
orderTerm: orderDate,
specificAccountCode: usSpecificAccountCode(options.accountType),
settlementMethodCode: usSettlementMethodCode(options.foreignStockSettlementMethod),
})
const usOrderDate = (options: CashOrderOptions | PlaceCashOrderOptions) => {
if ('orderTerm' in options && options.orderTerm === 'date') {
if (!('orderDate' in options) || !options.orderDate) {
throw new Error('US stock orderTerm: "date" requires orderDate')
}
return normalizeUsOrderDate(options.orderDate)
}
return undefined
}
const normalizeUsOrderDate = (value: string) => {
const normalized = value.replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
throw new Error('US stock orderDate must be yyyy-MM-dd or yyyyMMdd')
}
return normalized
}
const usGraphqlMarketCode = (market: MarketCode) => {
switch (market) {
case 'XNAS':
return 'US_NASDAQ'
case 'XNYS':
return 'US_NYSE'
case 'ARCX':
return 'US_ARCA'
default:
throw new Error(`unsupported US stock market: ${market}`)
}
}
const usRic = (issueCode: string, market: MarketCode) => {
switch (market) {
case 'XNAS':
return `${issueCode}.NB`
case 'XNYS':
return `${issueCode}.N`
case 'ARCX':
return `${issueCode}.P`
default:
throw new Error(`unsupported US stock market: ${market}`)
}
}
const assertUsCashOrderOptions = (options: CashOrderOptions, methodName: string) => {
if (options.kind === 's')
throw new Error(`${methodName} does not support S-kabu orders for US stocks`)
if (options.orderMethod && options.orderMethod !== 'normal') {
throw new Error(`${methodName} does not support stop/OCO orders for US stocks`)
}
if (!usOrderIsMarket(options) && options.price == null) {
throw new Error(`${methodName} requires price for non-market US stock orders`)
}
}
const requireUsTradePassword = (session: SbiSession, methodName: string) => {
if (!session.tradePassword) {
throw new Error(`${methodName} requires tradePassword in loginWithPasskey options`)
}
return session.tradePassword
}
const usOrderIsMarket = (options: CashOrderOptions | PlaceCashOrderOptions) =>
options.kind === 'market' || ('priceCondition' in options && options.priceCondition === 'market')
const buySellCode = (side: TradeSide) => (side === 'sell' ? 'SELL' : 'BUY')
const mapOrderStatus = (value: string | undefined): Order['status'] => {
if (!value) return 'unknown'
if (/EXECUT|約定/i.test(value)) return 'executed'
if (/CANCEL|取消/i.test(value)) return 'cancelled'
if (/REJECT|失効/i.test(value)) return 'rejected'
return 'open'
}
const usMarketFromGraphql = (market: unknown, fallback: MarketCode = 'XNAS'): MarketCode => {
const code = stringAt(market, ['marketCode'])
if (code === 'NASDAQ' || code === 'XNAS') return 'XNAS'
if (code === 'NYSE' || code === 'XNYS') return 'XNYS'
if (code === 'NYSE_ARCA' || code === 'ARCX') return 'ARCX'
return fallback
}
const mapUsSpecificAccount = (value: string | undefined) => {
if (value === 'SPECIFIC' || value === 'TOKUTEI') return 'specific'
if (value === 'GENERAL' || value === 'IPPAN') return 'general'
if (value?.includes('NISA')) return 'nisa'
return undefined
}
const usSpecificAccountCode = (value: CashOrderOptions['accountType']) => {
if (value === 'specific') return 'SPECIFIC'
if (value === 'general') return 'GENERAL'
if (value === 'nisa' || value === 'growthInvestment') return 'NISA'
if (!value) return undefined
throw new Error(`US stock orders do not support accountType: ${value}`)
}
const usSettlementMethodCode = (value: CashOrderOptions['foreignStockSettlementMethod']) => {
if (value === 'foreign') return 'FOREIGN_SETTLEMENT'
if (value === 'yen' || !value) return 'YEN_SETTLEMENT'
throw new Error(`US stock orders do not support foreignStockSettlementMethod: ${value}`)
}
const usd = (text: string | undefined): CurrencyAmount => ({
value: parseNumber(text),
text: text?.trim() ?? '',
currency: 'USD',
})
const percent = (text: string | undefined) => ({
value: parseNumber(text),
text: text?.trim() ?? '',
})
const signed = (text: string | undefined): SignedTextValue => {
const value = parseNumber(text)
return {
value,
text: text?.trim() ?? '',
sign: value == null ? undefined : value > 0 ? 'positive' : value < 0 ? 'negative' : 'zero',
}
}
const sumAmounts = (amounts: Array<CurrencyAmount | undefined>): CurrencyAmount | undefined => {
const values = amounts.map((amount) => amount?.value).filter((value) => value != null)
if (values.length === 0) return undefined
const value = values.reduce((sum, current) => sum + current, 0)
return { value, text: String(value), currency: 'USD' }
}
const sumSigned = (values: Array<SignedTextValue | undefined>): SignedTextValue | undefined => {
const numbers = values.map((value) => value?.value).filter((value) => value != null)
if (numbers.length === 0) return undefined
const value = numbers.reduce((sum, current) => sum + current, 0)
return {
value,
text: String(value),
sign: value > 0 ? 'positive' : value < 0 ? 'negative' : 'zero',
}
}
const parseNumber = (text: string | undefined | null) => {
if (text == null) return null
const normalized = text.replace(/[,\s$%]/g, '').replace(/[()]/g, '')
if (!normalized || normalized === '-' || normalized === '--') return null
const value = Number(normalized)
return Number.isFinite(value) ? value : null
}
const objectAt = (source: unknown, path: string[]): Record<string, unknown> | undefined => {
const value = valueAt(source, path)
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}
const arrayAt = (source: unknown, path: string[]): unknown[] => {
const value = valueAt(source, path)
return Array.isArray(value) ? value : []
}
const stringAt = (source: unknown, path: string[]): string | undefined => {
const value = valueAt(source, path)
if (typeof value === 'string') return value
if (typeof value === 'number') return String(value)
return undefined
}
const stringArrayAt = (source: unknown, path: string[]) =>
arrayAt(source, path).flatMap((value) => (typeof value === 'string' ? [value] : []))
const numberAt = (source: unknown, path: string[]) => parseNumber(stringAt(source, path))
const valueAt = (source: unknown, path: string[]): unknown =>
path.reduce<unknown>((current, key) => {
if (!current || typeof current !== 'object') return undefined
return (current as Record<string, unknown>)[key]
}, source)
const SEARCH_STOCKS = `
query SearchStocks($input: Input_fstock_securities_ListForeignStockSecuritiesRequest) {
listForeignStockSecurities(input: $input) {
foreignStocks {
market { marketCode marketName marketShortName timeZone }
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
securitiesType tradeUnit openBuyRestrict openSellRestrict
}
page { hasNextPage }
}
}`
const STOCK_DETAIL = `
query GetStockDetail($countryCode: common_enums_CountryEnum_Country, $securitiesCode: String, $rics: [String]) {
getForeignStockSecurities(input: { countryCode: $countryCode securitiesCode: $securitiesCode }) {
market { marketCode marketName marketShortName timeZone }
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
securitiesType tradeUnit openBuyRestrict openSellRestrict
}
listMarketPrices(input: { countryCode: $countryCode rics: $rics }) {
marketPrices {
ric ask askSize bid bidSize
price { last lastDatetime change changePercent open high low prevClose volume }
}
}
}`
const SECURITIES_BALANCES = `
query GetSecuritiesBalanceList($input: Input_account_balance_ListSecuritiesBalancesRequest) {
listSecuritiesBalances(input: $input) {
securitiesBalances {
specificAccountCode securitiesQuantity frnAcquisitionPrice acquisitionPrice currencyCode countryCode
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
market { marketCode marketName marketShortName timeZone }
evaluationProfitLoss {
frnEvaluationAmount frnEvaluationProfitLoss evaluationAmount evaluationProfitLoss evaluationProfitLossPercent frnEvaluationProfitLossPercent
}
stockPrice { last tickArrow }
}
page { hasNextPage pageNum pageSize }
}
}`
const ORDER_INITIAL_DATA = `
query GetOrderCreatingInitialData($buySellCode: common_enums_BuySellEnum_BuySell, $countryCode: common_enums_CountryEnum_Country, $securitiesCode: String, $rics: [String]) {
getForeignStockCreatedOrderInitialization(input: { buySellCode: $buySellCode countryCode: $countryCode securitiesCode: $securitiesCode }) {
securities { countryCode productCode ric securitiesCode securitiesName securitiesShortName }
market { countryCode marketCode marketName marketShortName timeZone }
priceRangeNoLimit priceRangeLimitMin priceRangeLimitMax tickSizes { tickSize }
specificAccountCodes settlementMethodCodes orderPriceKindCodes orderLimitCodes orderTerms
}
getForeignStockSecurities(input: { countryCode: $countryCode securitiesCode: $securitiesCode }) {
currencyCode listedSecuritiesStatus tradeUnit
market { marketCode marketName marketShortName timeZone }
securities { countryCode productCode ric securitiesCode securitiesName securitiesShortName }
}
listMarketPrices(input: { countryCode: $countryCode rics: $rics }) {
marketPrices { ask askSize bid bidSize price { last lastDatetime change changePercent } }
}
}`
const CONFIRM_ORDER = `
query ConfirmOrderCreating($input: Input_fstock_order_ConfirmForeignStockCreatedOrderRequest) {
confirmForeignStockCreatedOrder(input: $input) {
buyPossibleAmount estimatePrice nisaBuyLimitAmount sellPossibleQuantity warningStatuses
order {
orderNo orderSubNo orderInputDatetime orderQuantity orderPrice frnNetAmount frnCommissionAmount frnCommissionCtax
securities { securitiesCode securitiesName securitiesShortName }
market { marketCode marketName marketShortName }
}
}
}`
const SUBMIT_ORDER = `
mutation SubmitOrderCreating($input: Input_fstock_order_CreateForeignStockOrderRequest) {
createForeignStockOrder(input: $input) {
order {
orderNo orderSubNo orderInputDatetime orderQuantity orderPrice
securities { securitiesCode securitiesName securitiesShortName }
market { marketCode marketName marketShortName }
}
message
}
}`
const ORDER_LIST = `
query GetOrderList($input: Input_fstock_order_ListForeignStockOrdersRequest) {
listForeignStockOrders(input: $input) {
orderDecodes {
orderNo orderSubNo buySellCode orderStatus orderQuantity unexecutedQuantity executionQuantity
orderPrice executionAveragePrice orderInputDatetime
securities { securitiesCode securitiesName securitiesShortName }
market { marketCode marketName marketShortName }
}
page { hasNextPage pageNum pageSize }
}
}`
+308 -58
View File
@@ -11,8 +11,11 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { createMethodsFromSession, registerDeviceId } from '../methods'
import { mtsMarketToDomestic } from '../markets'
import type {
AccountProfile,
ForeignStockEndpointConfig,
ForeignStockSession,
LoginWithPasskeyOptions,
PasskeyLoginResponse,
PlaintextStoredWebAuthnCredential,
@@ -27,6 +30,11 @@ type PasskeyLoginStart = {
publicKey: string
}
type PasskeyAccessToken = {
callbackUrl: string
accessToken: string
}
type CredentialRequest = {
challenge: string
rpId: string
@@ -37,8 +45,25 @@ type SbiEndpointConfig = {
authBaseUrl: string
mtsBaseUrl: string
izanagiBaseUrl?: string
foreignStock?: ForeignStockEndpointConfig
mainSiteBaseUrl?: string
mainSiteEtGatePath?: string
mainSiteAssetsValuationsPath?: string
mainSiteExchangeOrderInputPath?: string
mainSiteExchangeOrderPasswordPath?: string
mainSiteExchangeOrderConfirmPath?: string
mainSiteExchangeOrderCompletePath?: string
}
const MAIN_SITE_DEFAULT_PATHS = {
etGate: '/ETGate/',
assetsValuations: '/account/api/assets/valuations/current',
exchangeOrderInput: '/exchange/order/input',
exchangeOrderPassword: '/exchange/api/order/input/password',
exchangeOrderConfirm: '/exchange/order/confirm',
exchangeOrderComplete: '/exchange/order/complete',
} as const
export const loginWithPasskey = async (
options: LoginWithPasskeyOptions,
clientOptions: SbiClientOptions = {},
@@ -52,7 +77,257 @@ export const createPasskeySession = async (
clientOptions: SbiClientOptions = {},
): Promise<SbiSession> => {
const endpoints = resolveSbiEndpointConfig(options)
const started = startPasskeyLogin(endpoints.authBaseUrl)
const domesticAccess = await requestPasskeyAccessToken({
authBaseUrl: endpoints.authBaseUrl,
passkeyCredential: options.passkeyCredential,
channel: 'kabu-app',
})
const loginResponse = await finishPasskeyLogin({
accessToken: domesticAccess.accessToken,
mtsBaseUrl: endpoints.mtsBaseUrl,
})
const profile = parsePasskeyLoginProfile(loginResponse)
const foreignStock = endpoints.foreignStock
? await createForeignStockSession(
endpoints.foreignStock,
(
await requestPasskeyAccessToken({
authBaseUrl: endpoints.authBaseUrl,
passkeyCredential: options.passkeyCredential,
channel: 'foreign-kabu-app',
})
).accessToken,
)
: undefined
const session: SbiSession = {
mtsBaseUrl: endpoints.mtsBaseUrl,
izanagiBaseUrl: endpoints.izanagiBaseUrl,
foreignStock,
mainSite: endpoints.mainSiteBaseUrl
? {
baseUrl: endpoints.mainSiteBaseUrl,
etGatePath: endpoints.mainSiteEtGatePath,
assetsValuationsPath: endpoints.mainSiteAssetsValuationsPath,
exchangeOrderInputPath: endpoints.mainSiteExchangeOrderInputPath,
exchangeOrderPasswordPath: endpoints.mainSiteExchangeOrderPasswordPath,
exchangeOrderConfirmPath: endpoints.mainSiteExchangeOrderConfirmPath,
exchangeOrderCompletePath: endpoints.mainSiteExchangeOrderCompletePath,
}
: undefined,
profile,
loginResponse,
tradePassword: clientOptions.tradePassword,
tradeAuthentication: clientOptions.tradeAuthentication,
}
if (clientOptions.deviceId) {
await registerDeviceId(session, clientOptions.deviceId)
session.deviceIdRegistered = true
}
return session
}
const resolveSbiEndpointConfig = (options: LoginWithPasskeyOptions): SbiEndpointConfig => {
const authBaseUrl = options.authBaseUrl ?? process.env.SBI_AUTH_BASE_URL
const mtsBaseUrl = options.mtsBaseUrl ?? process.env.SBI_MTS_BASE_URL
const izanagiBaseUrl = options.izanagiBaseUrl ?? process.env.SBI_IZANAGI_BASE_URL
const foreignStock = resolveForeignStockEndpointConfig(options)
const mainSiteBaseUrl = options.mainSiteBaseUrl ?? process.env.SBI_MAIN_SITE_BASE_URL
const mainSiteEtGatePath =
options.mainSiteEtGatePath ??
process.env.SBI_MAIN_SITE_ET_GATE_PATH ??
MAIN_SITE_DEFAULT_PATHS.etGate
const mainSiteAssetsValuationsPath =
options.mainSiteAssetsValuationsPath ??
process.env.SBI_MAIN_SITE_ASSETS_VALUATIONS_PATH ??
MAIN_SITE_DEFAULT_PATHS.assetsValuations
const mainSiteExchangeOrderInputPath =
options.mainSiteExchangeOrderInputPath ??
process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_INPUT_PATH ??
MAIN_SITE_DEFAULT_PATHS.exchangeOrderInput
const mainSiteExchangeOrderPasswordPath =
options.mainSiteExchangeOrderPasswordPath ??
process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_PASSWORD_PATH ??
MAIN_SITE_DEFAULT_PATHS.exchangeOrderPassword
const mainSiteExchangeOrderConfirmPath =
options.mainSiteExchangeOrderConfirmPath ??
process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_CONFIRM_PATH ??
MAIN_SITE_DEFAULT_PATHS.exchangeOrderConfirm
const mainSiteExchangeOrderCompletePath =
options.mainSiteExchangeOrderCompletePath ??
process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_COMPLETE_PATH ??
MAIN_SITE_DEFAULT_PATHS.exchangeOrderComplete
if (!authBaseUrl) throw new Error('SBI_AUTH_BASE_URL is required')
if (!mtsBaseUrl) throw new Error('SBI_MTS_BASE_URL is required')
return {
authBaseUrl,
mtsBaseUrl,
izanagiBaseUrl: optionalUrl(izanagiBaseUrl),
foreignStock,
mainSiteBaseUrl: optionalUrl(mainSiteBaseUrl),
mainSiteEtGatePath,
mainSiteAssetsValuationsPath,
mainSiteExchangeOrderInputPath,
mainSiteExchangeOrderPasswordPath,
mainSiteExchangeOrderConfirmPath,
mainSiteExchangeOrderCompletePath,
}
}
const optionalUrl = (value: string | undefined) => {
if (!value) return undefined
return new URL(value).toString()
}
const resolveForeignStockEndpointConfig = (
options: LoginWithPasskeyOptions,
): ForeignStockEndpointConfig | undefined => {
const baseUrl =
options.foreignStockBaseUrl ??
options.usStockBaseUrl ??
process.env.SBI_FOREIGN_STOCK_BASE_URL ??
process.env.SBI_US_STOCK_BASE_URL
const restUrl = options.foreignStockRestUrl ?? process.env.SBI_FOREIGN_STOCK_REST_URL
const graphqlBffUrl =
options.foreignStockGraphqlBffUrl ?? process.env.SBI_FOREIGN_STOCK_GRAPHQL_BFF_URL
const graphqlIntUrl =
options.foreignStockGraphqlIntUrl ?? process.env.SBI_FOREIGN_STOCK_GRAPHQL_INT_URL
const userAgent = options.foreignStockUserAgent ?? process.env.SBI_FOREIGN_STOCK_USER_AGENT
if (!baseUrl && !restUrl && !graphqlBffUrl && !graphqlIntUrl) return undefined
if (!baseUrl && (!restUrl || !graphqlBffUrl || !graphqlIntUrl)) {
throw new Error(
'foreign stock endpoints require foreignStockBaseUrl/usStockBaseUrl or all foreignStockRestUrl, foreignStockGraphqlBffUrl, and foreignStockGraphqlIntUrl',
)
}
return {
baseUrl: optionalUrl(baseUrl),
restUrl: optionalUrl(restUrl) ?? requiredDerivedUrl(baseUrl, '/rest/'),
graphqlBffUrl: optionalUrl(graphqlBffUrl) ?? requiredDerivedUrl(baseUrl, '/graphql/bff'),
graphqlIntUrl: optionalUrl(graphqlIntUrl) ?? requiredDerivedUrl(baseUrl, '/graphql/int'),
userAgent: userAgent || 'SBIFStockAndroid/1.6.10(csbi/0)',
}
}
const requiredDerivedUrl = (baseUrl: string | undefined, path: string) => {
if (!baseUrl) throw new Error(`foreign stock base URL is required to derive ${path}`)
return new URL(path, baseUrl).toString()
}
const createForeignStockSession = async (
endpoints: ForeignStockEndpointConfig,
ssoToken: string | undefined,
): Promise<ForeignStockSession> => {
if (!ssoToken) {
throw new Error('foreign stock SSO login requires a passkey callback access token')
}
const response = await fetch(new URL('account/authentication:ssoLogin', endpoints.restUrl), {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({ ssoToken }),
})
const text = await response.text()
if (!response.ok) {
throw new Error(`foreign stock SSO login failed with HTTP ${response.status}: ${text}`)
}
let body: unknown
try {
body = text ? JSON.parse(text) : undefined
} catch {
throw new Error('foreign stock SSO login returned non-JSON response')
}
const objectBody = body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
const sessionId = response.headers.get('Set-Session') ?? stringField(objectBody, 'sessionId')
const accountId = response.headers.get('Account-Id') ?? stringField(objectBody, 'accountId')
if (!sessionId || !accountId) {
throw new Error('foreign stock SSO login did not return Set-Session and Account-Id')
}
const marketPriceHash = await fetchForeignStockMarketPriceHash(endpoints, sessionId, accountId)
const candleHash = await fetchForeignStockHash(
endpoints,
sessionId,
accountId,
'information/chart/countries/US/candle_hashes',
'foreign stock candle hash',
)
return {
endpoints,
ssoToken,
sessionId,
accountId,
marketPriceHash,
candleHash,
loginAuthenticated: true,
}
}
const fetchForeignStockMarketPriceHash = async (
endpoints: ForeignStockEndpointConfig,
sessionId: string,
accountId: string,
) => {
return fetchForeignStockHash(
endpoints,
sessionId,
accountId,
'information/market_price/countries/US/price_hashes',
'foreign stock market price hash',
)
}
const fetchForeignStockHash = async (
endpoints: ForeignStockEndpointConfig,
sessionId: string,
accountId: string,
path: string,
label: string,
) => {
const response = await fetch(new URL(path, endpoints.restUrl), {
headers: {
accept: 'application/json',
authorization: `Bearer ${sessionId}`,
'account-id': accountId,
...(endpoints.userAgent ? { 'user-agent': endpoints.userAgent } : {}),
},
})
const text = await response.text()
if (!response.ok) {
throw new Error(`${label} failed with HTTP ${response.status}: ${text}`)
}
let body: unknown
try {
body = text ? JSON.parse(text) : undefined
} catch {
throw new Error(`${label} returned non-JSON response`)
}
const objectBody = body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
const hashValue = stringField(objectBody, 'hashValue')
if (!hashValue) {
throw new Error(`${label} response did not include hashValue`)
}
return hashValue
}
const stringField = (object: Record<string, unknown>, key: string) => {
const value = object[key]
return typeof value === 'string' && value.length > 0 ? value : undefined
}
const requestPasskeyAccessToken = async (options: {
authBaseUrl: string
passkeyCredential: PlaintextStoredWebAuthnCredential
channel: string
}): Promise<PasskeyAccessToken> => {
const started = startPasskeyLogin(options.authBaseUrl, options.channel)
const jar = new CookieJar()
const headers = defaultBrowserHeaders()
@@ -68,7 +343,7 @@ export const createPasskeySession = async (
const csrfToken = extractCsrfToken(entryText)
const challengeUrl = new URL('/api/fido2/auth/challenge', started.url)
challengeUrl.searchParams.set('cccid', 'kabu-app')
challengeUrl.searchParams.set('cccid', options.channel)
const challengeResponse = await fetchWithCookies(challengeUrl, {
jar,
method: 'POST',
@@ -81,16 +356,16 @@ export const createPasskeySession = async (
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
},
})
assertOk(challengeResponse, 'passkey challenge')
assertOk(challengeResponse, `passkey challenge (${options.channel})`)
const challengeJson = await challengeResponse.json()
const credentialRequest = normalizeCredentialRequest(challengeJson, options.passkeyCredential)
const assertion = createWebAuthnAssertion(options.passkeyCredential, credentialRequest)
const csrf = credentialRequest.csrfToken ?? csrfToken
if (!csrf) throw new Error('missing CSRF token for passkey authentication')
if (!csrf) throw new Error(`missing CSRF token for passkey authentication (${options.channel})`)
const authUrl = new URL('/fido2/auth', started.url)
authUrl.searchParams.set('cccid', 'kabu-app')
authUrl.searchParams.set('cccid', options.channel)
const authResponse = await fetchWithCookies(authUrl, {
jar,
method: 'POST',
@@ -116,7 +391,7 @@ export const createPasskeySession = async (
})
const channelUrl = new URL(
authResponse.headers.get('location') ?? '/sso/channel?cccid=kabu-app',
authResponse.headers.get('location') ?? `/sso/channel?cccid=${options.channel}`,
started.url,
)
const channelResponse = await fetchWithCookies(channelUrl, {
@@ -128,50 +403,23 @@ export const createPasskeySession = async (
'upgrade-insecure-requests': '1',
},
})
assertOk(channelResponse, 'passkey callback')
assertOk(channelResponse, `passkey callback (${options.channel})`)
const channelHtml = await channelResponse.text()
const callbackUrl = extractCallbackUrl(channelHtml)
if (!callbackUrl) throw new Error('passkey callback token was not found in sso/channel response')
if (!callbackUrl) {
throw new Error(
`passkey callback token was not found in sso/channel response (${options.channel})`,
)
}
const loginResponse = await finishPasskeyLogin({
return {
callbackUrl,
privateKeyPem: started.privateKeyPem,
mtsBaseUrl: endpoints.mtsBaseUrl,
})
const profile = parsePasskeyLoginProfile(loginResponse)
const session: SbiSession = {
mtsBaseUrl: endpoints.mtsBaseUrl,
izanagiBaseUrl: endpoints.izanagiBaseUrl,
profile,
loginResponse,
tradePassword: clientOptions.tradePassword,
tradeAuthentication: clientOptions.tradeAuthentication,
accessToken: extractPasskeyAccessToken(callbackUrl, started.privateKeyPem),
}
if (clientOptions.deviceId) {
await registerDeviceId(session, clientOptions.deviceId)
session.deviceIdRegistered = true
}
return session
}
const resolveSbiEndpointConfig = (options: LoginWithPasskeyOptions): SbiEndpointConfig => {
const authBaseUrl = options.authBaseUrl ?? process.env.SBI_AUTH_BASE_URL
const mtsBaseUrl = options.mtsBaseUrl ?? process.env.SBI_MTS_BASE_URL
const izanagiBaseUrl = options.izanagiBaseUrl ?? process.env.SBI_IZANAGI_BASE_URL
if (!authBaseUrl) throw new Error('SBI_AUTH_BASE_URL is required')
if (!mtsBaseUrl) throw new Error('SBI_MTS_BASE_URL is required')
return { authBaseUrl, mtsBaseUrl, izanagiBaseUrl: optionalUrl(izanagiBaseUrl) }
}
const optionalUrl = (value: string | undefined) => {
if (!value) return undefined
return new URL(value).toString()
}
const startPasskeyLogin = (authBaseUrl: string): PasskeyLoginStart => {
const startPasskeyLogin = (authBaseUrl: string, channel: string): PasskeyLoginStart => {
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
modulusLength: 4096,
publicExponent: 0x10001,
@@ -181,7 +429,7 @@ const startPasskeyLogin = (authBaseUrl: string): PasskeyLoginStart => {
const publicKeyParam = base64Url(publicKey, true)
const url = new URL(authBaseUrl)
url.searchParams.set('channel', 'kabu-app')
url.searchParams.set('channel', channel)
url.searchParams.set('pk', publicKeyParam)
url.searchParams.set('ap', 'true')
@@ -193,16 +441,9 @@ const startPasskeyLogin = (authBaseUrl: string): PasskeyLoginStart => {
}
const finishPasskeyLogin = async (options: {
callbackUrl: string
privateKeyPem: string
accessToken: string
mtsBaseUrl: string
}): Promise<PasskeyLoginResponse> => {
const encryptedToken = extractEncryptedToken(options.callbackUrl)
if (!encryptedToken) {
throw new Error('callbackUrl must contain token=...')
}
const accessToken = decryptPasskeyToken(encryptedToken, options.privateKeyPem)
const requestUrl = new URL('/mtsmobile/ssologingate', options.mtsBaseUrl)
const response = await fetch(requestUrl, {
method: 'POST',
@@ -211,7 +452,7 @@ const finishPasskeyLogin = async (options: {
},
body: new URLSearchParams({
KIND: 'L',
TOKEN: accessToken,
TOKEN: options.accessToken,
}),
})
@@ -224,10 +465,19 @@ const finishPasskeyLogin = async (options: {
status: response.status,
body,
text,
accessToken: options.accessToken,
header: parseMtsHeader(text),
}
}
const extractPasskeyAccessToken = (callbackUrl: string, privateKeyPem: string) => {
const encryptedToken = extractEncryptedToken(callbackUrl)
if (!encryptedToken) {
throw new Error('callbackUrl must contain token=...')
}
return decryptPasskeyToken(encryptedToken, privateKeyPem)
}
const extractEncryptedToken = (callbackUrl: string) => {
const url = new URL(callbackUrl)
if (url.searchParams.get('cmd') === 'pwlogin') return undefined
@@ -335,8 +585,8 @@ const parsePasskeyLoginProfile = (response: PasskeyLoginResponse): AccountProfil
sor: {
defaultEnabled: sorDefaultCode === '1',
defaultCode: emptyToUndefined(sorDefaultCode),
lastMarket: emptyToUndefined(sorLastMarket),
juniorNisaLastMarket: emptyToUndefined(sorLastMarketJrNisa),
lastMarket: mtsMarketToDomestic(emptyToUndefined(sorLastMarket)),
juniorNisaLastMarket: mtsMarketToDomestic(emptyToUndefined(sorLastMarketJrNisa)),
},
notices: {
hasImportantNotice: importantNoticeFlag === '1',
@@ -458,8 +708,8 @@ const parsePasskeyLoginProfile = (response: PasskeyLoginResponse): AccountProfil
sor: {
defaultEnabled: sorDefaultCode === '1',
defaultCode: emptyToUndefined(sorDefaultCode),
lastMarket: emptyToUndefined(sorLastMarket),
juniorNisaLastMarket: emptyToUndefined(sorLastMarketJrNisa),
lastMarket: mtsMarketToDomestic(emptyToUndefined(sorLastMarket)),
juniorNisaLastMarket: mtsMarketToDomestic(emptyToUndefined(sorLastMarketJrNisa)),
},
notices: {
hasImportantNotice: importantNoticeFlag === '1',
@@ -722,8 +972,8 @@ const uint32be = (value: number) => {
const extractCallbackUrl = (html: string) => {
const match =
html.match(/sbikabu2:\\\/\\\/auth\\\/callback\?token=[^"'<\\]+/) ??
html.match(/sbikabu2:\/\/auth\/callback\?token=[^"'<\\]+/)
html.match(/[a-z][a-z0-9+.-]*:\\\/\\\/auth\\\/callback\?token=[^"'<\\]+/i) ??
html.match(/[a-z][a-z0-9+.-]*:\/\/auth\/callback\?token=[^"'<\\]+/i)
if (!match) return undefined
return match[0].replaceAll('\\/', '/')
}
+139 -2
View File
@@ -82,6 +82,7 @@ export type PasskeyLoginResponse = {
status: number
body: ArrayBuffer
text: string
accessToken?: string
header: {
sessionId: string
trCode: string
@@ -89,9 +90,48 @@ export type PasskeyLoginResponse = {
} | null
}
export type ForeignStockEndpointConfig = {
baseUrl?: string
restUrl: string
graphqlBffUrl: string
graphqlIntUrl: string
userAgent?: string
}
export type ForeignStockSession = {
endpoints: ForeignStockEndpointConfig
ssoToken?: string
sessionId?: string
accountId?: string
marketPriceHash?: string
candleHash?: string
loginAuthenticated?: boolean
}
export type MainSiteAuthCache = {
baseUrl: string
assetsUrl: string
cookieHeader: string
authenticatedAt: string
}
export type MainSiteSession = {
baseUrl?: string
etGatePath?: string
assetsValuationsPath?: string
exchangeOrderInputPath?: string
exchangeOrderPasswordPath?: string
exchangeOrderConfirmPath?: string
exchangeOrderCompletePath?: string
auth?: MainSiteAuthCache
authPromise?: Promise<MainSiteAuthCache>
}
export type SbiSession = {
mtsBaseUrl: string
izanagiBaseUrl?: string
foreignStock?: ForeignStockSession
mainSite?: MainSiteSession
profile: AccountProfile
loginResponse: PasskeyLoginResponse
tradePassword?: string
@@ -104,6 +144,19 @@ export type LoginWithPasskeyOptions = {
authBaseUrl?: string
mtsBaseUrl?: string
izanagiBaseUrl?: string
foreignStockBaseUrl?: string
usStockBaseUrl?: string
foreignStockRestUrl?: string
foreignStockGraphqlBffUrl?: string
foreignStockGraphqlIntUrl?: string
foreignStockUserAgent?: string
mainSiteBaseUrl?: string
mainSiteEtGatePath?: string
mainSiteAssetsValuationsPath?: string
mainSiteExchangeOrderInputPath?: string
mainSiteExchangeOrderPasswordPath?: string
mainSiteExchangeOrderConfirmPath?: string
mainSiteExchangeOrderCompletePath?: string
}
export type SbiClientOptions = {
@@ -127,7 +180,10 @@ export type SbiTradeAuthenticationOptions = {
}
export type IssueCode = string
export type MarketCode = string
export type DomesticMarketCode = 'XTKS'
export type SKabuMarketCode = 'STK'
export type UsStockMarketCode = 'XNAS' | 'XNYS' | 'ARCX'
export type MarketCode = DomesticMarketCode | SKabuMarketCode | UsStockMarketCode
export type OrderId = string
export type WatchlistId = string
export type PositionId = string
@@ -136,7 +192,7 @@ export type ThemeId = string
export type CurrencyAmount = {
value: number | null
text: string
currency: 'JPY'
currency: 'JPY' | 'USD'
}
export type PercentValue = {
@@ -150,6 +206,35 @@ export type SignedTextValue = {
sign?: 'positive' | 'negative' | 'zero'
}
export type AccountAssetsValuationSummary = {
assetsErrorType: unknown
valuation: number | null
netChange: number | null
percentChange: number | null
monthOnMonth: number | null
monthOnMonthRatio: unknown
profitLoss: number | null
profitLossRate: number | null
acquisitionCost: number | null
}
export type AccountAssetsValuationDetail = AccountAssetsValuationSummary & {
category: string
compositionRatio: number | null
}
export type AccountAssetsValuations = {
fetchedAt: string
summary: AccountAssetsValuationSummary
summaryWithoutDeposit: AccountAssetsValuationSummary
summaryWithoutIdeco?: AccountAssetsValuationSummary
summaryWithoutDepositAndIdeco?: AccountAssetsValuationSummary
summaryDetails: AccountAssetsValuationDetail[]
summaryDetailsWithoutDeposit: AccountAssetsValuationDetail[]
summaryDetailsWithoutIdeco: AccountAssetsValuationDetail[]
summaryDetailsWithoutDepositAndIdeco: AccountAssetsValuationDetail[]
}
export type AccountType =
| 'general'
| 'specific'
@@ -747,6 +832,58 @@ export type OrderReceipt = {
error?: SbiMethodError
}
export type ExchangeOrderSide = 'buy' | 'sell'
export type ExchangeSpecificMethod = 'foreign' | 'domestic'
export type ExchangeAccountKind = 'GENERAL' | 'JR_NISA'
export type ExchangeSellMethod = 'SELL_PART' | 'SELL_ALL'
export type ExchangeOrderPreview = {
currencyCode: string
currencyName?: string
side: ExchangeOrderSide
exchangeType?: string
accountKind?: ExchangeAccountKind
specificMethod?: ExchangeSpecificMethod
sellMethod?: ExchangeSellMethod | null
tradeQuantity?: string
orderAmount?: string
exchangeRate?: string
netAmount?: string
valueDate?: string
rateDateTime?: string
warningMessage?: string | null
isMaintenance?: boolean
csrfToken: string
}
export type ExchangeOrderReceipt = {
accepted: boolean
currencyCode?: string
side?: ExchangeOrderSide
message?: string
warningMessage?: string | null
rawTitle?: string
}
export type ExchangeRateInfo = {
currencyCode: string
side: ExchangeOrderSide
referenceExchangeRate?: string
computeExchangeRate?: string
basePrice?: string
exchangeTradeType?: string
updateTime?: string
buyPossibleAmount?: string
sellPossibleAmount?: string
buyUnit?: string
sellUnit?: string
buyLimitMin?: string
buyLimitMax?: string
sellLimitMin?: string
sellLimitMax?: string
raw: Record<string, unknown>
}
export type ThemeInvestment = {
id: ThemeId
name: string