feat: introduce better mcp

This commit is contained in:
Shotaro Nakamura
2026-06-21 14:09:05 +09:00
parent 17c5a1156e
commit 207f9c2164
19 changed files with 1862 additions and 254 deletions
+204 -104
View File
@@ -7,13 +7,7 @@ import { eq } from 'drizzle-orm'
import * as z from 'zod/v4'
import type { AppBindings, AuthContext } from '../context'
import { sbiPasskeys } from '../db/schema'
import {
invokeSbiMethod,
isCashOrderMethod,
isTradingMethod,
RPC_METHODS,
type RpcMethod,
} from '../rpc/methods'
import { invokeSbiMethod, isCashOrderMethod, isTradingMethod, type RpcMethod } from '../rpc/methods'
import { connectSbi } from '../rpc/sbi-session'
import type { StoredSbiPasskeySecret } from './admin'
import {
@@ -33,8 +27,6 @@ const requireAuthenticated = (auth: AuthContext) => {
if (!auth.authenticated) throw new Error('unauthorized')
}
const toolNameForMethod = (method: RpcMethod) => `csbie_sbi_${method.replaceAll('.', '_')}`
const ORDER_SUBMIT_TICKET_TTL_MS = 10 * 60 * 1000
type OrderSubmitTicket = {
@@ -81,10 +73,6 @@ const orderSubmitMethodByEstimateMethod = {
const submitMethodForEstimateMethod = (method: RpcMethod) =>
orderSubmitMethodByEstimateMethod[method as keyof typeof orderSubmitMethodByEstimateMethod]
const isDirectOrderSubmitMethod = (method: RpcMethod) => isTradingMethod(method)
const mcpExposedRpcMethods = RPC_METHODS.filter((method) => !isDirectOrderSubmitMethod(method))
const orderSubmitParams = (value: unknown, confirmationId?: string) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return { allowTrading: true }
return {
@@ -179,6 +167,17 @@ const orderInquiryOptionsSchema = z.object({
status: z.enum(['open', 'executed', 'cancelled', 'expired', 'rejected', 'unknown']).optional(),
})
const orderDetailSchema = z.object({
orderNumber: z.string().min(1).optional().describe('Order number shown in order inquiry'),
orderId: orderIdSchema.optional().describe('Order id shown in order inquiry'),
issueCode: issueCodeSchema.optional(),
market: marketCodeSchema.describe('US stock market code for order detail'),
})
const tradeRecordInquiryOptionsSchema = orderInquiryOptionsSchema.extend({
accountType: accountTypeSchema.optional(),
})
const boardOptionsSchema = issueOptionsSchema.extend({
accountType: accountTypeSchema.optional(),
side: z
@@ -337,6 +336,8 @@ const placeOrderCorrectionSchema = orderCorrectionSchema.extend({
const orderCancelSchema = z.object({
orderNumber: z.string().min(1).describe('Order number shown in order inquiry'),
orderId: orderIdSchema.optional().describe('Original order id shown in order inquiry'),
issueCode: issueCodeSchema.optional().describe('Original issue code shown in order inquiry'),
market: marketCodeSchema.optional().describe('Original market code shown in order inquiry'),
tradeId: z.string().min(1).optional().describe('Original trade id code'),
cancelType: z.string().min(1).optional().describe('Additional cancel flag'),
})
@@ -628,8 +629,10 @@ const methodParamSchemas = {
'market.ranking.sbi': undefined,
'news.list': undefined,
'watchlist.list': undefined,
'orders.inquiry.detail': orderDetailSchema,
'orders.inquiry.executionsToday': orderInquiryOptionsSchema.optional(),
'orders.inquiry.open': orderInquiryOptionsSchema.optional(),
'orders.inquiry.tradeRecords': tradeRecordInquiryOptionsSchema,
'orders.cash.preOrder': cashOrderPreOrderSchema,
'orders.cash.estimate': cashOrderSchema,
'orders.cash.place': placeCashOrderSchema,
@@ -665,6 +668,84 @@ const methodParamSchemas = {
'orders.exchange.place': placeExchangeOrderSchema,
} satisfies Record<RpcMethod, z.ZodType | undefined>
const getActionToMethod = {
sessionProfile: 'session.profile',
accountProfile: 'account.profile',
currentAssets: 'account.assets.current',
buyingPower: 'account.power.buyingPower',
collateralRatio: 'account.power.collateralRatio',
cashPositions: 'account.positions.cash',
cashPositionDetail: 'account.positions.cashDetail',
cashPositionForIssue: 'account.positions.cashForIssue',
marginPositions: 'account.positions.margin',
marginPositionDetail: 'account.positions.marginDetail',
marginPositionForIssue: 'account.positions.marginForIssue',
marginSummaryForIssue: 'account.positions.marginSummaryForIssue',
marginDetailsForIssue: 'account.positions.marginDetailsForIssue',
closeableMarginPositions: 'account.positions.closeableMargin',
deliverableMarginPositions: 'account.positions.deliverableMargin',
unrealizedProfitLoss: 'account.profitLoss.unrealized',
issueSearch: 'market.issue.search',
issueSuggest: 'market.issue.suggest',
allowedPrices: 'market.issue.allowedPrices',
board: 'market.issue.board',
chart: 'market.issue.chart',
openOrdersForIssue: 'market.issue.openOrders',
tradingInfo: 'market.issue.tradingInfo',
majorIndexes: 'market.index.major',
marketOverview: 'market.overview',
marketRanking: 'market.ranking.market',
sectorRanking: 'market.ranking.sector',
sbiRanking: 'market.ranking.sbi',
news: 'news.list',
watchlist: 'watchlist.list',
orderDetail: 'orders.inquiry.detail',
executionsToday: 'orders.inquiry.executionsToday',
openOrders: 'orders.inquiry.open',
tradeRecords: 'orders.inquiry.tradeRecords',
cashOrderPreflight: 'orders.cash.preOrder',
marginOpenPreflight: 'orders.margin.preOrderOpen',
marginClosePreflight: 'orders.margin.preOrderClose',
actualDeliveryPreflight: 'orders.margin.preOrderActualDelivery',
themeInvestmentList: 'orders.themeInvestment.list',
exchangeRate: 'orders.exchange.rate',
} as const satisfies Record<string, RpcMethod>
const changeActionToEstimateMethod = {
cashOrder: 'orders.cash.estimate',
cashOrderCorrection: 'orders.cash.estimateCorrection',
cashOrderCorrectionConfirm: 'orders.cash.estimateCorrectionConfirm',
cashOrderCancel: 'orders.cash.estimateCancel',
marginOpenOrder: 'orders.margin.estimateOpen',
marginCloseOrder: 'orders.margin.estimateClose',
marginCloseSummaryOrder: 'orders.margin.estimateSummary',
marginCloseSummaryConfirm: 'orders.margin.estimateCloseSummary',
actualDeliveryOrder: 'orders.margin.estimateActualDelivery',
ifdOrder: 'orders.ifd.estimate',
ifdOrderCorrection: 'orders.ifd.estimateCorrection',
ifdOrderCancel: 'orders.ifd.estimateCancel',
themeInvestmentOrder: 'orders.themeInvestment.estimate',
exchangeOrder: 'orders.exchange.estimate',
} as const satisfies Record<string, keyof typeof orderSubmitMethodByEstimateMethod>
const getActions = Object.keys(getActionToMethod) as Array<keyof typeof getActionToMethod>
const changeActions = Object.keys(changeActionToEstimateMethod) as Array<
keyof typeof changeActionToEstimateMethod
>
const getActionSchema = z.enum(['capabilities', 'passkeys', ...getActions])
const changeActionSchema = z.enum(changeActions)
const parseMethodParams = (method: RpcMethod, input: unknown) => {
const schema = methodParamSchemas[method]
if (!schema) {
if (input !== undefined && input !== null) {
throw new Error(`${method} does not accept input`)
}
return undefined
}
return schema.parse(input)
}
const createMcpServer = (c: Context<AppBindings>) => {
const db = c.get('db')
const config = c.get('config')
@@ -675,59 +756,33 @@ const createMcpServer = (c: Context<AppBindings>) => {
version: '0.1.0',
})
server.registerTool(
'csbie_sbi_methods',
{
title: 'List SBI RPC Methods',
description: 'List SBI client methods exposed through CSBIE.',
inputSchema: {},
},
async () => {
requireAuthenticated(auth)
return textResult({
methods: mcpExposedRpcMethods,
submitTool: 'csbie_sbi_submit_order',
const listPasskeys = async () => {
const rows = await db
.select({
id: sbiPasskeys.id,
label: sbiPasskeys.label,
keyringAccount: sbiPasskeys.keyringAccount,
createdAt: sbiPasskeys.createdAt,
updatedAt: sbiPasskeys.updatedAt,
})
},
)
.from(sbiPasskeys)
.orderBy(sbiPasskeys.createdAt)
return Promise.all(
rows.map(async ({ keyringAccount, ...row }) => {
const secret = await readSecret<StoredSbiPasskeySecret>(keyringAccount)
const hasDeviceId = Boolean(effectiveSbiDeviceId(secret))
const hasTradePassword = Boolean(effectiveSbiTradePassword(secret))
return {
...row,
hasTradePassword,
hasDeviceId,
cashOrderReady: hasTradePassword && hasDeviceId,
}
}),
)
}
server.registerTool(
'csbie_sbi_passkeys',
{
title: 'List SBI Passkeys',
description: 'List saved SBI passkey profiles. Secret material is never returned.',
inputSchema: {},
},
async () => {
requireAuthenticated(auth)
const rows = await db
.select({
id: sbiPasskeys.id,
label: sbiPasskeys.label,
keyringAccount: sbiPasskeys.keyringAccount,
createdAt: sbiPasskeys.createdAt,
updatedAt: sbiPasskeys.updatedAt,
})
.from(sbiPasskeys)
.orderBy(sbiPasskeys.createdAt)
const passkeys = await Promise.all(
rows.map(async ({ keyringAccount, ...row }) => {
const secret = await readSecret<StoredSbiPasskeySecret>(keyringAccount)
const hasDeviceId = Boolean(effectiveSbiDeviceId(secret))
const hasTradePassword = Boolean(effectiveSbiTradePassword(secret))
return {
...row,
hasTradePassword,
hasDeviceId,
cashOrderReady: hasTradePassword && hasDeviceId,
}
}),
)
return textResult({ passkeys })
},
)
const callSbiMethod = async (method: RpcMethod, passkeyId: string, params: unknown) => {
const invokeCheckedSbiMethod = async (method: RpcMethod, passkeyId: string, params: unknown) => {
requireAuthenticated(auth)
if (auth.type === 'apiKey') {
@@ -767,41 +822,105 @@ const createMcpServer = (c: Context<AppBindings>) => {
}
const client = await connectSbi(db, config, passkeyId)
const result = await invokeSbiMethod(client, method, params)
const submitMethod = submitMethodForEstimateMethod(method)
if (!submitMethod) return textResult(result)
return invokeSbiMethod(client, method, params)
}
const createChangeRequest = async (
action: keyof typeof changeActionToEstimateMethod,
passkeyId: string,
input: unknown,
) => {
const estimateMethod = changeActionToEstimateMethod[action]
const submitMethod = submitMethodForEstimateMethod(estimateMethod)
if (!submitMethod) throw new Error(`${action} cannot create a confirmable request`)
const params = parseMethodParams(estimateMethod, input)
const preview = await invokeCheckedSbiMethod(estimateMethod, passkeyId, params)
cleanupExpiredOrderSubmitTickets()
const uuid = randomUUID()
const expiresAt = new Date(Date.now() + ORDER_SUBMIT_TICKET_TTL_MS)
const confirmationId = confirmationIdFromPreview(result)
const confirmationId = confirmationIdFromPreview(preview)
orderSubmitTickets.set(uuid, {
passkeyId,
estimateMethod: method,
estimateMethod,
submitMethod,
params,
confirmationId,
authKey: authKey(auth),
expiresAt,
})
return textResult({
preview: result,
submit: {
uuid,
tool: 'csbie_sbi_submit_order',
expiresAt: expiresAt.toISOString(),
},
})
return {
uuid,
expiresAt: expiresAt.toISOString(),
preview,
confirmTool: 'confirm-request',
}
}
server.registerTool(
'csbie_sbi_submit_order',
'csbie-get',
{
title: 'Submit Estimated SBI Order',
title: 'Get SBI Data',
description:
'Submit the same SBI order as a previous MCP estimate result by UUID. The UUID expires shortly and is bound to the same authenticated caller.',
'Read SBI data through a small abstract action API. This tool never places, corrects, cancels, or otherwise changes real orders.',
inputSchema: {
uuid: z.string().uuid().describe('UUID returned by an order estimate tool'),
action: getActionSchema.describe('Read action to perform'),
passkeyId: z
.string()
.optional()
.describe('Saved SBI passkey id. Required for SBI-backed read actions.'),
input: z.unknown().optional().describe('Action input object'),
},
},
async ({ action, passkeyId, input }) => {
requireAuthenticated(auth)
if (action === 'capabilities') {
return textResult({
getActions,
changeActions,
changeTool: 'csbie-request-change',
confirmTool: 'confirm-request',
})
}
if (action === 'passkeys') return textResult({ passkeys: await listPasskeys() })
const method = getActionToMethod[action]
if (!method) throw new Error(`unsupported get action: ${String(action)}`)
if (!passkeyId) throw new Error(`${action} requires passkeyId`)
const params = parseMethodParams(method, input)
return textResult(await invokeCheckedSbiMethod(method, passkeyId, params))
},
)
server.registerTool(
'csbie-request-change',
{
title: 'Create SBI Change Request',
description:
'Prepare a real SBI change by running the corresponding estimate/preview and returning a UUID. This tool never submits the change; pass the UUID to confirm-request.',
inputSchema: {
action: changeActionSchema.describe('Change action to prepare'),
passkeyId: z.string().describe('Saved SBI passkey id'),
input: z.unknown().describe('Change action input object'),
},
},
async ({ action, passkeyId, input }) =>
textResult(await createChangeRequest(action, passkeyId, input)),
)
server.registerTool(
'confirm-request',
{
title: 'Confirm SBI Change Request',
description:
'Submit a previously prepared SBI change request by UUID. The UUID expires shortly, is single-use, and is bound to the same authenticated caller.',
inputSchema: {
uuid: z.string().uuid().describe('UUID returned by csbie-request-change'),
},
},
async ({ uuid }) => {
@@ -809,40 +928,21 @@ const createMcpServer = (c: Context<AppBindings>) => {
cleanupExpiredOrderSubmitTickets()
const ticket = orderSubmitTickets.get(uuid)
if (!ticket) throw new Error('order submit uuid not found or expired')
if (!ticket) throw new Error('request uuid not found or expired')
if (ticket.authKey !== authKey(auth)) {
throw new Error('order submit uuid was created by a different authenticated caller')
throw new Error('request uuid was created by a different authenticated caller')
}
orderSubmitTickets.delete(uuid)
return callSbiMethod(
const result = await invokeCheckedSbiMethod(
ticket.submitMethod,
ticket.passkeyId,
orderSubmitParams(ticket.params, ticket.confirmationId),
)
return textResult(result)
},
)
for (const method of mcpExposedRpcMethods) {
const paramsSchema = methodParamSchemas[method]
server.registerTool(
toolNameForMethod(method),
{
title: `Call ${method}`,
description: `Connect with one saved SBI passkey and call ${method}. API key method permissions and trading limits are enforced.`,
inputSchema: {
passkeyId: z.string().describe('Saved SBI passkey id from csbie_sbi_passkeys'),
...(paramsSchema
? {
params: paramsSchema.describe(`${method} params`),
}
: {}),
},
},
async ({ passkeyId, params }) => callSbiMethod(method, passkeyId, params),
)
}
return server
}
+2
View File
@@ -31,8 +31,10 @@ export const RPC_METHODS = [
'market.ranking.sbi',
'news.list',
'watchlist.list',
'orders.inquiry.detail',
'orders.inquiry.executionsToday',
'orders.inquiry.open',
'orders.inquiry.tradeRecords',
'orders.cash.preOrder',
'orders.cash.estimate',
'orders.cash.place',
+12
View File
@@ -105,6 +105,7 @@ const {
holdingsMarketValue,
totalProfitLoss,
totalProfitLossRate,
marketIndexes,
orders,
cancelingOrderKey,
orderHistoryLoaded,
@@ -146,6 +147,11 @@ const {
askPlaceOrder,
placeCashOrder,
cancelOrder,
loadOrderDetail,
loadTradeRecords,
loadPositionDetail,
estimateOrderCorrection,
placeOrderCorrection,
downloadCsv,
openTradeForStock,
openTradeForPosition,
@@ -232,6 +238,7 @@ onMounted(async () => {
:holdings-market-value="holdingsMarketValue"
:total-profit-loss="totalProfitLoss"
:total-profit-loss-rate="totalProfitLossRate"
:market-indexes="marketIndexes"
:stock-asset-ratio="stockAssetRatio"
:cash-asset-ratio="cashAssetRatio"
:positions="positions"
@@ -241,6 +248,7 @@ onMounted(async () => {
:connected="connected"
:order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice"
:load-position-detail="loadPositionDetail"
@connect="connect"
@open-position="
(code) =>
@@ -304,6 +312,10 @@ onMounted(async () => {
:canceling-order-key="cancelingOrderKey"
:order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice"
:load-order-detail="loadOrderDetail"
:load-trade-records="loadTradeRecords"
:estimate-order-correction="estimateOrderCorrection"
:place-order-correction="placeOrderCorrection"
@refresh="loadTradingData"
@cancel="cancelOrder"
/>
@@ -39,8 +39,10 @@ const rpcMethods = [
'market.ranking.sbi',
'news.list',
'watchlist.list',
'orders.inquiry.detail',
'orders.inquiry.executionsToday',
'orders.inquiry.open',
'orders.inquiry.tradeRecords',
'orders.cash.estimate',
'orders.cash.place',
'orders.cash.estimateCorrection',
+2 -2
View File
@@ -8,14 +8,14 @@ export type PageMeta = {
}
export const pageMeta: Record<RouteName, PageMeta> = {
portfolio: { title: 'ポートフォリオ', icon: WalletCards },
portfolio: { title: 'ホーム', icon: WalletCards },
trade: { title: '取引', icon: ArrowLeftRight },
history: { title: '取引履歴', icon: History },
settings: { title: '設定', icon: Settings },
}
export const sidebarItems: Array<{ name: RouteName; label: string; icon: Component }> = [
{ name: 'portfolio', label: '資産一覧', icon: pageMeta.portfolio.icon },
{ name: 'portfolio', label: 'ホーム', icon: pageMeta.portfolio.icon },
{ name: 'trade', label: '取引', icon: pageMeta.trade.icon },
{ name: 'settings', label: '設定', icon: pageMeta.settings.icon },
]
+12 -1
View File
@@ -34,12 +34,23 @@ export const cashOrderMarketOptions: Array<{
}> = [
{ label: '自動', value: 'auto' },
{ label: '東証', value: 'XTKS' },
{ label: '名証', value: 'XNGO' },
{ label: '福証', value: 'XFKA' },
{ label: '札証', value: 'XSAP' },
{ label: 'NASDAQ', value: 'XNAS' },
{ label: 'NYSE', value: 'XNYS' },
{ label: 'NYSE Arca', value: 'ARCX' },
]
export const searchableMarkets: CashOrderMarket[] = ['XTKS', 'XNAS', 'XNYS', 'ARCX']
export const searchableMarkets: CashOrderMarket[] = [
'XTKS',
'XNGO',
'XFKA',
'XSAP',
'XNAS',
'XNYS',
'ARCX',
]
export const tradeRouteIdFromStockId = (id: string) => {
const separator = id.indexOf(':')
@@ -1,21 +1,40 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { AnimatePresence } from 'motion-v'
import { ArrowLeft, Ban, RefreshCw } from 'lucide-vue-next'
import {
ArrowLeft,
Ban,
FileText,
Pencil,
ReceiptText,
RefreshCw,
ShieldCheck,
} from 'lucide-vue-next'
import Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
import type { OrderRow } from '../../types/trading'
import type { OrderDetail, OrderPreview, OrderRow, TradeRecordRow } from '../../types/trading'
import { currencyForMarket } from '../../utils/format'
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
defineProps<{
const props = defineProps<{
orders: OrderRow[]
connected: boolean
dataLoading: boolean
cancelingOrderKey: string
orderHistoryLoaded: boolean
orderHistoryNotice: string
loadOrderDetail: (order: OrderRow) => Promise<OrderDetail>
loadTradeRecords: () => Promise<TradeRecordRow[]>
estimateOrderCorrection: (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
) => Promise<OrderPreview>
placeOrderCorrection: (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
) => Promise<void>
}>()
const emit = defineEmits<{
@@ -25,11 +44,51 @@ const emit = defineEmits<{
const cancelCandidate = ref<OrderRow | null>(null)
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
const isUsMarket = (market: string) => usMarkets.has(market)
const canCancel = (order: OrderRow) =>
order.status === '注文中' && Boolean(order.orderNumber) && !usMarkets.has(order.market)
order.status === '注文中' && Boolean(order.orderNumber) && order.cancelable !== false
const canShowUsDetail = (order: OrderRow) => isUsMarket(order.market)
const canCorrect = (order: OrderRow) =>
order.status === '注文中' &&
Boolean(order.orderNumber) &&
isUsMarket(order.market) &&
order.correctable !== false
const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
orderHistoryKey(order) === cancelingOrderKey
const cancelTitle = computed(() => cancelCandidate.value?.stock ?? '')
const orderDetail = ref<OrderDetail | null>(null)
const orderDetailLoadingKey = ref('')
const orderActionError = ref('')
const tradeRecords = ref<TradeRecordRow[]>([])
const showTradeRecords = ref(false)
const tradeRecordsLoading = ref(false)
const correctionCandidate = ref<OrderRow | null>(null)
const correctionQuantityInput = ref('')
const correctionPriceCondition = ref<'market' | 'limit'>('limit')
const correctionPriceInput = ref('')
const correctionPreview = ref<OrderPreview | null>(null)
const correctionLoading = ref(false)
const correctionSubmitting = ref(false)
const orderActionKey = (order: OrderRow) => orderHistoryKey(order)
const numericInput = (value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
const correctionDraft = () => {
const quantity = numericInput(correctionQuantityInput.value)
if (!quantity || quantity <= 0) throw new Error('数量を入力してください')
if (correctionPriceCondition.value === 'market') {
return { quantity, priceCondition: 'market' as const }
}
const price = numericInput(correctionPriceInput.value)
if (!price || price <= 0) throw new Error('指値を入力してください')
return { quantity, priceCondition: 'limit' as const, price }
}
const priceText = (value: number | null | undefined, market: string) =>
value == null ? '-' : currencyForMarket(value, market)
const tradeAmountText = (record: TradeRecordRow) =>
record.amount == null ? '-' : currencyForMarket(record.amount, record.market)
const askCancel = (order: OrderRow) => {
if (!canCancel(order)) return
@@ -41,27 +100,108 @@ const confirmCancel = () => {
emit('cancel', cancelCandidate.value)
cancelCandidate.value = null
}
const showOrderDetail = async (order: OrderRow) => {
if (!canShowUsDetail(order) || orderDetailLoadingKey.value) return
orderActionError.value = ''
orderDetailLoadingKey.value = orderActionKey(order)
try {
orderDetail.value = await props.loadOrderDetail(order)
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '注文詳細の取得に失敗しました'
} finally {
orderDetailLoadingKey.value = ''
}
}
const loadTradeRecords = async () => {
if (tradeRecordsLoading.value) return
orderActionError.value = ''
tradeRecordsLoading.value = true
try {
tradeRecords.value = await props.loadTradeRecords()
showTradeRecords.value = true
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '約定履歴の取得に失敗しました'
} finally {
tradeRecordsLoading.value = false
}
}
const askCorrection = (order: OrderRow) => {
if (!canCorrect(order)) return
correctionCandidate.value = order
correctionQuantityInput.value = String(order.unexecutedQuantity ?? order.quantity ?? '')
correctionPriceCondition.value = order.price == null ? 'market' : 'limit'
correctionPriceInput.value = order.price == null ? '' : String(order.price)
correctionPreview.value = null
orderActionError.value = ''
}
const estimateCorrection = async () => {
if (!correctionCandidate.value || correctionLoading.value) return
orderActionError.value = ''
correctionLoading.value = true
try {
correctionPreview.value = await props.estimateOrderCorrection(
correctionCandidate.value,
correctionDraft(),
)
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '注文訂正の見積に失敗しました'
} finally {
correctionLoading.value = false
}
}
const confirmCorrection = async () => {
if (!correctionCandidate.value || correctionSubmitting.value) return
orderActionError.value = ''
correctionSubmitting.value = true
try {
await props.placeOrderCorrection(correctionCandidate.value, correctionDraft())
correctionCandidate.value = null
correctionPreview.value = null
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '注文訂正に失敗しました'
} finally {
correctionSubmitting.value = false
}
}
</script>
<template>
<section :class="ui.panel">
<div :class="ui.panelHead">
<h2>取引履歴</h2>
<button
:class="ui.ghostButton"
type="button"
:disabled="!connected || dataLoading"
@click="emit('refresh')"
>
<RefreshCw class="h-4 w-4" aria-hidden="true" />
履歴を更新
</button>
<span :class="ui.rowActions">
<button
:class="ui.ghostButton"
type="button"
:disabled="!connected || tradeRecordsLoading"
@click="loadTradeRecords"
>
<Spinner v-if="tradeRecordsLoading" size="sm" />
<ReceiptText v-else class="h-4 w-4" aria-hidden="true" />
約定履歴
</button>
<button
:class="ui.ghostButton"
type="button"
:disabled="!connected || dataLoading"
@click="emit('refresh')"
>
<RefreshCw class="h-4 w-4" aria-hidden="true" />
更新
</button>
</span>
</div>
<p v-if="orderActionError" :class="ui.dialogNote">{{ orderActionError }}</p>
<div :class="ui.list">
<div v-for="order in orders" :key="order.id" :class="ui.orderRow">
<span>
<strong>{{ order.stock }}</strong>
<small>{{ order.date }} / 通常単元</small>
<small>{{ order.code }} / {{ order.market }} / {{ order.date }}</small>
</span>
<span :class="order.side === 'buy' ? ui.positive : ui.negative">
{{ order.side === 'buy' ? '購入' : '売却' }}
@@ -71,17 +211,43 @@ const confirmCancel = () => {
<span :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]">
{{ order.status }}
</span>
<button
v-if="canCancel(order)"
:class="ui.ghostButton"
type="button"
:disabled="Boolean(cancelingOrderKey)"
@click="askCancel(order)"
>
<Spinner v-if="isCanceling(order, cancelingOrderKey)" size="sm" />
<Ban v-else class="h-4 w-4" aria-hidden="true" />
{{ isCanceling(order, cancelingOrderKey) ? '取消中' : '取消' }}
</button>
<span :class="ui.rowActions">
<button
v-if="canShowUsDetail(order)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(orderDetailLoadingKey)"
@click="showOrderDetail(order)"
>
<Spinner v-if="orderDetailLoadingKey === orderActionKey(order)" size="sm" />
<FileText v-else class="h-3.5 w-3.5" aria-hidden="true" />
詳細
</button>
<button
v-if="canCorrect(order)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(cancelingOrderKey)"
@click="askCorrection(order)"
>
<Pencil class="h-3.5 w-3.5" aria-hidden="true" />
訂正
</button>
<button
v-if="canCancel(order)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(cancelingOrderKey)"
@click="askCancel(order)"
>
<Spinner v-if="isCanceling(order, cancelingOrderKey)" size="sm" />
<Ban v-else class="h-3.5 w-3.5" aria-hidden="true" />
{{ isCanceling(order, cancelingOrderKey) ? '取消中' : '取消' }}
</button>
</span>
</div>
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
<Spinner />
@@ -98,6 +264,195 @@ const confirmCancel = () => {
</div>
</section>
<AnimatePresence>
<UiModal
v-if="orderDetail"
key="order-detail-dialog"
eyebrow="注文詳細"
:title="orderDetail.stock"
@close="orderDetail = null"
>
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>注文番号</dt>
<dd>{{ orderDetail.orderNumber ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>枝番</dt>
<dd>{{ orderDetail.orderSubNo ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>銘柄</dt>
<dd>{{ orderDetail.code }} / {{ orderDetail.market }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>売買</dt>
<dd>{{ orderDetail.side === 'buy' ? '購入' : '売却' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>数量</dt>
<dd>{{ orderQuantityText(orderDetail) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>未約定数量</dt>
<dd>{{ orderDetail.unexecutedQuantity ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>価格</dt>
<dd>{{ priceText(orderDetail.price, orderDetail.market) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>状態</dt>
<dd>{{ orderDetail.statusText ?? orderDetail.status }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>有効期限</dt>
<dd>{{ orderDetail.expiresAt ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>取消/訂正</dt>
<dd>
{{ orderDetail.cancelable === false ? '取消不可' : '取消可' }} /
{{ orderDetail.correctable === false ? '訂正不可' : '訂正可' }}
</dd>
</div>
</dl>
<div :class="ui.actions">
<UiButton variant="ghost" @click="orderDetail = null">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
閉じる
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="showTradeRecords"
key="trade-records-dialog"
eyebrow="約定履歴"
title="米国株"
@close="showTradeRecords = false"
>
<div v-if="tradeRecords.length" :class="ui.confirmList">
<div v-for="record in tradeRecords" :key="record.id" :class="ui.row">
<span class="grid gap-1">
<strong>{{ record.stock }}</strong>
<small>{{ record.code }} / {{ record.market }} / {{ record.type }}</small>
<small :class="ui.muted">
{{ record.tradeDate ?? '-' }} 約定 / {{ record.valueDate ?? '-' }} 受渡
</small>
</span>
<span class="grid justify-items-end gap-1 text-right">
<strong>{{ tradeAmountText(record) }}</strong>
<small :class="ui.muted">
{{ record.quantity ?? '-' }}株 @ {{ priceText(record.price, record.market) }}
</small>
</span>
</div>
</div>
<p v-else :class="ui.dialogNote">該当する約定履歴はありません</p>
<div :class="ui.actions">
<UiButton variant="ghost" @click="showTradeRecords = false">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
閉じる
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="correctionCandidate"
key="correction-dialog"
eyebrow="注文訂正"
:title="correctionCandidate.stock"
@close="correctionCandidate = null"
>
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>注文番号</dt>
<dd>{{ correctionCandidate.orderNumber }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>現在数量</dt>
<dd>{{ orderQuantityText(correctionCandidate) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>現在価格</dt>
<dd>{{ priceText(correctionCandidate.price, correctionCandidate.market) }}</dd>
</div>
</dl>
<div class="grid gap-3">
<label :class="ui.label">
数量
<input
v-model="correctionQuantityInput"
:class="ui.input"
inputmode="decimal"
@input="correctionPreview = null"
/>
</label>
<label :class="ui.label">
執行条件
<select
v-model="correctionPriceCondition"
:class="ui.input"
@change="correctionPreview = null"
>
<option value="limit">指値</option>
<option value="market">成行</option>
</select>
</label>
<label v-if="correctionPriceCondition === 'limit'" :class="ui.label">
指値
<input
v-model="correctionPriceInput"
:class="ui.input"
inputmode="decimal"
@input="correctionPreview = null"
/>
</label>
</div>
<dl v-if="correctionPreview" :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>見積数量</dt>
<dd>{{ correctionPreview.quantity }}株</dd>
</div>
<div :class="ui.confirmRow">
<dt>見積金額</dt>
<dd>
{{
correctionPreview.price
? priceText(correctionPreview.price.value, correctionCandidate.market)
: '-'
}}
</dd>
</div>
</dl>
<div :class="ui.actions">
<UiButton variant="ghost" @click="correctionCandidate = null">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
戻る
</UiButton>
<UiButton :disabled="correctionLoading" @click="estimateCorrection">
<Spinner v-if="correctionLoading" size="sm" />
<ShieldCheck v-else class="h-4 w-4" aria-hidden="true" />
見積
</UiButton>
<UiButton
variant="danger"
:disabled="!correctionPreview || correctionSubmitting"
@click="confirmCorrection"
>
<Spinner v-if="correctionSubmitting" size="sm" />
<Pencil v-else class="h-4 w-4" aria-hidden="true" />
訂正
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="cancelCandidate"
@@ -1,28 +1,30 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { AnimatePresence } from 'motion-v'
import { ArrowLeft, Ban, Plug } from 'lucide-vue-next'
import { ArrowLeft, Ban, FileText, Plug } from 'lucide-vue-next'
import Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
import type { OrderRow, Position } from '../../types/trading'
import type { MarketIndex, OrderRow, Position } from '../../types/trading'
import {
currency,
currencyForMarket,
number as formatNumber,
signedCurrency,
signedCurrencyForMarket,
signedPercent,
} from '../../utils/format'
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
defineProps<{
const props = defineProps<{
showPortfolioSpinner: boolean
totalAssetValue: number
buyingPower: number
holdingsMarketValue: number
totalProfitLoss: number
totalProfitLossRate: number
marketIndexes: MarketIndex[]
stockAssetRatio: number
cashAssetRatio: number
positions: Position[]
@@ -32,6 +34,7 @@ defineProps<{
connected: boolean
orderHistoryLoaded: boolean
orderHistoryNotice: string
loadPositionDetail: (position: Position) => Promise<Position>
}>()
const emit = defineEmits<{
@@ -41,12 +44,47 @@ const emit = defineEmits<{
}>()
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
const isUsMarket = (market: string) => usMarkets.has(market)
const canCancel = (order: OrderRow) =>
order.status === '注文中' && Boolean(order.orderNumber) && !usMarkets.has(order.market)
order.status === '注文中' && Boolean(order.orderNumber) && order.cancelable !== false
const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
orderHistoryKey(order) === cancelingOrderKey
const cancelCandidate = ref<OrderRow | null>(null)
const positionDetail = ref<Position | null>(null)
const positionDetailLoadingKey = ref('')
const positionDetailError = ref('')
const cancelTitle = computed(() => cancelCandidate.value?.stock ?? '')
const indexValueText = (index: MarketIndex) =>
index.valueText || (index.value == null ? '-' : formatNumber(index.value))
const indexChangeText = (index: MarketIndex) => {
const change = index.changeText || (index.change == null ? '' : formatNumber(index.change))
const rate =
index.changeRateText || (index.changeRate == null ? '' : signedPercent(index.changeRate))
return [change, rate].filter(Boolean).join(' / ') || '-'
}
const indexTone = (index: MarketIndex) => {
if (index.sign === 'positive') return ui.positive
if (index.sign === 'negative') return ui.negative
return ui.muted
}
const positionKey = (position: Position) =>
[position.market, position.code, position.accountType].filter(Boolean).join(':')
const canLoadPositionDetail = (position: Position) => isUsMarket(position.market)
const showPositionDetail = async (position: Position) => {
if (!canLoadPositionDetail(position) || positionDetailLoadingKey.value) return
const key = positionKey(position)
positionDetailLoadingKey.value = key
positionDetailError.value = ''
try {
positionDetail.value = await props.loadPositionDetail(position)
} catch (cause) {
positionDetailError.value =
cause instanceof Error ? cause.message : '保有詳細の取得に失敗しました'
} finally {
positionDetailLoadingKey.value = ''
}
}
const askCancel = (order: OrderRow) => {
if (!canCancel(order)) return
@@ -69,6 +107,13 @@ const confirmCancel = () => {
<template v-if="!showPortfolioSpinner">{{ currency(totalAssetValue) }}</template>
<Spinner v-else size="lg" />
</strong>
<small :class="[ui.assetOverviewSubtext, totalProfitLoss >= 0 ? ui.positive : ui.negative]">
通算評価損益:
<template v-if="!showPortfolioSpinner">
{{ signedCurrency(totalProfitLoss) }} · {{ signedPercent(totalProfitLossRate) }}
</template>
<Spinner v-else size="sm" />
</small>
<small :class="ui.assetOverviewSubtext">
余力:
<template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template>
@@ -120,17 +165,6 @@ const confirmCancel = () => {
</div>
</article>
<article :class="ui.metricPanel">
<span :class="ui.metricLabel">通算評価損益</span>
<strong :class="[ui.metricValue, totalProfitLoss >= 0 ? ui.positive : ui.negative]">
<template v-if="!showPortfolioSpinner">
{{ signedCurrency(totalProfitLoss) }}
<small> · {{ signedPercent(totalProfitLossRate) }}</small>
</template>
<Spinner v-else size="lg" />
</strong>
</article>
<article :class="ui.holdingsPanel">
<div :class="ui.panelHead">
<h2>保有銘柄</h2>
@@ -151,19 +185,18 @@ const confirmCancel = () => {
<span>数量</span>
<span>評価額</span>
<span>評価損益</span>
<span>操作</span>
</div>
<div v-if="positions.length" :class="ui.holdingsRows">
<button
v-for="position in positions"
:key="position.code"
:class="ui.holdingRow"
type="button"
@click="emit('openPosition', position.code)"
>
<span class="grid gap-1">
<div v-for="position in positions" :key="positionKey(position)" :class="ui.holdingRow">
<button
class="grid gap-1 text-left text-[#e3e3e9]"
type="button"
@click="emit('openPosition', position.code)"
>
<strong>{{ position.name }}</strong>
<small>{{ position.code }}</small>
</span>
</button>
<b :class="ui.typePill">{{
position.type ?? (position.quantity >= 100 ? '単元' : 'S株')
}}</b>
@@ -178,12 +211,27 @@ const confirmCancel = () => {
<strong>{{ signedCurrencyForMarket(position.profitLoss, position.market) }}</strong>
<small>{{ signedPercent(position.profitLossRate) }}</small>
</span>
</button>
<span :class="ui.rowActions">
<button
v-if="canLoadPositionDetail(position)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(positionDetailLoadingKey)"
@click="showPositionDetail(position)"
>
<Spinner v-if="positionDetailLoadingKey === positionKey(position)" size="sm" />
<FileText v-else class="h-3.5 w-3.5" aria-hidden="true" />
詳細
</button>
</span>
</div>
</div>
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
<Spinner />
</div>
<p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると保有銘柄を表示します</p>
<p v-if="positionDetailError" :class="ui.dialogNote">{{ positionDetailError }}</p>
</div>
</article>
@@ -234,8 +282,85 @@ const confirmCancel = () => {
</p>
</div>
</article>
<article :class="ui.marketIndexPanel">
<div :class="ui.panelHead">
<h2>指数</h2>
</div>
<div v-if="marketIndexes.length" :class="ui.marketIndexGrid">
<div
v-for="index in marketIndexes"
:key="index.code ?? index.name"
:class="ui.marketIndexCard"
>
<span :class="ui.metricLabel">{{ index.name }}</span>
<strong :class="ui.marketIndexValue">{{ indexValueText(index) }}</strong>
<small :class="indexTone(index)">{{ indexChangeText(index) }}</small>
</div>
</div>
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
<Spinner />
</div>
<p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると指数を表示します</p>
</article>
</section>
<AnimatePresence>
<UiModal
v-if="positionDetail"
key="position-detail-dialog"
eyebrow="保有詳細"
:title="positionDetail.name"
@close="positionDetail = null"
>
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>銘柄</dt>
<dd>{{ positionDetail.code }} / {{ positionDetail.market }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>預り区分</dt>
<dd>{{ positionDetail.type ?? positionDetail.accountType ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>数量</dt>
<dd>{{ positionDetail.quantity }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>現在値</dt>
<dd>
{{
positionDetail.currentPrice == null
? '-'
: currencyForMarket(positionDetail.currentPrice, positionDetail.market)
}}
</dd>
</div>
<div :class="ui.confirmRow">
<dt>平均取得単価</dt>
<dd>{{ currencyForMarket(positionDetail.avgPrice, positionDetail.market) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>評価額</dt>
<dd>{{ currencyForMarket(positionDetail.marketValue, positionDetail.market) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>評価損益</dt>
<dd :class="positionDetail.profitLoss >= 0 ? ui.positive : ui.negative">
{{ signedCurrencyForMarket(positionDetail.profitLoss, positionDetail.market) }} /
{{ signedPercent(positionDetail.profitLossRate) }}
</dd>
</div>
</dl>
<div :class="ui.actions">
<UiButton variant="ghost" @click="positionDetail = null">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
閉じる
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="cancelCandidate"
@@ -1,19 +1,29 @@
import type {
ChartNotice,
MarketIndex,
OrderDetail,
OrderPreview,
OrderRow,
Position,
RealtimePricePoint,
Stock,
TradeRecordRow,
} from '../../types/trading'
import { currency } from '../../utils/format'
export type RecordLike = Record<string, unknown>
type IssueLike = {
code: string
market: string
name: string
searchText?: string
}
export const emptyStock: Stock = {
code: '',
name: '未選択',
symbol: '',
searchText: '',
country: '日本',
market: '',
sector: '',
@@ -75,13 +85,27 @@ const countryFromMarket = (market: string) =>
export const issueFrom = (value: unknown) => {
const issue = asRecord(value)
const code = textValue(issue.code, textValue(issue.issueCode))
const market = textValue(issue.market, textValue(issue.marketCode, ''))
const name = textValue(
issue.name,
textValue(issue.issueName, textValue(issue.stockName, textValue(issue.displayName))),
)
const searchText = [
code,
market,
name,
textValue(issue.extract),
textValue(issue.extractWord),
textValue(issue.hitString),
]
.filter(Boolean)
.join(' ')
return {
code: textValue(issue.code, textValue(issue.issueCode)),
market: textValue(issue.market, textValue(issue.marketCode, '')),
name: textValue(
issue.name,
textValue(issue.issueName, textValue(issue.stockName, textValue(issue.displayName))),
),
code,
market,
name,
searchText,
}
}
@@ -112,10 +136,11 @@ const boxFromHistory = (history: number[]) => {
return { min: at(0), q1: at(0.25), median: at(0.5), q3: at(0.75), max: at(1) }
}
export const stockFromIssue = (issue: ReturnType<typeof issueFrom>): Stock => ({
export const stockFromIssue = (issue: IssueLike): Stock => ({
code: issue.code,
name: issue.name || issue.code,
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
searchText: issue.searchText,
country: countryFromMarket(issue.market),
market: issue.market,
sector: '',
@@ -146,10 +171,16 @@ export const stockFromPosition = (position: Position): Stock => {
}
}
export const stockFromBoard = (
value: unknown,
fallbackIssue?: ReturnType<typeof issueFrom>,
): Stock => {
const accountTypeLabels: Record<string, string> = {
specific: '特定',
general: '一般',
growthInvestment: 'NISA成長投資枠',
nisa: 'NISA',
}
const accountTypeLabel = (value: string) => accountTypeLabels[value] ?? value
export const stockFromBoard = (value: unknown, fallbackIssue?: IssueLike): Stock => {
const board = asRecord(value)
const quoteRecord = asRecord(board.quote)
const boardIssue = issueFrom(board.issue)
@@ -158,6 +189,7 @@ export const stockFromBoard = (
code: quoteIssue.code || boardIssue.code || fallbackIssue?.code || '',
market: quoteIssue.market || boardIssue.market || fallbackIssue?.market || '',
name: quoteIssue.name || boardIssue.name || fallbackIssue?.name || '',
searchText: quoteIssue.searchText || boardIssue.searchText || fallbackIssue?.searchText || '',
}
const quote = quoteFrom(quoteRecord)
const codeAsNumber = Number(issue.code)
@@ -169,6 +201,7 @@ export const stockFromBoard = (
code: issue.code,
name: issue.name,
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
searchText: issue.searchText,
country: countryFromMarket(issue.market),
market: issue.market,
sector: '',
@@ -186,6 +219,53 @@ export const stockFromBoard = (
}
}
const signedValueFrom = (value: unknown) => {
const record = asRecord(value)
const parsedValue = nullableNumberValue(record.value)
const text = textValue(record.text, parsedValue == null ? '' : String(parsedValue))
const rawSign = textValue(record.sign)
const sign: MarketIndex['sign'] =
rawSign === 'positive' || rawSign === 'negative' || rawSign === 'zero'
? rawSign
: parsedValue == null || parsedValue === 0
? 'zero'
: parsedValue > 0
? 'positive'
: 'negative'
return { value: parsedValue, text, sign }
}
const percentValueFrom = (value: unknown) => {
const record = asRecord(value)
const parsedValue = nullableNumberValue(record.value)
return {
value: parsedValue,
text: textValue(record.text, parsedValue == null ? '' : `${parsedValue}%`),
}
}
export const marketIndexFromApi = (value: unknown): MarketIndex | null => {
const index = asRecord(value)
const name = textValue(index.name)
if (!name) return null
const parsedValue = nullableNumberValue(index.value)
const change = signedValueFrom(index.change)
const changeRate = percentValueFrom(index.changeRate)
return {
code: textValue(index.code) || undefined,
name,
value: parsedValue,
valueText: textValue(index.valueText, parsedValue == null ? '' : String(parsedValue)),
change: change.value,
changeText: change.text,
changeRate: changeRate.value,
changeRateText: changeRate.text,
sign: change.sign,
timestamp: textValue(index.timestamp) || undefined,
}
}
const zonedPartsFormatter = (timeZone: string) =>
new Intl.DateTimeFormat('en-US', {
timeZone,
@@ -357,16 +437,20 @@ export const positionFromApi = (value: unknown): Position | null => {
const costBasis = avgPrice * quantity
const profitLossRate =
numberValue(item.profitLossRate) || (costBasis ? (profitLoss / costBasis) * 100 : 0)
const accountType = textValue(item.accountType, textValue(item.depositType))
return {
code: issue.code,
name: issue.name,
market: issue.market,
quantity,
avgPrice,
currentPrice: nullableNumberValue(item.currentPrice),
marketValue: numberValue(item.marketValue ?? item.valuationPrice),
profitLoss,
profitLossRate,
type: textValue(item.depositTypeText) || undefined,
type:
textValue(item.depositTypeText) || (accountType ? accountTypeLabel(accountType) : undefined),
accountType: accountType || undefined,
}
}
@@ -396,16 +480,59 @@ export const orderFromApi = (value: unknown): OrderRow | null => {
: (nullableNumberValue(item.price) ?? nullableNumberValue(item.executedPrice))
return {
id: textValue(item.id, textValue(item.orderNumber, `${issue.code}-${item.orderedAt ?? ''}`)),
code: issue.code,
date: textValue(item.orderedAt, textValue(item.expiresAt)),
stock: issue.name || issue.code,
market: issue.market,
side,
kind,
quantity,
unexecutedQuantity,
executedQuantity,
price,
status,
orderNumber: textValue(item.orderNumber),
orderSubNo: textValue(item.orderSubNo),
tradeId: textValue(item.tradeId),
accountType: textValue(item.accountType, textValue(item.depositType)),
cancelable: typeof item.cancelable === 'boolean' ? item.cancelable : undefined,
correctable: typeof item.correctable === 'boolean' ? item.correctable : undefined,
}
}
export const orderDetailFromApi = (value: unknown): OrderDetail | null => {
const row = orderFromApi(value)
if (!row) return null
const item = asRecord(value)
return {
...row,
expiresAt: textValue(item.expiresAt) || undefined,
statusText: textValue(item.statusText) || undefined,
depositType: textValue(item.depositType) || undefined,
accountInformation: textValue(item.accountInformation) || undefined,
}
}
export const tradeRecordFromApi = (value: unknown): TradeRecordRow | null => {
const item = asRecord(value)
const issue = issueFrom(item.issue)
if (!issue.code) return null
const tradeDate = textValue(item.tradeDate)
const valueDate = textValue(item.valueDate)
const type = textValue(item.tradeRecordTypeCode, textValue(item.type, '取引'))
return {
id: textValue(item.id, [issue.code, tradeDate, valueDate, type].filter(Boolean).join(':')),
code: issue.code,
stock: issue.name || issue.code,
market: issue.market,
type,
quantity: nullableNumberValue(item.quantity),
price: nullableNumberValue(item.price),
amount: nullableNumberValue(item.amount),
tradeDate: tradeDate || undefined,
valueDate: valueDate || undefined,
accountType: textValue(item.accountType) || undefined,
settlementCurrencyCode: textValue(item.settlementCurrencyCode) || undefined,
}
}
@@ -17,6 +17,8 @@ import type {
CashOrderTerm,
CashOrderTriggerZone,
JsonRpcResponse,
MarketIndex,
OrderDetail,
OrderKind,
OrderPreview,
OrderRow,
@@ -24,6 +26,7 @@ import type {
RealtimePricePoint,
RpcMessage,
Stock,
TradeRecordRow,
TradeSide,
} from '../../types/trading'
import {
@@ -34,7 +37,9 @@ import {
isOrderPreview,
issueFrom,
marketDateKey,
marketIndexFromApi,
numberValue,
orderDetailFromApi,
orderFromApi,
orderHistoryKey,
orderHistoryResultNotice,
@@ -44,6 +49,7 @@ import {
stockFromBoard,
stockFromIssue,
stockFromPosition,
tradeRecordFromApi,
textValue,
type RecordLike,
} from './trading-data'
@@ -157,6 +163,8 @@ const displayApkOrderTermDate = (value: string) => {
}
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
const isUsMarket = (market: string) => usMarkets.has(market)
const usOrderMarkets = ['XNAS', 'XNYS', 'ARCX'] as const
const parseApkExchangeMarkets = (value: string) => {
const markets: CashOrderMarket[] = []
@@ -212,6 +220,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const holdingsMarketValue = ref(0)
const totalProfitLoss = ref(0)
const totalProfitLossRate = ref(0)
const marketIndexes = ref<MarketIndex[]>([])
const orders = ref<OrderRow[]>([])
const cancelingOrderKey = ref('')
const orderHistoryLoaded = ref(false)
@@ -522,7 +531,8 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const matchesQuery =
stock.name.toLowerCase().includes(query) ||
stock.code.includes(query) ||
stock.symbol.toLowerCase().includes(query)
stock.symbol.toLowerCase().includes(query) ||
stock.searchText?.toLowerCase().includes(query)
return matchesQuery && matchesFilters(stock)
})
: viewedStocks.value.filter(matchesFilters)
@@ -753,6 +763,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
price: stock.price || current?.price || 0,
change: stock.change || current?.change || 0,
changeAmount: stock.changeAmount || current?.changeAmount || 0,
searchText: stock.searchText || current?.searchText || '',
history: stock.price ? stock.history : (current?.history ?? stock.history),
box: stock.price ? stock.box : (current?.box ?? stock.box),
})
@@ -771,16 +782,19 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const loadOrderHistoryFromSdk = async () => {
orderHistoryLoaded.value = false
orderHistoryNotice.value = ''
const [openOrdersResult, executionsTodayResult] = await Promise.allSettled([
const orderResults = await Promise.allSettled([
rpcCallOptional<RecordLike>('orders.inquiry.open'),
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday'),
rpcCallOptional<RecordLike>('orders.inquiry.open', { market: 'XNAS' }),
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday', { market: 'XNAS' }),
...usOrderMarkets.flatMap((market) => [
rpcCallOptional<RecordLike>('orders.inquiry.open', { market }),
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday', { market }),
]),
])
const [openOrdersResult, executionsTodayResult] = orderResults
if (openOrdersResult.status === 'rejected' && executionsTodayResult.status === 'rejected') {
throw openOrdersResult.reason
}
const nextOrders = fulfilledValues([openOrdersResult, executionsTodayResult])
const nextOrders = fulfilledValues(orderResults)
.flatMap((orderList) => asArray(orderList.orders))
.map(orderFromApi)
.filter((order): order is OrderRow => Boolean(order))
@@ -790,9 +804,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
orders.value = [...deduped.values()]
orderHistoryLoaded.value = true
if (!orders.value.length) {
const notices = fulfilledValues([openOrdersResult, executionsTodayResult])
.map(orderHistoryResultNotice)
.filter(Boolean)
const notices = fulfilledValues(orderResults).map(orderHistoryResultNotice).filter(Boolean)
orderHistoryNotice.value = [...new Set(notices)].join(' / ')
}
}
@@ -822,11 +834,25 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const loadTradingData = async () => {
dataLoading.value = true
try {
const [assetsResult, ...positionResults] = await Promise.allSettled([
const [assetsResult, indexResult, ...positionResults] = await Promise.allSettled([
rpcCallOptional<RecordLike>('account.assets.current', undefined, 20_000),
rpcCallOptional<unknown[]>('market.index.major', undefined, 15_000),
rpcCallOptional<RecordLike>('account.positions.cash', undefined, 15_000),
rpcCallOptional<RecordLike>('account.positions.cash', { market: 'XNAS' }, 15_000),
...usOrderMarkets.map((market) =>
rpcCallOptional<RecordLike>('account.positions.cash', { market }, 15_000),
),
])
if (indexResult.status === 'fulfilled') {
marketIndexes.value = asArray(indexResult.value)
.map(marketIndexFromApi)
.filter((index): index is MarketIndex => Boolean(index))
} else {
marketIndexes.value = []
reportDataError(
errorMessage(indexResult.reason, '指数の取得に失敗しました'),
indexResult.reason,
)
}
const cashPositionLists = fulfilledValues(positionResults)
const cashPositions = cashPositionLists[0] ?? {}
const nextPositions = cashPositionLists
@@ -835,13 +861,26 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
.filter((position): position is Position => Boolean(position))
positions.value = nextPositions
mergeStocks(nextPositions.map(stockFromPosition))
const nextHoldingsMarketValue = numberValue(
cashPositions.totalMarketValue,
nextPositions.reduce((sum, position) => sum + position.marketValue, 0),
const summedHoldingsMarketValue = nextPositions.reduce(
(sum, position) => sum + position.marketValue,
0,
)
const summedProfitLoss = nextPositions.reduce((sum, position) => sum + position.profitLoss, 0)
const summedCostBasis = nextPositions.reduce(
(sum, position) => sum + (position.marketValue - position.profitLoss),
0,
)
const nextHoldingsMarketValue =
nextPositions.length > 0
? summedHoldingsMarketValue
: numberValue(cashPositions.totalMarketValue)
holdingsMarketValue.value = nextHoldingsMarketValue
totalProfitLoss.value = numberValue(cashPositions.totalProfitLoss)
totalProfitLossRate.value = numberValue(cashPositions.totalProfitLossRate)
totalProfitLoss.value =
nextPositions.length > 0 ? summedProfitLoss : numberValue(cashPositions.totalProfitLoss)
totalProfitLossRate.value =
nextPositions.length > 0 && summedCostBasis
? (summedProfitLoss / summedCostBasis) * 100
: numberValue(cashPositions.totalProfitLossRate)
const hasAccountAssets = assetsResult.status === 'fulfilled'
if (hasAccountAssets) {
@@ -1011,7 +1050,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
}
}
const searchIssues = async (query: string) => {
const suggestIssues = async (query: string) => {
if (!connected.value || query.trim().length < 2) return
const marketsToSearch =
marketFilter.value !== 'all'
@@ -1019,7 +1058,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
: searchableMarkets.filter((market) => market !== 'auto')
const results = await Promise.allSettled(
marketsToSearch.map((market) =>
rpcCall<RecordLike>('market.issue.search', { query, market, limit: 12 }),
rpcCall<RecordLike>('market.issue.suggest', { query, market, limit: 12 }),
),
)
const issues = fulfilledValues(results)
@@ -1121,6 +1160,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
orders.value = [
{
id: textValue(receipt.orderId, `ord-${Date.now()}`),
code: selectedStock.value.code,
date: textValue(receipt.acceptedAt, new Date().toLocaleString('ja-JP')),
stock: selectedStock.value.name,
market: selectedStock.value.market,
@@ -1147,7 +1187,9 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
try {
const params = {
orderNumber: order.orderNumber,
orderId: order.id,
orderId: order.orderSubNo || order.id,
issueCode: order.code,
market: order.market,
tradeId: order.tradeId || undefined,
}
await rpcCall('orders.cash.placeCancel', {
@@ -1163,6 +1205,85 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
}
}
const loadOrderDetail = async (order: OrderRow): Promise<OrderDetail> => {
if (!isUsMarket(order.market)) {
throw new Error('注文詳細は米国株のみ対応しています')
}
if (!order.orderNumber && !order.id && !order.orderSubNo) {
throw new Error('注文番号を取得できないため詳細を取得できません')
}
const detail = await rpcCall<RecordLike>('orders.inquiry.detail', {
orderNumber: order.orderNumber,
orderId: order.orderSubNo || order.id,
issueCode: order.code,
market: order.market,
})
const parsed = orderDetailFromApi(detail)
if (!parsed) throw new Error('注文詳細を読み取れませんでした')
return parsed
}
const loadTradeRecords = async (): Promise<TradeRecordRow[]> => {
const result = await rpcCall<RecordLike>('orders.inquiry.tradeRecords', { limit: 50 })
return asArray(result.records)
.map(tradeRecordFromApi)
.filter((record): record is TradeRecordRow => Boolean(record))
}
const loadPositionDetail = async (position: Position): Promise<Position> => {
if (!isUsMarket(position.market)) {
throw new Error('保有詳細は米国株のみ対応しています')
}
const detail = await rpcCall<RecordLike>('account.positions.cashDetail', {
issueCode: position.code,
market: position.market,
accountType: position.accountType,
limit: 1,
})
const parsed = asArray(detail.positions).map(positionFromApi)[0]
if (!parsed) throw new Error('保有詳細を読み取れませんでした')
return parsed
}
const orderCorrectionParams = (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
) => ({
orderNumber: order.orderNumber,
orderId: order.orderSubNo || order.id,
issueCode: order.code,
market: order.market,
quantity: draft.quantity,
priceCondition: draft.priceCondition,
price: draft.priceCondition === 'limit' ? draft.price : undefined,
orderMethod: 'normal',
})
const estimateOrderCorrection = async (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
): Promise<OrderPreview> => {
if (!isUsMarket(order.market)) throw new Error('注文訂正は米国株のみ対応しています')
const preview = await rpcCall<unknown>(
'orders.cash.estimateCorrection',
orderCorrectionParams(order, draft),
)
if (!isOrderPreview(preview)) throw new Error('注文訂正の見積を読み取れませんでした')
return preview
}
const placeOrderCorrection = async (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
) => {
if (!isUsMarket(order.market)) throw new Error('注文訂正は米国株のみ対応しています')
await rpcCall('orders.cash.placeCorrection', {
...orderCorrectionParams(order, draft),
allowTrading: true,
})
await loadTradingData()
}
const downloadCsv = () => {
const header = ['code', 'name', 'symbol', 'market', 'price'].join(',')
const rows = selectedStock.value.history.map((price) =>
@@ -1284,7 +1405,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
searchTimer = setTimeout(async () => {
const requestId = ++searchRequestId
try {
await searchIssues(query)
await suggestIssues(query)
} catch (cause) {
reportDataError(errorMessage(cause, '銘柄検索に失敗しました'), cause)
} finally {
@@ -1337,6 +1458,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
holdingsMarketValue,
totalProfitLoss,
totalProfitLossRate,
marketIndexes,
orders,
cancelingOrderKey,
orderHistoryLoaded,
@@ -1379,6 +1501,11 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
askPlaceOrder,
placeCashOrder,
cancelOrder,
loadOrderDetail,
loadTradeRecords,
loadPositionDetail,
estimateOrderCorrection,
placeOrderCorrection,
downloadCsv,
openTradeForStock,
openTradeForPosition,
+14 -9
View File
@@ -24,12 +24,12 @@ export const ui = {
panelHead: 'flex flex-wrap items-center justify-between gap-3',
eyebrow: 'mb-1 text-xs font-black uppercase text-[#9aa0a9]',
dashboardGrid:
'grid min-h-0 flex-1 grid-cols-1 items-stretch gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)] lg:grid-rows-[auto_minmax(0,1fr)] lg:gap-6 lg:overflow-hidden',
'grid min-h-0 flex-1 grid-cols-1 items-stretch gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)] lg:grid-rows-[auto_minmax(20rem,auto)_auto] lg:gap-6',
metricPanel:
'grid min-h-36 content-center gap-3 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:min-h-40 sm:p-7',
assetOverviewPanel:
'grid min-h-40 grid-cols-1 items-center gap-x-7 gap-y-5 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] sm:p-7',
assetOverviewHead: 'grid gap-2',
'grid min-h-48 grid-cols-1 items-center gap-x-7 gap-y-5 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 pb-8 shadow-lg shadow-black/15 sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] sm:p-7 sm:pb-10 lg:col-span-2',
assetOverviewHead: 'grid gap-3',
assetOverviewSubtext: 'text-sm font-semibold text-[#c3c7cf]',
assetBreakdownPanel: 'grid gap-3',
assetBreakdownTitle: 'text-xs font-extrabold text-[#9aa0a9]',
@@ -52,19 +52,24 @@ export const ui = {
assetBreakdownAmount: 'text-sm font-black text-[#e3e3e9]',
assetBreakdownRatio: 'text-xs font-semibold text-[#8f949d]',
holdingsPanel:
'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7',
'flex min-h-80 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7',
holdingsBody: 'flex min-h-0 flex-1 flex-col overflow-visible lg:overflow-hidden',
holdingsRows: 'grid min-h-0 flex-1 content-start overflow-y-auto',
holdingsHead:
'hidden grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 border-b border-[#33383f] py-3 text-xs font-extrabold text-[#8f949d] md:grid',
'hidden grid-cols-[1.6fr_0.7fr_0.6fr_0.9fr_0.9fr_auto] items-center gap-4 border-b border-[#33383f] py-3 text-xs font-extrabold text-[#8f949d] md:grid',
holdingRow:
'grid min-h-16 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-2xl bg-[#111418] px-3 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] md:grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] md:gap-4 md:bg-transparent',
'grid min-h-16 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-2xl bg-[#111418] px-3 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] md:grid-cols-[1.6fr_0.7fr_0.6fr_0.9fr_0.9fr_auto] md:gap-4 md:bg-transparent',
typePill: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs text-[#d3e3fd]',
muted: 'text-[#8f949d]',
portfolioHistory:
'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7',
'flex min-h-80 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7',
historyList: 'flex min-h-0 flex-1 flex-col overflow-visible lg:overflow-hidden',
historyRows: 'grid min-h-0 flex-1 content-start gap-5 overflow-y-auto lg:gap-7',
marketIndexPanel:
'grid gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7 lg:col-span-2',
marketIndexGrid: 'grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4',
marketIndexCard: 'grid min-h-28 content-center gap-2 rounded-2xl bg-[#111418] p-4',
marketIndexValue: 'break-words text-xl font-black text-[#e3e3e9]',
emptyState: 'flex flex-1 items-center justify-center py-8 text-center',
miniOrder: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3',
tradeLayout:
@@ -153,13 +158,13 @@ export const ui = {
'inline-flex min-h-10 items-center justify-center gap-2 rounded-full border border-[#4a5058] bg-transparent px-5 font-extrabold text-[#d3e3fd] transition hover:bg-[#263141]',
list: 'grid gap-2',
orderRow:
'grid grid-cols-2 items-center gap-3 rounded-[20px] bg-[#111418] p-4 md:grid-cols-[minmax(180px,1fr)_auto_auto_auto_auto_auto]',
'grid grid-cols-2 items-center gap-3 rounded-[20px] bg-[#111418] p-4 md:grid-cols-[minmax(180px,1fr)_auto_auto_auto_auto_minmax(12rem,auto)]',
statusBadge: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs font-black text-[#d3e3fd]',
pendingBadge: 'bg-[#4a3720] text-[#ffddb3]',
apiLayout: 'grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.9fr)] lg:gap-7',
settingsLayout: 'grid gap-7',
row: 'grid grid-cols-1 items-center gap-3 rounded-[20px] bg-[#111418] p-4 sm:grid-cols-[minmax(0,1fr)_auto]',
rowActions: 'flex gap-2',
rowActions: 'flex flex-wrap items-center justify-end gap-2',
keyRow: 'grid gap-3 rounded-[24px] bg-[#111418] p-4',
profileRow:
'grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-[20px] bg-[#111418] p-4 sm:grid-cols-[auto_minmax(0,1fr)_auto]',
+60 -1
View File
@@ -1,7 +1,16 @@
export type TradeSide = 'buy' | 'sell'
export type OrderKind = 'standard' | 's'
export type CashOrderAccountType = 'specific' | 'general' | 'growthInvestment' | 'nisa'
export type CashOrderMarket = 'auto' | 'XTKS' | 'XNAS' | 'XNYS' | 'ARCX' | 'STK'
export type CashOrderMarket =
| 'auto'
| 'XTKS'
| 'XNGO'
| 'XFKA'
| 'XSAP'
| 'XNAS'
| 'XNYS'
| 'ARCX'
| 'STK'
export type CashOrderPriceCondition =
| 'limit'
| 'limitAtOpen'
@@ -31,6 +40,11 @@ export type OrderPreview = {
}
side: string
quantity: number
price?: {
value: number | null
text: string
currency?: string
}
warnings: string[]
confirmationId?: string
message?: string
@@ -61,10 +75,24 @@ export type ChartNotice = {
detail?: string
}
export type MarketIndex = {
code?: string
name: string
value: number | null
valueText: string
change: number | null
changeText: string
changeRate: number | null
changeRateText: string
sign: 'positive' | 'negative' | 'zero'
timestamp?: string
}
export type Stock = {
code: string
name: string
symbol: string
searchText?: string
country: string
market: string
sector: string
@@ -89,16 +117,45 @@ export type Stock = {
export type OrderRow = {
id: string
code: string
date: string
stock: string
market: string
side: TradeSide
kind: OrderKind
quantity: number | null
unexecutedQuantity?: number | null
executedQuantity?: number | null
price: number | null
status: '注文中' | '約定済' | '取消済'
orderNumber?: string
orderSubNo?: string
tradeId?: string
accountType?: string
cancelable?: boolean
correctable?: boolean
}
export type OrderDetail = OrderRow & {
expiresAt?: string
statusText?: string
depositType?: string
accountInformation?: string
}
export type TradeRecordRow = {
id: string
code: string
stock: string
market: string
type: string
quantity: number | null
price: number | null
amount: number | null
tradeDate?: string
valueDate?: string
accountType?: string
settlementCurrencyCode?: string
}
export type Position = {
@@ -107,8 +164,10 @@ export type Position = {
market: string
quantity: number
avgPrice: number
currentPrice?: number | null
marketValue: number
profitLoss: number
profitLossRate: number
type?: string
accountType?: CashOrderAccountType | string
}
+2
View File
@@ -37,6 +37,8 @@ export type {
OrderCorrectionPreOrder,
OrderCorrectionPreOrderDetail,
OrderPreview,
TradeRecord,
TradeRecordList,
StockOrderPreOrder,
StockOrderPreOrderMarginTradeType,
StockOrderPreOrderPaymentLimit,
+3
View File
@@ -4,6 +4,9 @@ export type MarketRegion = 'domestic' | 'us'
const DOMESTIC_TO_MTS = {
XTKS: 'TKY',
XNGO: 'NGY',
XFKA: 'FKO',
XSAP: 'SPR',
} as const satisfies Record<string, string>
const MTS_TO_DOMESTIC = Object.fromEntries(
+34 -14
View File
@@ -297,19 +297,21 @@ export const createMethodsFromSession = (session: SbiSession): SbiClientMethods
positions: {
cash: async (options) =>
options?.market && isUsMarket(options.market)
? usStock.positions()
? usStock.positions(options)
: parseCashPositions(
await callMts(session, 'F2631', listAccountTrin(session, options)),
options,
),
cashDetail: async (options) =>
parseCashPositions(
await callMts(session, 'F2632', listAccountTrin(session, options)),
options,
),
options?.market && isUsMarket(options.market)
? usStock.positionsDetail(options)
: parseCashPositions(
await callMts(session, 'F2632', listAccountTrin(session, options)),
options,
),
cashForIssue: async (options) => {
return isUsMarket(options.market)
? usStock.positions()
? usStock.positions(options)
: filterCashPositions(
parseCashPositions(
await callMts(session, 'F2602', issuePositionTrin(session, options)),
@@ -487,6 +489,18 @@ export const createMethodsFromSession = (session: SbiSession): SbiClientMethods
),
options,
),
detail: async (options) => {
if (isUsMarket(options.market)) return usStock.orderDetail(options)
throw new Error(
'orders.inquiry.detail is currently implemented only for US stock markets',
)
},
tradeRecords: async (options) => {
if (!options.market || isUsMarket(options.market)) return usStock.tradeRecords(options)
throw new Error(
'orders.inquiry.tradeRecords is currently implemented only for US stock markets',
)
},
},
cash: {
preOrder: async (options) =>
@@ -528,22 +542,28 @@ export const createMethodsFromSession = (session: SbiSession): SbiClientMethods
)
},
estimateCorrection: async (options) =>
parseOrderCorrectionPreOrder(
await callMts(session, 'F2301', orderCorrectionPreOrderTrin(session, options)),
cashCorrectionPreviewInput,
),
options.market && isUsMarket(options.market)
? usStock.estimateCorrection(options)
: parseOrderCorrectionPreOrder(
await callMts(session, 'F2301', orderCorrectionPreOrderTrin(session, options)),
cashCorrectionPreviewInput,
),
estimateCorrectionConfirm: async (options) =>
parseOrderPreview(
await callMts(session, 'F2302', orderCorrectionSubmitTrin(session, options)),
cashCorrectionPreviewInput,
),
options.market && isUsMarket(options.market)
? usStock.estimateCorrectionConfirm(options)
: parseOrderPreview(
await callMts(session, 'F2302', orderCorrectionSubmitTrin(session, options)),
cashCorrectionPreviewInput,
),
placeCorrection: async (options) => {
if (options.market && isUsMarket(options.market)) return usStock.placeCorrection(options)
assertTradingAllowed(options, 'orders.cash.placeCorrection')
return parseOrderReceipt(
await callMts(session, 'F2303', orderCorrectionSubmitTrin(session, options)),
)
},
estimateCancel: async (options) => {
if (options.market && isUsMarket(options.market)) return usStock.estimateCancel(options)
assertOrderCancelOptions(options)
return parseOrderCorrectionPreOrder(
await callMts(session, 'F2311', orderCancelPreOrderTrin(session, options)),
+26
View File
@@ -23,6 +23,7 @@ import type {
MarketCode,
MarketIndex,
NewsList,
Order,
OrderId,
OrderKind,
OrderList,
@@ -36,6 +37,7 @@ import type {
Ranking,
ThemeId,
ThemeInvestmentList,
TradeRecordList,
TradeSide,
Watchlist,
} from '../types'
@@ -116,6 +118,22 @@ export type OrderInquiryOptions = PagingOptions &
status?: OrderStatus
}
export type OrderDetailOptions = {
/** Order number shown in order inquiry. */
orderNumber?: string
/** Order ID from order inquiry. For US stocks this is often `orderSubNo`. */
orderId?: OrderId
/** Issue code used to fetch the related security and quote details. */
issueCode?: IssueCode
/** Market code used to fetch the related security and quote details. */
market: MarketCode
}
export type TradeRecordInquiryOptions = OrderInquiryOptions & {
/** Filters trade records by account type. */
accountType?: AccountType
}
export type BoardOptions = IssueOptions & {
/** Account type used when requesting board-order information. */
accountType?: AccountType
@@ -384,6 +402,10 @@ export type OrderCancelOptions = {
orderNumber: string
/** Original order ID shown in order inquiry. */
orderId?: OrderId
/** Original issue code shown in order inquiry. */
issueCode?: IssueCode
/** Original market code shown in order inquiry. */
market?: MarketCode
/** Original trade ID code. Defaults to cash stock when omitted. */
tradeId?: string
/** Additional cancel flag used by the mobile MTS route. */
@@ -667,6 +689,10 @@ export interface SbiClientMethodOrderInquiry {
executionsToday(options?: OrderInquiryOptions): Promise<OrderList>
/** Fetches open or recently active orders. */
open(options?: OrderInquiryOptions): Promise<OrderList>
/** Fetches a detailed order record. Currently implemented for US stock orders. */
detail(options: OrderDetailOptions): Promise<Order>
/** Fetches trade records. Currently implemented for US stock trades. */
tradeRecords(options: TradeRecordInquiryOptions): Promise<TradeRecordList>
}
export interface SbiClientMethodCashOrder {
+632 -38
View File
@@ -16,23 +16,32 @@ import type {
SbiSession,
SignedTextValue,
StockOrderPreOrder,
TradeRecord,
TradeRecordList,
TradeSide,
} from '../types'
import type {
BoardOptions,
CashOrderOptions,
CashOrderPreOrderOptions,
CashPositionOptions,
IssueChartOptions,
IssueOptions,
IssueSearchOptions,
OrderCancelOptions,
OrderCorrectionOptions,
OrderDetailOptions,
OrderInquiryOptions,
PlaceCashOrderOptions,
PlaceOrderCancelOptions,
PlaceOrderCorrectionOptions,
TradeRecordInquiryOptions,
} from './types'
import { requireUsMarket } from '../markets'
const COUNTRY_US = 'US'
const DEFAULT_CHART_COUNT = 120
const DEFAULT_US_INQUIRY_LOOKBACK_DAYS = 90
const US_CHART_INTERVALS: Record<ChartPeriod, Record<number, string>> = {
minute: { 1: '1', 5: '2', 10: '3', 15: '4' },
day: { 1: '7' },
@@ -127,7 +136,10 @@ export const createUsStockAdapter = (session: SbiSession) => ({
quote,
}
},
positions: async (): Promise<CashPositionList> => fetchUsCashPositions(session),
positions: async (options?: CashPositionOptions): Promise<CashPositionList> =>
fetchUsCashPositions(session, options),
positionsDetail: async (options?: CashPositionOptions): Promise<CashPositionList> =>
fetchUsCashPositionDetail(session, options),
unrealized: async () => {
const positions = await fetchUsCashPositions(session)
return {
@@ -139,6 +151,10 @@ export const createUsStockAdapter = (session: SbiSession) => ({
},
orders: async (options?: OrderInquiryOptions): Promise<OrderList> =>
fetchUsOrders(session, options),
orderDetail: async (options: OrderDetailOptions): Promise<Order> =>
fetchUsOrderDetail(session, options),
tradeRecords: async (options: TradeRecordInquiryOptions): Promise<TradeRecordList> =>
fetchUsTradeRecords(session, options),
preOrder: async (options: CashOrderPreOrderOptions): Promise<StockOrderPreOrder> => {
requireUsMarket(options.market, 'orders.cash.preOrder')
const data = await callUsGraphql(session, 'GetOrderCreatingInitialData', ORDER_INITIAL_DATA, {
@@ -204,11 +220,108 @@ export const createUsStockAdapter = (session: SbiSession) => ({
message: stringAt(data, ['createForeignStockOrder', 'message']),
}
},
estimateCorrection: async (options: OrderCorrectionOptions): Promise<OrderPreview> => {
requireUsOrderCorrectionOptions(options, 'orders.cash.estimateCorrection')
const order = await resolveUsOrderForAction(session, options)
const input = usOrderCorrectionInput(options, order)
const market = requireUsOrderMarket(order, 'orders.cash.estimateCorrection')
const data = await callUsGraphql(
session,
'GetOrderUpdatingInitialData',
ORDER_UPDATE_INITIAL_DATA,
{
countryCode: COUNTRY_US,
securitiesCode: order.issue.code,
baseDate: undefined,
orderSubNo: input.orderSubNo,
rics: [usRic(order.issue.code, market)],
},
)
const init = objectAt(data, ['getForeignStockUpdatedOrderInitialization'])
const initOrder = objectAt(init, ['order'])
return {
issue: order.issue,
side: order.side,
quantity: numberAt(initOrder, ['orderQuantity']) ?? order.quantity ?? options.quantity,
price: usd(stringAt(initOrder, ['orderPrice']) ?? String(options.price ?? '')),
estimatedAmount: usd(stringAt(initOrder, ['frnNetAmount'])),
commission: usd(stringAt(initOrder, ['frnCommissionAmount'])),
tax: usd(stringAt(initOrder, ['frnCommissionCtax'])),
warnings: [],
confirmationId: input.orderSubNo,
correction: usOrderCorrectionPreOrderFromOrder(order, init),
}
},
estimateCorrectionConfirm: async (options: OrderCorrectionOptions): Promise<OrderPreview> => {
requireUsOrderCorrectionOptions(options, 'orders.cash.estimateCorrectionConfirm')
const order = await resolveUsOrderForAction(session, options)
const input = usOrderCorrectionInput(options, order)
const data = await callUsGraphql(
session,
'ConfirmOrderUpdating',
CONFIRM_ORDER_UPDATE,
{ input: { order: input } },
{ tradePassword: requireUsTradePassword(session, 'orders.cash.estimateCorrectionConfirm') },
)
return orderUpdatePreviewFromConfirmation(data, order, options)
},
placeCorrection: async (options: PlaceOrderCorrectionOptions): Promise<OrderReceipt> => {
requireUsOrderCorrectionOptions(options, 'orders.cash.placeCorrection')
if (options.allowTrading !== true) {
throw new Error('orders.cash.placeCorrection requires allowTrading: true')
}
const order = await resolveUsOrderForAction(session, options)
const input = usOrderCorrectionInput(options, order)
const data = await callUsGraphql(
session,
'SubmitOrderUpdating',
SUBMIT_ORDER_UPDATE,
{ input: { order: input } },
{ tradePassword: requireUsTradePassword(session, 'orders.cash.placeCorrection') },
)
const updatedOrder = objectAt(data, ['updateForeignStockOrder', 'order'])
return orderReceiptFromUsOrder(updatedOrder, 'updateForeignStockOrder')
},
estimateCancel: async (options: OrderCancelOptions): Promise<OrderPreview> => {
const order = await resolveUsOrderForAction(session, options)
const orderSubNo = requireUsOrderSubNo(order, options, 'orders.cash.estimateCancel')
await callUsGraphql(session, 'ConfirmCancelOrderInitialization', CONFIRM_CANCEL_ORDER, {
input: { orderSubNo },
})
return {
issue: order.issue,
side: order.side,
quantity: order.unexecutedQuantity ?? order.quantity ?? undefined,
price: order.price,
estimatedAmount: usd(stringAt(order, ['frnNetAmount'])),
commission: usd(''),
tax: usd(''),
warnings: [],
confirmationId: orderSubNo,
correction: usOrderCorrectionPreOrderFromOrder(order),
}
},
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 order = await resolveUsOrderForAction(session, options)
const orderSubNo = requireUsOrderSubNo(order, options, 'orders.cash.placeCancel')
await callUsGraphql(session, 'ConfirmCancelOrderInitialization', CONFIRM_CANCEL_ORDER, {
input: { orderSubNo },
})
const data = await callUsGraphql(
session,
'SubmitOrderCancelling',
SUBMIT_ORDER_CANCEL,
{ input: { orderSubNo } },
{
tradePassword:
options.tradePassword ?? requireUsTradePassword(session, 'orders.cash.placeCancel'),
},
)
const deletedOrder = objectAt(data, ['deleteForeignStockOrder', 'order'])
return orderReceiptFromUsOrder(deletedOrder, 'deleteForeignStockOrder')
},
})
@@ -224,39 +337,75 @@ const fetchStockDetail = async (session: SbiSession, options: IssueOptions, meth
return { stock, marketPrice }
}
const fetchUsCashPositions = async (session: SbiSession): Promise<CashPositionList> => {
const fetchUsCashPositions = async (
session: SbiSession,
options?: CashPositionOptions,
): 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'])),
}
})
const positions = balances
.map((balance) => cashPositionFromUsBalance(balance))
.filter((position) => {
if (options?.issueCode && position.issue.code !== options.issueCode) return false
if (options?.market && position.issue.market !== options.market) return false
if (options?.accountType && position.accountType !== options.accountType) return false
return true
})
const limited = limitList(positions, options)
return {
positions,
positions: limited,
totalCount: positions.length,
totalMarketValue: sumAmounts(positions.map((position) => position.marketValue)),
totalProfitLoss: sumSigned(positions.map((position) => position.profitLoss)),
totalMarketValue: sumAmounts(limited.map((position) => position.marketValue)),
totalProfitLoss: sumSigned(limited.map((position) => position.profitLoss)),
}
}
const fetchUsCashPositionDetail = async (
session: SbiSession,
options?: CashPositionOptions,
): Promise<CashPositionList> => {
if (options?.market) requireUsMarket(options.market, 'account.positions.cashDetail')
if (!options?.issueCode) {
throw new Error('account.positions.cashDetail requires issueCode for US stock positions')
}
const baseList = await fetchUsCashPositions(session, options)
const base = baseList.positions[0]
if (!base) {
throw new Error('account.positions.cashDetail could not find the requested US stock position')
}
const data = await callUsGraphql(
session,
'GetSecuritiesBalanceDetail',
SECURITIES_BALANCE_DETAIL,
{
inputSecuritiesBalance: {
productCode: 'FOREIGN_STOCK',
countryCode: COUNTRY_US,
currencyCode: 'USD',
specificAccountCode: usSpecificAccountCode(base.accountType),
securitiesCode: base.issue.code,
},
inputExchangeRate: { currencyPair: 'USDJPY' },
inputStockSecurities: {
countryCode: COUNTRY_US,
securitiesCode: base.issue.code,
},
},
)
const balance = objectAt(data, ['getSecuritiesBalance'])
if (!balance) {
throw new Error('account.positions.cashDetail returned no US stock balance detail')
}
const position = cashPositionFromUsBalance(balance, base.issue.market)
return {
positions: [position],
totalCount: 1,
totalMarketValue: position.marketValue,
totalProfitLoss: position.profitLoss,
}
}
@@ -264,10 +413,21 @@ const fetchUsOrders = async (
session: SbiSession,
options?: OrderInquiryOptions,
): Promise<OrderList> => {
const dateRange = usInquiryDateRange(options)
const data = await callUsGraphql(session, 'GetOrderList', ORDER_LIST, {
input: { countryCode: COUNTRY_US, page: { pageNum: 1, pageSize: options?.limit ?? 999 } },
input: {
countryCode: COUNTRY_US,
securitiesCode: options?.issueCode,
orderDateFrom: dateRange.from,
orderDateTo: dateRange.to,
orderDateType: 'ORDER_INPUT_DATE',
page: { pageNum: options?.index ?? 1, pageSize: options?.limit ?? 999 },
},
})
const orders = arrayAt(data, ['listForeignStockOrders', 'orderDecodes']).map(orderFromGraphql)
const orders = arrayAt(data, ['listForeignStockOrders', 'orderDecodes']).map((order) =>
orderFromGraphql(order),
)
const list = objectAt(data, ['listForeignStockOrders'])
return {
orders: orders.filter((order) => {
if (options?.issueCode && order.issue.code !== options.issueCode) return false
@@ -275,6 +435,65 @@ const fetchUsOrders = async (
if (options?.status && order.status !== options.status) return false
return true
}),
hasMore: booleanAt(list, ['page', 'hasNextPage']),
}
}
const fetchUsOrderDetail = async (
session: SbiSession,
options: OrderDetailOptions,
): Promise<Order> => {
requireUsMarket(options.market, 'orders.inquiry.detail')
const base = await resolveUsOrderForAction(session, options)
const orderNo = base.orderNumber ?? options.orderNumber
if (!orderNo) throw new Error('orders.inquiry.detail requires orderNumber for US stock orders')
const data = await callUsGraphql(session, 'GetOrderDetail', ORDER_DETAIL, {
input: { orderNo },
inputStockSecurities: {
countryCode: COUNTRY_US,
securitiesCode: base.issue.code,
},
inputStockPrice: {
countryCode: COUNTRY_US,
rics: [usRic(base.issue.code, requireUsOrderMarket(base, 'orders.inquiry.detail'))],
},
})
const detail = objectAt(data, ['getForeignStockOrderDetail', 'orderDetail'])
const order = objectAt(detail, ['order'])
if (!order) throw new Error('orders.inquiry.detail returned no US stock order detail')
return orderFromGraphql(order, {
issue: base.issue,
cancelable: booleanAt(detail, ['cancelable']),
correctable: booleanAt(detail, ['correctable']),
})
}
const fetchUsTradeRecords = async (
session: SbiSession,
options: TradeRecordInquiryOptions,
): Promise<TradeRecordList> => {
if (options.market) requireUsMarket(options.market, 'orders.inquiry.tradeRecords')
const dateRange = usInquiryDateRange(options)
const data = await callUsGraphql(session, 'GetTradeRecordList', TRADE_RECORD_LIST, {
input: {
productCode: 'FOREIGN_STOCK',
countryCode: COUNTRY_US,
securitiesCode: options.issueCode,
specificAccountCode: usSpecificAccountCode(options.accountType),
tradeHistoryType: 'TRADE_RECORD',
searchDateType: 'TRADE_DATE_BASE',
searchDateFrom: dateRange.from,
searchDateTo: dateRange.to,
page: { pageNum: options.index ?? 1, pageSize: options.limit ?? 999 },
},
})
const list = objectAt(data, ['listTradeRecords'])
const records = arrayAt(list, ['tradeRecords']).map((record) =>
tradeRecordFromGraphql(record, options.market),
)
return {
records,
hasMore: booleanAt(list, ['page', 'hasNextPage']),
}
}
@@ -450,16 +669,23 @@ const orderPreviewFromConfirmation = (
}
}
const orderFromGraphql = (value: unknown): Order => {
const market = usMarketFromGraphql(objectAt(value, ['market']))
const orderFromGraphql = (
value: unknown,
extra: { issue?: Order['issue']; cancelable?: boolean; correctable?: boolean } = {},
): Order => {
const market = extra.issue?.market ?? usMarketFromGraphql(objectAt(value, ['market']))
const securities = objectAt(value, ['securities'])
const orderNo = stringAt(value, ['orderNo'])
const orderSubNo = stringAt(value, ['orderSubNo'])
return {
id: stringAt(value, ['orderNo']) ?? stringAt(value, ['orderSubNo']) ?? '',
id: orderSubNo ?? orderNo ?? '',
issue: {
code: stringAt(securities, ['securitiesCode']) ?? '',
code: extra.issue?.code ?? stringAt(securities, ['securitiesCode']) ?? '',
market,
name:
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
extra.issue?.name ??
stringAt(securities, ['securitiesName']) ??
stringAt(securities, ['securitiesShortName']),
},
side: stringAt(value, ['buySellCode']) === 'SELL' ? 'sell' : 'buy',
status: mapOrderStatus(stringAt(value, ['orderStatus'])),
@@ -470,7 +696,204 @@ const orderFromGraphql = (value: unknown): Order => {
price: usd(stringAt(value, ['orderPrice'])),
executedPrice: usd(stringAt(value, ['executionAveragePrice'])),
orderedAt: stringAt(value, ['orderInputDatetime']),
orderNumber: stringAt(value, ['orderNo']),
expiresAt: stringAt(value, ['expiredDatetime']) ?? stringAt(value, ['orderTerm']),
orderNumber: orderNo,
orderSubNo,
accountType: mapUsSpecificAccount(stringAt(value, ['specificAccountCode'])),
depositType: mapUsSpecificAccount(stringAt(value, ['specificAccountCode'])),
cancelable: extra.cancelable,
correctable: extra.correctable,
}
}
const cashPositionFromUsBalance = (balance: unknown, fallbackMarket?: MarketCode): CashPosition => {
const securities = objectAt(balance, ['securities'])
const evaluation = objectAt(balance, ['evaluationProfitLoss'])
const market = usMarketFromGraphql(objectAt(balance, ['market']), fallbackMarket)
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'])),
}
}
const resolveUsOrderForAction = async (
session: SbiSession,
options: {
orderNumber?: string
orderId?: string
issueCode?: string
market?: MarketCode
},
): Promise<Order> => {
if (options.market) requireUsMarket(options.market, 'US stock order action')
const orders = await fetchUsOrders(session, {
issueCode: options.issueCode,
market: options.market,
limit: 999,
})
const order = orders.orders.find((candidate) => {
const ids = [candidate.id, candidate.orderNumber, candidate.orderSubNo].filter(Boolean)
return (
(options.orderId != null && ids.includes(options.orderId)) ||
(options.orderNumber != null && ids.includes(options.orderNumber))
)
})
if (!order) {
throw new Error('US stock order action could not find the requested order in order inquiry')
}
return order
}
const requireUsOrderSubNo = (
order: Order,
options: { orderId?: string; orderNumber?: string },
methodName: string,
) => {
const orderSubNo = order.orderSubNo ?? order.id ?? options.orderId ?? options.orderNumber
if (!orderSubNo) throw new Error(`${methodName} requires US stock orderSubNo`)
return orderSubNo
}
const requireUsOrderMarket = (order: Order, methodName: string) => {
if (!order.issue.market) throw new Error(`${methodName} requires US stock order market`)
requireUsMarket(order.issue.market, methodName)
return order.issue.market
}
const requireUsOrderCorrectionOptions = (options: OrderCorrectionOptions, methodName: string) => {
if (options.market) requireUsMarket(options.market, methodName)
if (options.orderMethod && options.orderMethod !== 'normal') {
throw new Error(`${methodName} does not support stop/OCO/IFD correction for US stocks`)
}
if (options.secondaryPriceCondition || options.secondaryPrice || options.ifdPriceCondition) {
throw new Error(`${methodName} does not support OCO/IFD correction for US stocks`)
}
if (
options.triggerZone ||
options.triggerPrice ||
options.ifdOrderMethod ||
options.ifdTriggerZone
) {
throw new Error(`${methodName} does not support stop correction for US stocks`)
}
if (!Number.isFinite(options.quantity) || options.quantity == null || options.quantity <= 0) {
throw new Error(`${methodName} requires quantity for US stock correction`)
}
const priceCondition = options.priceCondition ?? 'limit'
if (priceCondition !== 'market' && priceCondition !== 'limit') {
throw new Error(`${methodName} supports only market or limit correction for US stocks`)
}
if (priceCondition === 'limit' && options.price == null) {
throw new Error(`${methodName} requires price for limit US stock correction`)
}
if (priceCondition === 'market' && options.price != null) {
throw new Error(`${methodName} cannot specify price for market US stock correction`)
}
}
const usOrderCorrectionInput = (options: OrderCorrectionOptions, order: Order) => {
const priceCondition = options.priceCondition ?? 'limit'
const orderSubNo = requireUsOrderSubNo(order, options, 'orders.cash.correction')
return {
orderSubNo,
countryCode: COUNTRY_US,
orderQuantity: String(options.quantity),
orderPriceKindCode: priceCondition === 'market' ? 'MARKET' : 'LIMIT',
orderPrice: priceCondition === 'market' ? undefined : String(options.price),
stopPrice: undefined,
}
}
const usOrderCorrectionPreOrderFromOrder = (order: Order, init?: Record<string, unknown>) => ({
issue: order.issue,
details: [],
orderNumber: order.orderNumber,
orderId: order.orderSubNo ?? order.id,
status: order.status,
statusText: order.statusText,
quantity: order.unexecutedQuantity ?? order.quantity,
price: order.price?.value,
priceAmount: order.price,
priceSteps: arrayAt(init, ['tickSizes']).map((tick) => ({
from: usd(stringAt(tick, ['tickSize']) ?? stringAt(tick, ['basePriceFrom'])),
to: usd(stringAt(tick, ['basePriceTo'])),
})),
marketName: order.issue.market,
})
const orderUpdatePreviewFromConfirmation = (
data: Record<string, unknown>,
order: Order,
options: OrderCorrectionOptions,
): OrderPreview => {
const confirmation = objectAt(data, ['confirmForeignStockUpdatedOrder'])
const confirmedOrder = objectAt(confirmation, ['order'])
return {
issue: order.issue,
side: order.side,
quantity: options.quantity,
price: usd(stringAt(confirmedOrder, ['orderPrice']) ?? String(options.price ?? '')),
estimatedAmount: usd(stringAt(confirmedOrder, ['frnNetAmount'])),
commission: usd(stringAt(confirmedOrder, ['frnCommissionAmount'])),
tax: usd(stringAt(confirmedOrder, ['frnCommissionCtax'])),
warnings: stringArrayAt(confirmation, ['warningStatuses']),
confirmationId: stringAt(confirmedOrder, ['orderSubNo']) ?? order.orderSubNo,
correction: usOrderCorrectionPreOrderFromOrder(order),
}
}
const orderReceiptFromUsOrder = (order: unknown, sourceName: string): OrderReceipt => {
if (!order) throw new Error(`${sourceName} returned no US stock order`)
return {
accepted: true,
orderId: stringAt(order, ['orderSubNo']) ?? stringAt(order, ['orderNo']),
acceptedAt: stringAt(order, ['orderInputDatetime']),
message: stringAt(order, ['orderStatus']) ?? sourceName,
}
}
const tradeRecordFromGraphql = (
value: unknown,
fallbackMarket: MarketCode = 'XNAS',
): TradeRecord => {
const securities = objectAt(value, ['securities'])
const code = stringAt(securities, ['securitiesCode']) ?? ''
const tradeDate = stringAt(value, ['tradeDate'])
const typeCode = stringAt(value, ['tradeRecordTypeCode'])
return {
id: [code, tradeDate, typeCode, stringAt(value, ['valueDate'])].filter(Boolean).join(':'),
issue: {
code,
market: fallbackMarket,
name:
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
},
tradeRecordTypeCode: typeCode,
tradeCurrencyCode: stringAt(value, ['tradeCurrencyCode']),
listedSecuritiesStatus: stringAt(value, ['listedSecuritiesStatus']),
orderPriceKindCode: stringAt(value, ['orderPriceKindCode']),
accountType: mapUsSpecificAccount(stringAt(value, ['specificAccountCode'])),
settlementCurrencyCode: stringAt(value, ['settlementCurrencyCode']),
amount: usd(stringAt(value, ['amount'])),
quantity: numberAt(value, ['quantity']),
price: usd(stringAt(value, ['price'])),
tradeDate,
valueDate: stringAt(value, ['valueDate']),
marginCloseLimitType: stringAt(value, ['marginCloseLimitType']),
}
}
@@ -549,6 +972,35 @@ const normalizeUsOrderDate = (value: string) => {
return normalized
}
const usInquiryDateRange = (options?: { from?: string; to?: string }) => {
const to = normalizeUsOptionalDate(options?.to) ?? formatUsDate(new Date())
const from =
normalizeUsOptionalDate(options?.from) ??
formatUsDate(addDays(parseUsDate(to), -DEFAULT_US_INQUIRY_LOOKBACK_DAYS))
return { from, to }
}
const parseUsDate = (value: string) => {
const date = new Date(`${value}T00:00:00.000Z`)
if (Number.isNaN(date.getTime())) {
throw new Error('US stock inquiry date must be yyyy-MM-dd or yyyyMMdd')
}
return date
}
const addDays = (date: Date, days: number) => {
const next = new Date(date)
next.setUTCDate(next.getUTCDate() + days)
return next
}
const formatUsDate = (date: Date) => {
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const usGraphqlMarketCode = (market: MarketCode) => {
switch (market) {
case 'XNAS':
@@ -705,6 +1157,20 @@ const stringArrayAt = (source: unknown, path: string[]) =>
const numberAt = (source: unknown, path: string[]) => parseNumber(stringAt(source, path))
const booleanAt = (source: unknown, path: string[]) => {
const value = valueAt(source, path)
return typeof value === 'boolean' ? value : undefined
}
const limitList = <T>(items: T[], options?: { index?: number; limit?: number }) => {
const start = Math.max((options?.index ?? 1) - 1, 0)
const end = options?.limit ? start + options.limit : undefined
return items.slice(start, end)
}
const normalizeUsOptionalDate = (value: string | undefined) =>
value ? normalizeUsOrderDate(value) : undefined
const valueAt = (source: unknown, path: string[]): unknown =>
path.reduce<unknown>((current, key) => {
if (!current || typeof current !== 'object') return undefined
@@ -801,10 +1267,138 @@ query GetOrderList($input: Input_fstock_order_ListForeignStockOrdersRequest) {
listForeignStockOrders(input: $input) {
orderDecodes {
orderNo orderSubNo buySellCode orderStatus orderQuantity unexecutedQuantity executionQuantity
orderPrice executionAveragePrice orderInputDatetime
orderPrice executionAveragePrice orderInputDatetime orderTerm specificAccountCode
securities { securitiesCode securitiesName securitiesShortName }
market { marketCode marketName marketShortName }
}
page { hasNextPage pageNum pageSize }
}
}`
const ORDER_UPDATE_INITIAL_DATA = `
query GetOrderUpdatingInitialData($countryCode: common_enums_CountryEnum_Country, $securitiesCode: String, $baseDate: String, $orderSubNo: String, $rics: [String]) {
getForeignStockUpdatedOrderInitialization(input: { orderSubNo: $orderSubNo }) {
priceRangeLimitMax priceRangeLimitMin priceRangeNoLimit
tickSizes { basePriceFrom basePriceTo tickSize }
orderPriceKindCodes buyPossibleAmount
order {
orderNo orderSubNo buySellCode orderStatus orderQuantity unexecutedQuantity executionQuantity
orderPrice executionAveragePrice orderInputDatetime orderTerm frnNetAmount frnCommissionAmount frnCommissionCtax
securities { securitiesCode securitiesName securitiesShortName }
market { marketCode marketName marketShortName }
}
}
getForeignStockSecurities(input: { countryCode: $countryCode securitiesCode: $securitiesCode }) {
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
market { marketCode marketName marketShortName timeZone }
}
checkJrNisaRestrictedReleaseBefore(input: { baseDate: $baseDate }) { restrictedReleaseBefore }
listMarketPrices(input: { countryCode: $countryCode rics: $rics }) {
marketPrices { ask askSize bid bidSize price { last lastDatetime change changePercent } }
}
checkJrNisaOpen { opened }
}`
const CONFIRM_ORDER_UPDATE = `
query ConfirmOrderUpdating($input: Input_fstock_order_ConfirmForeignStockUpdatedOrderRequest) {
confirmForeignStockUpdatedOrder(input: $input) {
buyPossibleAmount nisaBuyLimitAmount warningStatuses
order {
orderNo orderSubNo buySellCode orderStatus orderQuantity unexecutedQuantity executionQuantity
orderPrice executionAveragePrice orderInputDatetime orderTerm frnNetAmount frnCommissionAmount frnCommissionCtax
securities { securitiesCode securitiesName securitiesShortName }
market { marketCode marketName marketShortName }
}
}
checkJrNisaOpen { opened }
}`
const SUBMIT_ORDER_UPDATE = `
mutation SubmitOrderUpdating($input: Input_fstock_order_UpdateForeignStockOrderRequest) {
updateForeignStockOrder(input: $input) {
warningStatuses
order {
orderNo orderSubNo buySellCode orderStatus orderQuantity unexecutedQuantity executionQuantity
orderPrice executionAveragePrice orderInputDatetime orderTerm
securities { securitiesCode securitiesName securitiesShortName }
market { marketCode marketName marketShortName }
}
}
}`
const CONFIRM_CANCEL_ORDER = `
query ConfirmCancelOrderInitialization($input: Input_fstock_order_GetForeignStockDeletedOrderInitializationRequest) {
getForeignStockDeletedOrderInitialization(input: $input) { __typename }
}`
const SUBMIT_ORDER_CANCEL = `
mutation SubmitOrderCancelling($input: Input_fstock_order_DeleteForeignStockOrderRequest) {
deleteForeignStockOrder(input: $input) {
order {
orderNo orderSubNo buySellCode orderStatus orderQuantity unexecutedQuantity executionQuantity
orderPrice executionAveragePrice orderInputDatetime orderTerm
securities { securitiesCode securitiesName securitiesShortName }
market { marketCode marketName marketShortName }
}
}
}`
const ORDER_DETAIL = `
query GetOrderDetail($input: Input_fstock_order_GetForeignStockOrderDetailRequest, $inputStockSecurities: Input_fstock_securities_GetForeignStockSecuritiesRequest, $inputStockPrice: Input_information_marketprice_ListMarketPricesRequest) {
getForeignStockOrderDetail(input: $input) {
orderDetail {
cancelable correctable
order {
orderNo orderSubNo buySellCode specificAccountCode orderQuantity unexecutedQuantity
orderPriceKindCode stopPrice trailingStopAmount noLimitPrice orderLimitCode orderTerm
settlementMethodCode settlementCurrencyCode orderPrice executionAveragePrice orderInputDatetime
executionDatetime orderStatus tradeCurrencyCode expiredDatetime executionQuantity frnTradeDate
tradeDate valueDate frnCommissionAmount commissionAmount frnCommissionCtax commissionCtax
frnLocalCharge localCharge frnLocalNetAmount localNetAmount frnGrossAmount grossAmount
frnNetAmount netAmount executionNetAmount exchangeRate executionStatus workingStatus stockTradeType
market { marketCode marketName marketShortName timeZone }
}
}
}
getForeignStockSecurities(input: $inputStockSecurities) {
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
market { marketCode marketName marketShortName timeZone }
}
listMarketPrices(input: $inputStockPrice) {
marketPrices { ask askSize bid bidSize price { last lastDatetime change changePercent } }
}
checkJrNisaOpen { opened }
}`
const TRADE_RECORD_LIST = `
query GetTradeRecordList($input: Input_account_ListTradeRecordsRequest) {
listTradeRecords(input: $input) {
tradeRecords {
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
tradeRecordTypeCode tradeCurrencyCode listedSecuritiesStatus orderPriceKindCode
specificAccountCode settlementCurrencyCode amount quantity price tradeDate valueDate marginCloseLimitType
}
page { hasNextPage }
}
checkJrNisaOpen { opened }
}`
const SECURITIES_BALANCE_DETAIL = `
query GetSecuritiesBalanceDetail($inputSecuritiesBalance: Input_account_balance_GetSecuritiesBalanceRequest, $inputExchangeRate: Input_exchange_master_GetExchangeRateRequest, $inputStockSecurities: Input_fstock_securities_GetForeignStockSecuritiesRequest) {
getSecuritiesBalance(input: $inputSecuritiesBalance) {
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
listedSecuritiesStatus stockPrice { last lastDatetime tickArrow change changePercent open high low prevClose volume }
evaluationProfitLoss {
frnEvaluationAmount frnEvaluationProfitLoss evaluationAmount evaluationProfitLoss evaluationProfitLossPercent frnEvaluationProfitLossPercent
}
specificAccountCode securitiesQuantity sellFixedOrderQuantity frnAcquisitionPrice acquisitionPrice
frnAcquisitionAmount acquisitionAmount countryCode currencyCode attentionSecurities
market { marketCode marketName marketShortName timeZone }
}
getExchangeRate(input: $inputExchangeRate) { rateDatetime exchangeRate }
getForeignStockSecurities(input: $inputStockSecurities) {
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
market { marketCode marketName marketShortName timeZone }
}
checkJrNisaOpen { opened }
}`
+27 -1
View File
@@ -180,7 +180,7 @@ export type SbiTradeAuthenticationOptions = {
}
export type IssueCode = string
export type DomesticMarketCode = 'XTKS'
export type DomesticMarketCode = 'XTKS' | 'XNGO' | 'XFKA' | 'XSAP'
export type SKabuMarketCode = 'STK'
export type UsStockMarketCode = 'XNAS' | 'XNYS' | 'ARCX'
export type MarketCode = DomesticMarketCode | SKabuMarketCode | UsStockMarketCode
@@ -647,9 +647,12 @@ export type Order = {
orderedAt?: string
expiresAt?: string
orderNumber?: string
orderSubNo?: string
tradeId?: string
exchangeCode?: string
accountInformation?: string
cancelable?: boolean
correctable?: boolean
}
export type OrderList = {
@@ -658,6 +661,29 @@ export type OrderList = {
error?: SbiMethodError
}
export type TradeRecord = {
id: string
issue: IssueRef
tradeRecordTypeCode?: string
tradeCurrencyCode?: string
listedSecuritiesStatus?: string
orderPriceKindCode?: string
accountType?: AccountType
settlementCurrencyCode?: string
amount?: CurrencyAmount
quantity?: number | null
price?: CurrencyAmount
tradeDate?: string
valueDate?: string
marginCloseLimitType?: string
}
export type TradeRecordList = {
records: TradeRecord[]
hasMore?: boolean
error?: SbiMethodError
}
export type OrderCorrectionPreOrderDetail = {
exchangeName?: string
marketLoanKbn?: string
+15 -3
View File
@@ -8,7 +8,13 @@ const runtimeDefaults = {
CSBIE_RP_ID: '127.0.0.1',
}
const optionalUrls = ['SBI_AUTH_BASE_URL', 'SBI_MTS_BASE_URL', 'CSBIE_ORIGIN', 'CSBIE_CORS_ORIGIN']
const optionalUrls = [
'SBI_AUTH_BASE_URL',
'SBI_MTS_BASE_URL',
'SBI_IZANAGI_BASE_URL',
'CSBIE_ORIGIN',
'CSBIE_CORS_ORIGIN',
]
const present = (key: string) => Boolean(process.env[key]?.trim())
@@ -44,8 +50,14 @@ if (process.env.CSBIE_KEYRING_BACKEND === 'sqlite' && !present('CSBIE_KEYRING_SE
messages.push('Set CSBIE_KEYRING_SECRET when CSBIE_KEYRING_BACKEND=sqlite')
}
if (!present('SBI_AUTH_BASE_URL') || !present('SBI_MTS_BASE_URL')) {
messages.push('Set SBI_AUTH_BASE_URL and SBI_MTS_BASE_URL before connecting an SBI session')
if (
!present('SBI_AUTH_BASE_URL') ||
!present('SBI_MTS_BASE_URL') ||
!present('SBI_IZANAGI_BASE_URL')
) {
messages.push(
'Set SBI_AUTH_BASE_URL, SBI_MTS_BASE_URL, and SBI_IZANAGI_BASE_URL before connecting an SBI session with domestic issue search',
)
}
if (messages.length === 0) {