fix
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}`
|
||||
@@ -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('\\/', '/')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user