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 * as z from 'zod/v4'
import type { AppBindings, AuthContext } from '../context' import type { AppBindings, AuthContext } from '../context'
import { sbiPasskeys } from '../db/schema' import { sbiPasskeys } from '../db/schema'
import { import { invokeSbiMethod, isCashOrderMethod, isTradingMethod, type RpcMethod } from '../rpc/methods'
invokeSbiMethod,
isCashOrderMethod,
isTradingMethod,
RPC_METHODS,
type RpcMethod,
} from '../rpc/methods'
import { connectSbi } from '../rpc/sbi-session' import { connectSbi } from '../rpc/sbi-session'
import type { StoredSbiPasskeySecret } from './admin' import type { StoredSbiPasskeySecret } from './admin'
import { import {
@@ -33,8 +27,6 @@ const requireAuthenticated = (auth: AuthContext) => {
if (!auth.authenticated) throw new Error('unauthorized') if (!auth.authenticated) throw new Error('unauthorized')
} }
const toolNameForMethod = (method: RpcMethod) => `csbie_sbi_${method.replaceAll('.', '_')}`
const ORDER_SUBMIT_TICKET_TTL_MS = 10 * 60 * 1000 const ORDER_SUBMIT_TICKET_TTL_MS = 10 * 60 * 1000
type OrderSubmitTicket = { type OrderSubmitTicket = {
@@ -81,10 +73,6 @@ const orderSubmitMethodByEstimateMethod = {
const submitMethodForEstimateMethod = (method: RpcMethod) => const submitMethodForEstimateMethod = (method: RpcMethod) =>
orderSubmitMethodByEstimateMethod[method as keyof typeof orderSubmitMethodByEstimateMethod] 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) => { const orderSubmitParams = (value: unknown, confirmationId?: string) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return { allowTrading: true } if (!value || typeof value !== 'object' || Array.isArray(value)) return { allowTrading: true }
return { return {
@@ -179,6 +167,17 @@ const orderInquiryOptionsSchema = z.object({
status: z.enum(['open', 'executed', 'cancelled', 'expired', 'rejected', 'unknown']).optional(), 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({ const boardOptionsSchema = issueOptionsSchema.extend({
accountType: accountTypeSchema.optional(), accountType: accountTypeSchema.optional(),
side: z side: z
@@ -337,6 +336,8 @@ const placeOrderCorrectionSchema = orderCorrectionSchema.extend({
const orderCancelSchema = z.object({ const orderCancelSchema = z.object({
orderNumber: z.string().min(1).describe('Order number shown in order inquiry'), orderNumber: z.string().min(1).describe('Order number shown in order inquiry'),
orderId: orderIdSchema.optional().describe('Original order id 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'), tradeId: z.string().min(1).optional().describe('Original trade id code'),
cancelType: z.string().min(1).optional().describe('Additional cancel flag'), cancelType: z.string().min(1).optional().describe('Additional cancel flag'),
}) })
@@ -628,8 +629,10 @@ const methodParamSchemas = {
'market.ranking.sbi': undefined, 'market.ranking.sbi': undefined,
'news.list': undefined, 'news.list': undefined,
'watchlist.list': undefined, 'watchlist.list': undefined,
'orders.inquiry.detail': orderDetailSchema,
'orders.inquiry.executionsToday': orderInquiryOptionsSchema.optional(), 'orders.inquiry.executionsToday': orderInquiryOptionsSchema.optional(),
'orders.inquiry.open': orderInquiryOptionsSchema.optional(), 'orders.inquiry.open': orderInquiryOptionsSchema.optional(),
'orders.inquiry.tradeRecords': tradeRecordInquiryOptionsSchema,
'orders.cash.preOrder': cashOrderPreOrderSchema, 'orders.cash.preOrder': cashOrderPreOrderSchema,
'orders.cash.estimate': cashOrderSchema, 'orders.cash.estimate': cashOrderSchema,
'orders.cash.place': placeCashOrderSchema, 'orders.cash.place': placeCashOrderSchema,
@@ -665,6 +668,84 @@ const methodParamSchemas = {
'orders.exchange.place': placeExchangeOrderSchema, 'orders.exchange.place': placeExchangeOrderSchema,
} satisfies Record<RpcMethod, z.ZodType | undefined> } 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 createMcpServer = (c: Context<AppBindings>) => {
const db = c.get('db') const db = c.get('db')
const config = c.get('config') const config = c.get('config')
@@ -675,59 +756,33 @@ const createMcpServer = (c: Context<AppBindings>) => {
version: '0.1.0', version: '0.1.0',
}) })
server.registerTool( const listPasskeys = async () => {
'csbie_sbi_methods', const rows = await db
{ .select({
title: 'List SBI RPC Methods', id: sbiPasskeys.id,
description: 'List SBI client methods exposed through CSBIE.', label: sbiPasskeys.label,
inputSchema: {}, keyringAccount: sbiPasskeys.keyringAccount,
}, createdAt: sbiPasskeys.createdAt,
async () => { updatedAt: sbiPasskeys.updatedAt,
requireAuthenticated(auth)
return textResult({
methods: mcpExposedRpcMethods,
submitTool: 'csbie_sbi_submit_order',
}) })
}, .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( const invokeCheckedSbiMethod = async (method: RpcMethod, passkeyId: string, params: unknown) => {
'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) => {
requireAuthenticated(auth) requireAuthenticated(auth)
if (auth.type === 'apiKey') { if (auth.type === 'apiKey') {
@@ -767,41 +822,105 @@ const createMcpServer = (c: Context<AppBindings>) => {
} }
const client = await connectSbi(db, config, passkeyId) const client = await connectSbi(db, config, passkeyId)
const result = await invokeSbiMethod(client, method, params) return invokeSbiMethod(client, method, params)
const submitMethod = submitMethodForEstimateMethod(method) }
if (!submitMethod) return textResult(result)
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() cleanupExpiredOrderSubmitTickets()
const uuid = randomUUID() const uuid = randomUUID()
const expiresAt = new Date(Date.now() + ORDER_SUBMIT_TICKET_TTL_MS) const expiresAt = new Date(Date.now() + ORDER_SUBMIT_TICKET_TTL_MS)
const confirmationId = confirmationIdFromPreview(result) const confirmationId = confirmationIdFromPreview(preview)
orderSubmitTickets.set(uuid, { orderSubmitTickets.set(uuid, {
passkeyId, passkeyId,
estimateMethod: method, estimateMethod,
submitMethod, submitMethod,
params, params,
confirmationId, confirmationId,
authKey: authKey(auth), authKey: authKey(auth),
expiresAt, expiresAt,
}) })
return textResult({
preview: result, return {
submit: { uuid,
uuid, expiresAt: expiresAt.toISOString(),
tool: 'csbie_sbi_submit_order', preview,
expiresAt: expiresAt.toISOString(), confirmTool: 'confirm-request',
}, }
})
} }
server.registerTool( server.registerTool(
'csbie_sbi_submit_order', 'csbie-get',
{ {
title: 'Submit Estimated SBI Order', title: 'Get SBI Data',
description: 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: { 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 }) => { async ({ uuid }) => {
@@ -809,40 +928,21 @@ const createMcpServer = (c: Context<AppBindings>) => {
cleanupExpiredOrderSubmitTickets() cleanupExpiredOrderSubmitTickets()
const ticket = orderSubmitTickets.get(uuid) 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)) { 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) orderSubmitTickets.delete(uuid)
return callSbiMethod( const result = await invokeCheckedSbiMethod(
ticket.submitMethod, ticket.submitMethod,
ticket.passkeyId, ticket.passkeyId,
orderSubmitParams(ticket.params, ticket.confirmationId), 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 return server
} }
+2
View File
@@ -31,8 +31,10 @@ export const RPC_METHODS = [
'market.ranking.sbi', 'market.ranking.sbi',
'news.list', 'news.list',
'watchlist.list', 'watchlist.list',
'orders.inquiry.detail',
'orders.inquiry.executionsToday', 'orders.inquiry.executionsToday',
'orders.inquiry.open', 'orders.inquiry.open',
'orders.inquiry.tradeRecords',
'orders.cash.preOrder', 'orders.cash.preOrder',
'orders.cash.estimate', 'orders.cash.estimate',
'orders.cash.place', 'orders.cash.place',
+12
View File
@@ -105,6 +105,7 @@ const {
holdingsMarketValue, holdingsMarketValue,
totalProfitLoss, totalProfitLoss,
totalProfitLossRate, totalProfitLossRate,
marketIndexes,
orders, orders,
cancelingOrderKey, cancelingOrderKey,
orderHistoryLoaded, orderHistoryLoaded,
@@ -146,6 +147,11 @@ const {
askPlaceOrder, askPlaceOrder,
placeCashOrder, placeCashOrder,
cancelOrder, cancelOrder,
loadOrderDetail,
loadTradeRecords,
loadPositionDetail,
estimateOrderCorrection,
placeOrderCorrection,
downloadCsv, downloadCsv,
openTradeForStock, openTradeForStock,
openTradeForPosition, openTradeForPosition,
@@ -232,6 +238,7 @@ onMounted(async () => {
:holdings-market-value="holdingsMarketValue" :holdings-market-value="holdingsMarketValue"
:total-profit-loss="totalProfitLoss" :total-profit-loss="totalProfitLoss"
:total-profit-loss-rate="totalProfitLossRate" :total-profit-loss-rate="totalProfitLossRate"
:market-indexes="marketIndexes"
:stock-asset-ratio="stockAssetRatio" :stock-asset-ratio="stockAssetRatio"
:cash-asset-ratio="cashAssetRatio" :cash-asset-ratio="cashAssetRatio"
:positions="positions" :positions="positions"
@@ -241,6 +248,7 @@ onMounted(async () => {
:connected="connected" :connected="connected"
:order-history-loaded="orderHistoryLoaded" :order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice" :order-history-notice="orderHistoryNotice"
:load-position-detail="loadPositionDetail"
@connect="connect" @connect="connect"
@open-position=" @open-position="
(code) => (code) =>
@@ -304,6 +312,10 @@ onMounted(async () => {
:canceling-order-key="cancelingOrderKey" :canceling-order-key="cancelingOrderKey"
:order-history-loaded="orderHistoryLoaded" :order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice" :order-history-notice="orderHistoryNotice"
:load-order-detail="loadOrderDetail"
:load-trade-records="loadTradeRecords"
:estimate-order-correction="estimateOrderCorrection"
:place-order-correction="placeOrderCorrection"
@refresh="loadTradingData" @refresh="loadTradingData"
@cancel="cancelOrder" @cancel="cancelOrder"
/> />
@@ -39,8 +39,10 @@ const rpcMethods = [
'market.ranking.sbi', 'market.ranking.sbi',
'news.list', 'news.list',
'watchlist.list', 'watchlist.list',
'orders.inquiry.detail',
'orders.inquiry.executionsToday', 'orders.inquiry.executionsToday',
'orders.inquiry.open', 'orders.inquiry.open',
'orders.inquiry.tradeRecords',
'orders.cash.estimate', 'orders.cash.estimate',
'orders.cash.place', 'orders.cash.place',
'orders.cash.estimateCorrection', 'orders.cash.estimateCorrection',
+2 -2
View File
@@ -8,14 +8,14 @@ export type PageMeta = {
} }
export const pageMeta: Record<RouteName, PageMeta> = { export const pageMeta: Record<RouteName, PageMeta> = {
portfolio: { title: 'ポートフォリオ', icon: WalletCards }, portfolio: { title: 'ホーム', icon: WalletCards },
trade: { title: '取引', icon: ArrowLeftRight }, trade: { title: '取引', icon: ArrowLeftRight },
history: { title: '取引履歴', icon: History }, history: { title: '取引履歴', icon: History },
settings: { title: '設定', icon: Settings }, settings: { title: '設定', icon: Settings },
} }
export const sidebarItems: Array<{ name: RouteName; label: string; icon: Component }> = [ 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: 'trade', label: '取引', icon: pageMeta.trade.icon },
{ name: 'settings', label: '設定', icon: pageMeta.settings.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: 'auto' },
{ label: '東証', value: 'XTKS' }, { label: '東証', value: 'XTKS' },
{ label: '名証', value: 'XNGO' },
{ label: '福証', value: 'XFKA' },
{ label: '札証', value: 'XSAP' },
{ label: 'NASDAQ', value: 'XNAS' }, { label: 'NASDAQ', value: 'XNAS' },
{ label: 'NYSE', value: 'XNYS' }, { label: 'NYSE', value: 'XNYS' },
{ label: 'NYSE Arca', value: 'ARCX' }, { 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) => { export const tradeRouteIdFromStockId = (id: string) => {
const separator = id.indexOf(':') const separator = id.indexOf(':')
@@ -1,21 +1,40 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { AnimatePresence } from 'motion-v' 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 Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue' import UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue' import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui' 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' import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
defineProps<{ const props = defineProps<{
orders: OrderRow[] orders: OrderRow[]
connected: boolean connected: boolean
dataLoading: boolean dataLoading: boolean
cancelingOrderKey: string cancelingOrderKey: string
orderHistoryLoaded: boolean orderHistoryLoaded: boolean
orderHistoryNotice: string 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<{ const emit = defineEmits<{
@@ -25,11 +44,51 @@ const emit = defineEmits<{
const cancelCandidate = ref<OrderRow | null>(null) const cancelCandidate = ref<OrderRow | null>(null)
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX']) const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
const isUsMarket = (market: string) => usMarkets.has(market)
const canCancel = (order: OrderRow) => 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) => const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
orderHistoryKey(order) === cancelingOrderKey orderHistoryKey(order) === cancelingOrderKey
const cancelTitle = computed(() => cancelCandidate.value?.stock ?? '') 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) => { const askCancel = (order: OrderRow) => {
if (!canCancel(order)) return if (!canCancel(order)) return
@@ -41,27 +100,108 @@ const confirmCancel = () => {
emit('cancel', cancelCandidate.value) emit('cancel', cancelCandidate.value)
cancelCandidate.value = null 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> </script>
<template> <template>
<section :class="ui.panel"> <section :class="ui.panel">
<div :class="ui.panelHead"> <div :class="ui.panelHead">
<h2>取引履歴</h2> <h2>取引履歴</h2>
<button <span :class="ui.rowActions">
:class="ui.ghostButton" <button
type="button" :class="ui.ghostButton"
:disabled="!connected || dataLoading" type="button"
@click="emit('refresh')" :disabled="!connected || tradeRecordsLoading"
> @click="loadTradeRecords"
<RefreshCw class="h-4 w-4" aria-hidden="true" /> >
履歴を更新 <Spinner v-if="tradeRecordsLoading" size="sm" />
</button> <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> </div>
<p v-if="orderActionError" :class="ui.dialogNote">{{ orderActionError }}</p>
<div :class="ui.list"> <div :class="ui.list">
<div v-for="order in orders" :key="order.id" :class="ui.orderRow"> <div v-for="order in orders" :key="order.id" :class="ui.orderRow">
<span> <span>
<strong>{{ order.stock }}</strong> <strong>{{ order.stock }}</strong>
<small>{{ order.date }} / 通常単元</small> <small>{{ order.code }} / {{ order.market }} / {{ order.date }}</small>
</span> </span>
<span :class="order.side === 'buy' ? ui.positive : ui.negative"> <span :class="order.side === 'buy' ? ui.positive : ui.negative">
{{ order.side === 'buy' ? '購入' : '売却' }} {{ order.side === 'buy' ? '購入' : '売却' }}
@@ -71,17 +211,43 @@ const confirmCancel = () => {
<span :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]"> <span :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]">
{{ order.status }} {{ order.status }}
</span> </span>
<button <span :class="ui.rowActions">
v-if="canCancel(order)" <button
:class="ui.ghostButton" v-if="canShowUsDetail(order)"
type="button" :class="ui.ghostButton"
:disabled="Boolean(cancelingOrderKey)" class="min-h-8 px-3 text-xs"
@click="askCancel(order)" type="button"
> :disabled="Boolean(orderDetailLoadingKey)"
<Spinner v-if="isCanceling(order, cancelingOrderKey)" size="sm" /> @click="showOrderDetail(order)"
<Ban v-else class="h-4 w-4" aria-hidden="true" /> >
{{ isCanceling(order, cancelingOrderKey) ? '取消中' : '取消' }} <Spinner v-if="orderDetailLoadingKey === orderActionKey(order)" size="sm" />
</button> <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>
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']"> <div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
<Spinner /> <Spinner />
@@ -98,6 +264,195 @@ const confirmCancel = () => {
</div> </div>
</section> </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> <AnimatePresence>
<UiModal <UiModal
v-if="cancelCandidate" v-if="cancelCandidate"
@@ -1,28 +1,30 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { AnimatePresence } from 'motion-v' 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 Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue' import UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue' import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui' import { ui } from '../../styles/ui'
import type { OrderRow, Position } from '../../types/trading' import type { MarketIndex, OrderRow, Position } from '../../types/trading'
import { import {
currency, currency,
currencyForMarket, currencyForMarket,
number as formatNumber,
signedCurrency, signedCurrency,
signedCurrencyForMarket, signedCurrencyForMarket,
signedPercent, signedPercent,
} from '../../utils/format' } from '../../utils/format'
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data' import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
defineProps<{ const props = defineProps<{
showPortfolioSpinner: boolean showPortfolioSpinner: boolean
totalAssetValue: number totalAssetValue: number
buyingPower: number buyingPower: number
holdingsMarketValue: number holdingsMarketValue: number
totalProfitLoss: number totalProfitLoss: number
totalProfitLossRate: number totalProfitLossRate: number
marketIndexes: MarketIndex[]
stockAssetRatio: number stockAssetRatio: number
cashAssetRatio: number cashAssetRatio: number
positions: Position[] positions: Position[]
@@ -32,6 +34,7 @@ defineProps<{
connected: boolean connected: boolean
orderHistoryLoaded: boolean orderHistoryLoaded: boolean
orderHistoryNotice: string orderHistoryNotice: string
loadPositionDetail: (position: Position) => Promise<Position>
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -41,12 +44,47 @@ const emit = defineEmits<{
}>() }>()
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX']) const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
const isUsMarket = (market: string) => usMarkets.has(market)
const canCancel = (order: OrderRow) => 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) => const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
orderHistoryKey(order) === cancelingOrderKey orderHistoryKey(order) === cancelingOrderKey
const cancelCandidate = ref<OrderRow | null>(null) 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 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) => { const askCancel = (order: OrderRow) => {
if (!canCancel(order)) return if (!canCancel(order)) return
@@ -69,6 +107,13 @@ const confirmCancel = () => {
<template v-if="!showPortfolioSpinner">{{ currency(totalAssetValue) }}</template> <template v-if="!showPortfolioSpinner">{{ currency(totalAssetValue) }}</template>
<Spinner v-else size="lg" /> <Spinner v-else size="lg" />
</strong> </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"> <small :class="ui.assetOverviewSubtext">
余力: 余力:
<template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template> <template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template>
@@ -120,17 +165,6 @@ const confirmCancel = () => {
</div> </div>
</article> </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"> <article :class="ui.holdingsPanel">
<div :class="ui.panelHead"> <div :class="ui.panelHead">
<h2>保有銘柄</h2> <h2>保有銘柄</h2>
@@ -151,19 +185,18 @@ const confirmCancel = () => {
<span>数量</span> <span>数量</span>
<span>評価額</span> <span>評価額</span>
<span>評価損益</span> <span>評価損益</span>
<span>操作</span>
</div> </div>
<div v-if="positions.length" :class="ui.holdingsRows"> <div v-if="positions.length" :class="ui.holdingsRows">
<button <div v-for="position in positions" :key="positionKey(position)" :class="ui.holdingRow">
v-for="position in positions" <button
:key="position.code" class="grid gap-1 text-left text-[#e3e3e9]"
:class="ui.holdingRow" type="button"
type="button" @click="emit('openPosition', position.code)"
@click="emit('openPosition', position.code)" >
>
<span class="grid gap-1">
<strong>{{ position.name }}</strong> <strong>{{ position.name }}</strong>
<small>{{ position.code }}</small> <small>{{ position.code }}</small>
</span> </button>
<b :class="ui.typePill">{{ <b :class="ui.typePill">{{
position.type ?? (position.quantity >= 100 ? '単元' : 'S株') position.type ?? (position.quantity >= 100 ? '単元' : 'S株')
}}</b> }}</b>
@@ -178,12 +211,27 @@ const confirmCancel = () => {
<strong>{{ signedCurrencyForMarket(position.profitLoss, position.market) }}</strong> <strong>{{ signedCurrencyForMarket(position.profitLoss, position.market) }}</strong>
<small>{{ signedPercent(position.profitLossRate) }}</small> <small>{{ signedPercent(position.profitLossRate) }}</small>
</span> </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>
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]"> <div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
<Spinner /> <Spinner />
</div> </div>
<p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると保有銘柄を表示します</p> <p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると保有銘柄を表示します</p>
<p v-if="positionDetailError" :class="ui.dialogNote">{{ positionDetailError }}</p>
</div> </div>
</article> </article>
@@ -234,8 +282,85 @@ const confirmCancel = () => {
</p> </p>
</div> </div>
</article> </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> </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> <AnimatePresence>
<UiModal <UiModal
v-if="cancelCandidate" v-if="cancelCandidate"
@@ -1,19 +1,29 @@
import type { import type {
ChartNotice, ChartNotice,
MarketIndex,
OrderDetail,
OrderPreview, OrderPreview,
OrderRow, OrderRow,
Position, Position,
RealtimePricePoint, RealtimePricePoint,
Stock, Stock,
TradeRecordRow,
} from '../../types/trading' } from '../../types/trading'
import { currency } from '../../utils/format' import { currency } from '../../utils/format'
export type RecordLike = Record<string, unknown> export type RecordLike = Record<string, unknown>
type IssueLike = {
code: string
market: string
name: string
searchText?: string
}
export const emptyStock: Stock = { export const emptyStock: Stock = {
code: '', code: '',
name: '未選択', name: '未選択',
symbol: '', symbol: '',
searchText: '',
country: '日本', country: '日本',
market: '', market: '',
sector: '', sector: '',
@@ -75,13 +85,27 @@ const countryFromMarket = (market: string) =>
export const issueFrom = (value: unknown) => { export const issueFrom = (value: unknown) => {
const issue = asRecord(value) 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 { return {
code: textValue(issue.code, textValue(issue.issueCode)), code,
market: textValue(issue.market, textValue(issue.marketCode, '')), market,
name: textValue( name,
issue.name, searchText,
textValue(issue.issueName, textValue(issue.stockName, textValue(issue.displayName))),
),
} }
} }
@@ -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) } 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, code: issue.code,
name: issue.name || issue.code, name: issue.name || issue.code,
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '', symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
searchText: issue.searchText,
country: countryFromMarket(issue.market), country: countryFromMarket(issue.market),
market: issue.market, market: issue.market,
sector: '', sector: '',
@@ -146,10 +171,16 @@ export const stockFromPosition = (position: Position): Stock => {
} }
} }
export const stockFromBoard = ( const accountTypeLabels: Record<string, string> = {
value: unknown, specific: '特定',
fallbackIssue?: ReturnType<typeof issueFrom>, general: '一般',
): Stock => { growthInvestment: 'NISA成長投資枠',
nisa: 'NISA',
}
const accountTypeLabel = (value: string) => accountTypeLabels[value] ?? value
export const stockFromBoard = (value: unknown, fallbackIssue?: IssueLike): Stock => {
const board = asRecord(value) const board = asRecord(value)
const quoteRecord = asRecord(board.quote) const quoteRecord = asRecord(board.quote)
const boardIssue = issueFrom(board.issue) const boardIssue = issueFrom(board.issue)
@@ -158,6 +189,7 @@ export const stockFromBoard = (
code: quoteIssue.code || boardIssue.code || fallbackIssue?.code || '', code: quoteIssue.code || boardIssue.code || fallbackIssue?.code || '',
market: quoteIssue.market || boardIssue.market || fallbackIssue?.market || '', market: quoteIssue.market || boardIssue.market || fallbackIssue?.market || '',
name: quoteIssue.name || boardIssue.name || fallbackIssue?.name || '', name: quoteIssue.name || boardIssue.name || fallbackIssue?.name || '',
searchText: quoteIssue.searchText || boardIssue.searchText || fallbackIssue?.searchText || '',
} }
const quote = quoteFrom(quoteRecord) const quote = quoteFrom(quoteRecord)
const codeAsNumber = Number(issue.code) const codeAsNumber = Number(issue.code)
@@ -169,6 +201,7 @@ export const stockFromBoard = (
code: issue.code, code: issue.code,
name: issue.name, name: issue.name,
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '', symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
searchText: issue.searchText,
country: countryFromMarket(issue.market), country: countryFromMarket(issue.market),
market: issue.market, market: issue.market,
sector: '', 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) => const zonedPartsFormatter = (timeZone: string) =>
new Intl.DateTimeFormat('en-US', { new Intl.DateTimeFormat('en-US', {
timeZone, timeZone,
@@ -357,16 +437,20 @@ export const positionFromApi = (value: unknown): Position | null => {
const costBasis = avgPrice * quantity const costBasis = avgPrice * quantity
const profitLossRate = const profitLossRate =
numberValue(item.profitLossRate) || (costBasis ? (profitLoss / costBasis) * 100 : 0) numberValue(item.profitLossRate) || (costBasis ? (profitLoss / costBasis) * 100 : 0)
const accountType = textValue(item.accountType, textValue(item.depositType))
return { return {
code: issue.code, code: issue.code,
name: issue.name, name: issue.name,
market: issue.market, market: issue.market,
quantity, quantity,
avgPrice, avgPrice,
currentPrice: nullableNumberValue(item.currentPrice),
marketValue: numberValue(item.marketValue ?? item.valuationPrice), marketValue: numberValue(item.marketValue ?? item.valuationPrice),
profitLoss, profitLoss,
profitLossRate, 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)) : (nullableNumberValue(item.price) ?? nullableNumberValue(item.executedPrice))
return { return {
id: textValue(item.id, textValue(item.orderNumber, `${issue.code}-${item.orderedAt ?? ''}`)), id: textValue(item.id, textValue(item.orderNumber, `${issue.code}-${item.orderedAt ?? ''}`)),
code: issue.code,
date: textValue(item.orderedAt, textValue(item.expiresAt)), date: textValue(item.orderedAt, textValue(item.expiresAt)),
stock: issue.name || issue.code, stock: issue.name || issue.code,
market: issue.market, market: issue.market,
side, side,
kind, kind,
quantity, quantity,
unexecutedQuantity,
executedQuantity,
price, price,
status, status,
orderNumber: textValue(item.orderNumber), orderNumber: textValue(item.orderNumber),
orderSubNo: textValue(item.orderSubNo),
tradeId: textValue(item.tradeId), 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, CashOrderTerm,
CashOrderTriggerZone, CashOrderTriggerZone,
JsonRpcResponse, JsonRpcResponse,
MarketIndex,
OrderDetail,
OrderKind, OrderKind,
OrderPreview, OrderPreview,
OrderRow, OrderRow,
@@ -24,6 +26,7 @@ import type {
RealtimePricePoint, RealtimePricePoint,
RpcMessage, RpcMessage,
Stock, Stock,
TradeRecordRow,
TradeSide, TradeSide,
} from '../../types/trading' } from '../../types/trading'
import { import {
@@ -34,7 +37,9 @@ import {
isOrderPreview, isOrderPreview,
issueFrom, issueFrom,
marketDateKey, marketDateKey,
marketIndexFromApi,
numberValue, numberValue,
orderDetailFromApi,
orderFromApi, orderFromApi,
orderHistoryKey, orderHistoryKey,
orderHistoryResultNotice, orderHistoryResultNotice,
@@ -44,6 +49,7 @@ import {
stockFromBoard, stockFromBoard,
stockFromIssue, stockFromIssue,
stockFromPosition, stockFromPosition,
tradeRecordFromApi,
textValue, textValue,
type RecordLike, type RecordLike,
} from './trading-data' } from './trading-data'
@@ -157,6 +163,8 @@ const displayApkOrderTermDate = (value: string) => {
} }
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX']) 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 parseApkExchangeMarkets = (value: string) => {
const markets: CashOrderMarket[] = [] const markets: CashOrderMarket[] = []
@@ -212,6 +220,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const holdingsMarketValue = ref(0) const holdingsMarketValue = ref(0)
const totalProfitLoss = ref(0) const totalProfitLoss = ref(0)
const totalProfitLossRate = ref(0) const totalProfitLossRate = ref(0)
const marketIndexes = ref<MarketIndex[]>([])
const orders = ref<OrderRow[]>([]) const orders = ref<OrderRow[]>([])
const cancelingOrderKey = ref('') const cancelingOrderKey = ref('')
const orderHistoryLoaded = ref(false) const orderHistoryLoaded = ref(false)
@@ -522,7 +531,8 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const matchesQuery = const matchesQuery =
stock.name.toLowerCase().includes(query) || stock.name.toLowerCase().includes(query) ||
stock.code.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) return matchesQuery && matchesFilters(stock)
}) })
: viewedStocks.value.filter(matchesFilters) : viewedStocks.value.filter(matchesFilters)
@@ -753,6 +763,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
price: stock.price || current?.price || 0, price: stock.price || current?.price || 0,
change: stock.change || current?.change || 0, change: stock.change || current?.change || 0,
changeAmount: stock.changeAmount || current?.changeAmount || 0, changeAmount: stock.changeAmount || current?.changeAmount || 0,
searchText: stock.searchText || current?.searchText || '',
history: stock.price ? stock.history : (current?.history ?? stock.history), history: stock.price ? stock.history : (current?.history ?? stock.history),
box: stock.price ? stock.box : (current?.box ?? stock.box), box: stock.price ? stock.box : (current?.box ?? stock.box),
}) })
@@ -771,16 +782,19 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const loadOrderHistoryFromSdk = async () => { const loadOrderHistoryFromSdk = async () => {
orderHistoryLoaded.value = false orderHistoryLoaded.value = false
orderHistoryNotice.value = '' orderHistoryNotice.value = ''
const [openOrdersResult, executionsTodayResult] = await Promise.allSettled([ const orderResults = await Promise.allSettled([
rpcCallOptional<RecordLike>('orders.inquiry.open'), rpcCallOptional<RecordLike>('orders.inquiry.open'),
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday'), rpcCallOptional<RecordLike>('orders.inquiry.executionsToday'),
rpcCallOptional<RecordLike>('orders.inquiry.open', { market: 'XNAS' }), ...usOrderMarkets.flatMap((market) => [
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday', { market: 'XNAS' }), rpcCallOptional<RecordLike>('orders.inquiry.open', { market }),
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday', { market }),
]),
]) ])
const [openOrdersResult, executionsTodayResult] = orderResults
if (openOrdersResult.status === 'rejected' && executionsTodayResult.status === 'rejected') { if (openOrdersResult.status === 'rejected' && executionsTodayResult.status === 'rejected') {
throw openOrdersResult.reason throw openOrdersResult.reason
} }
const nextOrders = fulfilledValues([openOrdersResult, executionsTodayResult]) const nextOrders = fulfilledValues(orderResults)
.flatMap((orderList) => asArray(orderList.orders)) .flatMap((orderList) => asArray(orderList.orders))
.map(orderFromApi) .map(orderFromApi)
.filter((order): order is OrderRow => Boolean(order)) .filter((order): order is OrderRow => Boolean(order))
@@ -790,9 +804,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
orders.value = [...deduped.values()] orders.value = [...deduped.values()]
orderHistoryLoaded.value = true orderHistoryLoaded.value = true
if (!orders.value.length) { if (!orders.value.length) {
const notices = fulfilledValues([openOrdersResult, executionsTodayResult]) const notices = fulfilledValues(orderResults).map(orderHistoryResultNotice).filter(Boolean)
.map(orderHistoryResultNotice)
.filter(Boolean)
orderHistoryNotice.value = [...new Set(notices)].join(' / ') orderHistoryNotice.value = [...new Set(notices)].join(' / ')
} }
} }
@@ -822,11 +834,25 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const loadTradingData = async () => { const loadTradingData = async () => {
dataLoading.value = true dataLoading.value = true
try { try {
const [assetsResult, ...positionResults] = await Promise.allSettled([ const [assetsResult, indexResult, ...positionResults] = await Promise.allSettled([
rpcCallOptional<RecordLike>('account.assets.current', undefined, 20_000), 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', 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 cashPositionLists = fulfilledValues(positionResults)
const cashPositions = cashPositionLists[0] ?? {} const cashPositions = cashPositionLists[0] ?? {}
const nextPositions = cashPositionLists const nextPositions = cashPositionLists
@@ -835,13 +861,26 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
.filter((position): position is Position => Boolean(position)) .filter((position): position is Position => Boolean(position))
positions.value = nextPositions positions.value = nextPositions
mergeStocks(nextPositions.map(stockFromPosition)) mergeStocks(nextPositions.map(stockFromPosition))
const nextHoldingsMarketValue = numberValue( const summedHoldingsMarketValue = nextPositions.reduce(
cashPositions.totalMarketValue, (sum, position) => sum + position.marketValue,
nextPositions.reduce((sum, position) => sum + position.marketValue, 0), 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 holdingsMarketValue.value = nextHoldingsMarketValue
totalProfitLoss.value = numberValue(cashPositions.totalProfitLoss) totalProfitLoss.value =
totalProfitLossRate.value = numberValue(cashPositions.totalProfitLossRate) nextPositions.length > 0 ? summedProfitLoss : numberValue(cashPositions.totalProfitLoss)
totalProfitLossRate.value =
nextPositions.length > 0 && summedCostBasis
? (summedProfitLoss / summedCostBasis) * 100
: numberValue(cashPositions.totalProfitLossRate)
const hasAccountAssets = assetsResult.status === 'fulfilled' const hasAccountAssets = assetsResult.status === 'fulfilled'
if (hasAccountAssets) { 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 if (!connected.value || query.trim().length < 2) return
const marketsToSearch = const marketsToSearch =
marketFilter.value !== 'all' marketFilter.value !== 'all'
@@ -1019,7 +1058,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
: searchableMarkets.filter((market) => market !== 'auto') : searchableMarkets.filter((market) => market !== 'auto')
const results = await Promise.allSettled( const results = await Promise.allSettled(
marketsToSearch.map((market) => 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) const issues = fulfilledValues(results)
@@ -1121,6 +1160,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
orders.value = [ orders.value = [
{ {
id: textValue(receipt.orderId, `ord-${Date.now()}`), id: textValue(receipt.orderId, `ord-${Date.now()}`),
code: selectedStock.value.code,
date: textValue(receipt.acceptedAt, new Date().toLocaleString('ja-JP')), date: textValue(receipt.acceptedAt, new Date().toLocaleString('ja-JP')),
stock: selectedStock.value.name, stock: selectedStock.value.name,
market: selectedStock.value.market, market: selectedStock.value.market,
@@ -1147,7 +1187,9 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
try { try {
const params = { const params = {
orderNumber: order.orderNumber, orderNumber: order.orderNumber,
orderId: order.id, orderId: order.orderSubNo || order.id,
issueCode: order.code,
market: order.market,
tradeId: order.tradeId || undefined, tradeId: order.tradeId || undefined,
} }
await rpcCall('orders.cash.placeCancel', { 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 downloadCsv = () => {
const header = ['code', 'name', 'symbol', 'market', 'price'].join(',') const header = ['code', 'name', 'symbol', 'market', 'price'].join(',')
const rows = selectedStock.value.history.map((price) => const rows = selectedStock.value.history.map((price) =>
@@ -1284,7 +1405,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
searchTimer = setTimeout(async () => { searchTimer = setTimeout(async () => {
const requestId = ++searchRequestId const requestId = ++searchRequestId
try { try {
await searchIssues(query) await suggestIssues(query)
} catch (cause) { } catch (cause) {
reportDataError(errorMessage(cause, '銘柄検索に失敗しました'), cause) reportDataError(errorMessage(cause, '銘柄検索に失敗しました'), cause)
} finally { } finally {
@@ -1337,6 +1458,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
holdingsMarketValue, holdingsMarketValue,
totalProfitLoss, totalProfitLoss,
totalProfitLossRate, totalProfitLossRate,
marketIndexes,
orders, orders,
cancelingOrderKey, cancelingOrderKey,
orderHistoryLoaded, orderHistoryLoaded,
@@ -1379,6 +1501,11 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
askPlaceOrder, askPlaceOrder,
placeCashOrder, placeCashOrder,
cancelOrder, cancelOrder,
loadOrderDetail,
loadTradeRecords,
loadPositionDetail,
estimateOrderCorrection,
placeOrderCorrection,
downloadCsv, downloadCsv,
openTradeForStock, openTradeForStock,
openTradeForPosition, openTradeForPosition,
+14 -9
View File
@@ -24,12 +24,12 @@ export const ui = {
panelHead: 'flex flex-wrap items-center justify-between gap-3', panelHead: 'flex flex-wrap items-center justify-between gap-3',
eyebrow: 'mb-1 text-xs font-black uppercase text-[#9aa0a9]', eyebrow: 'mb-1 text-xs font-black uppercase text-[#9aa0a9]',
dashboardGrid: 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: 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', '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: 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', '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-2', assetOverviewHead: 'grid gap-3',
assetOverviewSubtext: 'text-sm font-semibold text-[#c3c7cf]', assetOverviewSubtext: 'text-sm font-semibold text-[#c3c7cf]',
assetBreakdownPanel: 'grid gap-3', assetBreakdownPanel: 'grid gap-3',
assetBreakdownTitle: 'text-xs font-extrabold text-[#9aa0a9]', assetBreakdownTitle: 'text-xs font-extrabold text-[#9aa0a9]',
@@ -52,19 +52,24 @@ export const ui = {
assetBreakdownAmount: 'text-sm font-black text-[#e3e3e9]', assetBreakdownAmount: 'text-sm font-black text-[#e3e3e9]',
assetBreakdownRatio: 'text-xs font-semibold text-[#8f949d]', assetBreakdownRatio: 'text-xs font-semibold text-[#8f949d]',
holdingsPanel: 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', 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', holdingsRows: 'grid min-h-0 flex-1 content-start overflow-y-auto',
holdingsHead: 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: 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]', typePill: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs text-[#d3e3fd]',
muted: 'text-[#8f949d]', muted: 'text-[#8f949d]',
portfolioHistory: 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', 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', 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', emptyState: 'flex flex-1 items-center justify-center py-8 text-center',
miniOrder: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3', miniOrder: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3',
tradeLayout: 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]', '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', list: 'grid gap-2',
orderRow: 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]', statusBadge: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs font-black text-[#d3e3fd]',
pendingBadge: 'bg-[#4a3720] text-[#ffddb3]', 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', apiLayout: 'grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.9fr)] lg:gap-7',
settingsLayout: 'grid 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]', 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', keyRow: 'grid gap-3 rounded-[24px] bg-[#111418] p-4',
profileRow: 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]', '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 TradeSide = 'buy' | 'sell'
export type OrderKind = 'standard' | 's' export type OrderKind = 'standard' | 's'
export type CashOrderAccountType = 'specific' | 'general' | 'growthInvestment' | 'nisa' 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 = export type CashOrderPriceCondition =
| 'limit' | 'limit'
| 'limitAtOpen' | 'limitAtOpen'
@@ -31,6 +40,11 @@ export type OrderPreview = {
} }
side: string side: string
quantity: number quantity: number
price?: {
value: number | null
text: string
currency?: string
}
warnings: string[] warnings: string[]
confirmationId?: string confirmationId?: string
message?: string message?: string
@@ -61,10 +75,24 @@ export type ChartNotice = {
detail?: string 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 = { export type Stock = {
code: string code: string
name: string name: string
symbol: string symbol: string
searchText?: string
country: string country: string
market: string market: string
sector: string sector: string
@@ -89,16 +117,45 @@ export type Stock = {
export type OrderRow = { export type OrderRow = {
id: string id: string
code: string
date: string date: string
stock: string stock: string
market: string market: string
side: TradeSide side: TradeSide
kind: OrderKind kind: OrderKind
quantity: number | null quantity: number | null
unexecutedQuantity?: number | null
executedQuantity?: number | null
price: number | null price: number | null
status: '注文中' | '約定済' | '取消済' status: '注文中' | '約定済' | '取消済'
orderNumber?: string orderNumber?: string
orderSubNo?: string
tradeId?: 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 = { export type Position = {
@@ -107,8 +164,10 @@ export type Position = {
market: string market: string
quantity: number quantity: number
avgPrice: number avgPrice: number
currentPrice?: number | null
marketValue: number marketValue: number
profitLoss: number profitLoss: number
profitLossRate: number profitLossRate: number
type?: string type?: string
accountType?: CashOrderAccountType | string
} }
+2
View File
@@ -37,6 +37,8 @@ export type {
OrderCorrectionPreOrder, OrderCorrectionPreOrder,
OrderCorrectionPreOrderDetail, OrderCorrectionPreOrderDetail,
OrderPreview, OrderPreview,
TradeRecord,
TradeRecordList,
StockOrderPreOrder, StockOrderPreOrder,
StockOrderPreOrderMarginTradeType, StockOrderPreOrderMarginTradeType,
StockOrderPreOrderPaymentLimit, StockOrderPreOrderPaymentLimit,
+3
View File
@@ -4,6 +4,9 @@ export type MarketRegion = 'domestic' | 'us'
const DOMESTIC_TO_MTS = { const DOMESTIC_TO_MTS = {
XTKS: 'TKY', XTKS: 'TKY',
XNGO: 'NGY',
XFKA: 'FKO',
XSAP: 'SPR',
} as const satisfies Record<string, string> } as const satisfies Record<string, string>
const MTS_TO_DOMESTIC = Object.fromEntries( const MTS_TO_DOMESTIC = Object.fromEntries(
+34 -14
View File
@@ -297,19 +297,21 @@ export const createMethodsFromSession = (session: SbiSession): SbiClientMethods
positions: { positions: {
cash: async (options) => cash: async (options) =>
options?.market && isUsMarket(options.market) options?.market && isUsMarket(options.market)
? usStock.positions() ? usStock.positions(options)
: parseCashPositions( : parseCashPositions(
await callMts(session, 'F2631', listAccountTrin(session, options)), await callMts(session, 'F2631', listAccountTrin(session, options)),
options, options,
), ),
cashDetail: async (options) => cashDetail: async (options) =>
parseCashPositions( options?.market && isUsMarket(options.market)
await callMts(session, 'F2632', listAccountTrin(session, options)), ? usStock.positionsDetail(options)
options, : parseCashPositions(
), await callMts(session, 'F2632', listAccountTrin(session, options)),
options,
),
cashForIssue: async (options) => { cashForIssue: async (options) => {
return isUsMarket(options.market) return isUsMarket(options.market)
? usStock.positions() ? usStock.positions(options)
: filterCashPositions( : filterCashPositions(
parseCashPositions( parseCashPositions(
await callMts(session, 'F2602', issuePositionTrin(session, options)), await callMts(session, 'F2602', issuePositionTrin(session, options)),
@@ -487,6 +489,18 @@ export const createMethodsFromSession = (session: SbiSession): SbiClientMethods
), ),
options, 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: { cash: {
preOrder: async (options) => preOrder: async (options) =>
@@ -528,22 +542,28 @@ export const createMethodsFromSession = (session: SbiSession): SbiClientMethods
) )
}, },
estimateCorrection: async (options) => estimateCorrection: async (options) =>
parseOrderCorrectionPreOrder( options.market && isUsMarket(options.market)
await callMts(session, 'F2301', orderCorrectionPreOrderTrin(session, options)), ? usStock.estimateCorrection(options)
cashCorrectionPreviewInput, : parseOrderCorrectionPreOrder(
), await callMts(session, 'F2301', orderCorrectionPreOrderTrin(session, options)),
cashCorrectionPreviewInput,
),
estimateCorrectionConfirm: async (options) => estimateCorrectionConfirm: async (options) =>
parseOrderPreview( options.market && isUsMarket(options.market)
await callMts(session, 'F2302', orderCorrectionSubmitTrin(session, options)), ? usStock.estimateCorrectionConfirm(options)
cashCorrectionPreviewInput, : parseOrderPreview(
), await callMts(session, 'F2302', orderCorrectionSubmitTrin(session, options)),
cashCorrectionPreviewInput,
),
placeCorrection: async (options) => { placeCorrection: async (options) => {
if (options.market && isUsMarket(options.market)) return usStock.placeCorrection(options)
assertTradingAllowed(options, 'orders.cash.placeCorrection') assertTradingAllowed(options, 'orders.cash.placeCorrection')
return parseOrderReceipt( return parseOrderReceipt(
await callMts(session, 'F2303', orderCorrectionSubmitTrin(session, options)), await callMts(session, 'F2303', orderCorrectionSubmitTrin(session, options)),
) )
}, },
estimateCancel: async (options) => { estimateCancel: async (options) => {
if (options.market && isUsMarket(options.market)) return usStock.estimateCancel(options)
assertOrderCancelOptions(options) assertOrderCancelOptions(options)
return parseOrderCorrectionPreOrder( return parseOrderCorrectionPreOrder(
await callMts(session, 'F2311', orderCancelPreOrderTrin(session, options)), await callMts(session, 'F2311', orderCancelPreOrderTrin(session, options)),
+26
View File
@@ -23,6 +23,7 @@ import type {
MarketCode, MarketCode,
MarketIndex, MarketIndex,
NewsList, NewsList,
Order,
OrderId, OrderId,
OrderKind, OrderKind,
OrderList, OrderList,
@@ -36,6 +37,7 @@ import type {
Ranking, Ranking,
ThemeId, ThemeId,
ThemeInvestmentList, ThemeInvestmentList,
TradeRecordList,
TradeSide, TradeSide,
Watchlist, Watchlist,
} from '../types' } from '../types'
@@ -116,6 +118,22 @@ export type OrderInquiryOptions = PagingOptions &
status?: OrderStatus 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 & { export type BoardOptions = IssueOptions & {
/** Account type used when requesting board-order information. */ /** Account type used when requesting board-order information. */
accountType?: AccountType accountType?: AccountType
@@ -384,6 +402,10 @@ export type OrderCancelOptions = {
orderNumber: string orderNumber: string
/** Original order ID shown in order inquiry. */ /** Original order ID shown in order inquiry. */
orderId?: OrderId 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. */ /** Original trade ID code. Defaults to cash stock when omitted. */
tradeId?: string tradeId?: string
/** Additional cancel flag used by the mobile MTS route. */ /** Additional cancel flag used by the mobile MTS route. */
@@ -667,6 +689,10 @@ export interface SbiClientMethodOrderInquiry {
executionsToday(options?: OrderInquiryOptions): Promise<OrderList> executionsToday(options?: OrderInquiryOptions): Promise<OrderList>
/** Fetches open or recently active orders. */ /** Fetches open or recently active orders. */
open(options?: OrderInquiryOptions): Promise<OrderList> 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 { export interface SbiClientMethodCashOrder {
+632 -38
View File
@@ -16,23 +16,32 @@ import type {
SbiSession, SbiSession,
SignedTextValue, SignedTextValue,
StockOrderPreOrder, StockOrderPreOrder,
TradeRecord,
TradeRecordList,
TradeSide, TradeSide,
} from '../types' } from '../types'
import type { import type {
BoardOptions, BoardOptions,
CashOrderOptions, CashOrderOptions,
CashOrderPreOrderOptions, CashOrderPreOrderOptions,
CashPositionOptions,
IssueChartOptions, IssueChartOptions,
IssueOptions, IssueOptions,
IssueSearchOptions, IssueSearchOptions,
OrderCancelOptions,
OrderCorrectionOptions,
OrderDetailOptions,
OrderInquiryOptions, OrderInquiryOptions,
PlaceCashOrderOptions, PlaceCashOrderOptions,
PlaceOrderCancelOptions, PlaceOrderCancelOptions,
PlaceOrderCorrectionOptions,
TradeRecordInquiryOptions,
} from './types' } from './types'
import { requireUsMarket } from '../markets' import { requireUsMarket } from '../markets'
const COUNTRY_US = 'US' const COUNTRY_US = 'US'
const DEFAULT_CHART_COUNT = 120 const DEFAULT_CHART_COUNT = 120
const DEFAULT_US_INQUIRY_LOOKBACK_DAYS = 90
const US_CHART_INTERVALS: Record<ChartPeriod, Record<number, string>> = { const US_CHART_INTERVALS: Record<ChartPeriod, Record<number, string>> = {
minute: { 1: '1', 5: '2', 10: '3', 15: '4' }, minute: { 1: '1', 5: '2', 10: '3', 15: '4' },
day: { 1: '7' }, day: { 1: '7' },
@@ -127,7 +136,10 @@ export const createUsStockAdapter = (session: SbiSession) => ({
quote, 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 () => { unrealized: async () => {
const positions = await fetchUsCashPositions(session) const positions = await fetchUsCashPositions(session)
return { return {
@@ -139,6 +151,10 @@ export const createUsStockAdapter = (session: SbiSession) => ({
}, },
orders: async (options?: OrderInquiryOptions): Promise<OrderList> => orders: async (options?: OrderInquiryOptions): Promise<OrderList> =>
fetchUsOrders(session, options), 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> => { preOrder: async (options: CashOrderPreOrderOptions): Promise<StockOrderPreOrder> => {
requireUsMarket(options.market, 'orders.cash.preOrder') requireUsMarket(options.market, 'orders.cash.preOrder')
const data = await callUsGraphql(session, 'GetOrderCreatingInitialData', ORDER_INITIAL_DATA, { const data = await callUsGraphql(session, 'GetOrderCreatingInitialData', ORDER_INITIAL_DATA, {
@@ -204,11 +220,108 @@ export const createUsStockAdapter = (session: SbiSession) => ({
message: stringAt(data, ['createForeignStockOrder', 'message']), 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> => { placeCancel: async (options: PlaceOrderCancelOptions): Promise<OrderReceipt> => {
if (options.allowTrading !== true) { if (options.allowTrading !== true) {
throw new Error('orders.cash.placeCancel requires 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 } 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, { const data = await callUsGraphql(session, 'GetSecuritiesBalanceList', SECURITIES_BALANCES, {
input: { countryCode: COUNTRY_US, page: { pageNum: 1, pageSize: 999 } }, input: { countryCode: COUNTRY_US, page: { pageNum: 1, pageSize: 999 } },
}) })
const balances = arrayAt(data, ['listSecuritiesBalances', 'securitiesBalances']) const balances = arrayAt(data, ['listSecuritiesBalances', 'securitiesBalances'])
const positions = balances.map((balance): CashPosition => { const positions = balances
const securities = objectAt(balance, ['securities']) .map((balance) => cashPositionFromUsBalance(balance))
const evaluation = objectAt(balance, ['evaluationProfitLoss']) .filter((position) => {
const market = usMarketFromGraphql(objectAt(balance, ['market'])) if (options?.issueCode && position.issue.code !== options.issueCode) return false
return { if (options?.market && position.issue.market !== options.market) return false
issue: { if (options?.accountType && position.accountType !== options.accountType) return false
code: stringAt(securities, ['securitiesCode']) ?? '', return true
market, })
name: const limited = limitList(positions, options)
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
},
accountType: mapUsSpecificAccount(stringAt(balance, ['specificAccountCode'])),
depositType: mapUsSpecificAccount(stringAt(balance, ['specificAccountCode'])),
quantity: numberAt(balance, ['securitiesQuantity']),
currentPrice: usd(stringAt(balance, ['stockPrice', 'last'])),
averagePrice: usd(stringAt(balance, ['frnAcquisitionPrice'])),
purchasePrice: usd(stringAt(balance, ['frnAcquisitionPrice'])),
marketValue: usd(stringAt(evaluation, ['frnEvaluationAmount'])),
valuationPrice: usd(stringAt(evaluation, ['frnEvaluationAmount'])),
profitLoss: signed(stringAt(evaluation, ['frnEvaluationProfitLoss'])),
profitLossRate: percent(stringAt(evaluation, ['frnEvaluationProfitLossPercent'])),
}
})
return { return {
positions, positions: limited,
totalCount: positions.length, totalCount: positions.length,
totalMarketValue: sumAmounts(positions.map((position) => position.marketValue)), totalMarketValue: sumAmounts(limited.map((position) => position.marketValue)),
totalProfitLoss: sumSigned(positions.map((position) => position.profitLoss)), 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, session: SbiSession,
options?: OrderInquiryOptions, options?: OrderInquiryOptions,
): Promise<OrderList> => { ): Promise<OrderList> => {
const dateRange = usInquiryDateRange(options)
const data = await callUsGraphql(session, 'GetOrderList', ORDER_LIST, { 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 { return {
orders: orders.filter((order) => { orders: orders.filter((order) => {
if (options?.issueCode && order.issue.code !== options.issueCode) return false 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 if (options?.status && order.status !== options.status) return false
return true 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 orderFromGraphql = (
const market = usMarketFromGraphql(objectAt(value, ['market'])) 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 securities = objectAt(value, ['securities'])
const orderNo = stringAt(value, ['orderNo'])
const orderSubNo = stringAt(value, ['orderSubNo'])
return { return {
id: stringAt(value, ['orderNo']) ?? stringAt(value, ['orderSubNo']) ?? '', id: orderSubNo ?? orderNo ?? '',
issue: { issue: {
code: stringAt(securities, ['securitiesCode']) ?? '', code: extra.issue?.code ?? stringAt(securities, ['securitiesCode']) ?? '',
market, market,
name: name:
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']), extra.issue?.name ??
stringAt(securities, ['securitiesName']) ??
stringAt(securities, ['securitiesShortName']),
}, },
side: stringAt(value, ['buySellCode']) === 'SELL' ? 'sell' : 'buy', side: stringAt(value, ['buySellCode']) === 'SELL' ? 'sell' : 'buy',
status: mapOrderStatus(stringAt(value, ['orderStatus'])), status: mapOrderStatus(stringAt(value, ['orderStatus'])),
@@ -470,7 +696,204 @@ const orderFromGraphql = (value: unknown): Order => {
price: usd(stringAt(value, ['orderPrice'])), price: usd(stringAt(value, ['orderPrice'])),
executedPrice: usd(stringAt(value, ['executionAveragePrice'])), executedPrice: usd(stringAt(value, ['executionAveragePrice'])),
orderedAt: stringAt(value, ['orderInputDatetime']), 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 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) => { const usGraphqlMarketCode = (market: MarketCode) => {
switch (market) { switch (market) {
case 'XNAS': case 'XNAS':
@@ -705,6 +1157,20 @@ const stringArrayAt = (source: unknown, path: string[]) =>
const numberAt = (source: unknown, path: string[]) => parseNumber(stringAt(source, path)) 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 => const valueAt = (source: unknown, path: string[]): unknown =>
path.reduce<unknown>((current, key) => { path.reduce<unknown>((current, key) => {
if (!current || typeof current !== 'object') return undefined if (!current || typeof current !== 'object') return undefined
@@ -801,10 +1267,138 @@ query GetOrderList($input: Input_fstock_order_ListForeignStockOrdersRequest) {
listForeignStockOrders(input: $input) { listForeignStockOrders(input: $input) {
orderDecodes { orderDecodes {
orderNo orderSubNo buySellCode orderStatus orderQuantity unexecutedQuantity executionQuantity orderNo orderSubNo buySellCode orderStatus orderQuantity unexecutedQuantity executionQuantity
orderPrice executionAveragePrice orderInputDatetime orderPrice executionAveragePrice orderInputDatetime orderTerm specificAccountCode
securities { securitiesCode securitiesName securitiesShortName } securities { securitiesCode securitiesName securitiesShortName }
market { marketCode marketName marketShortName } market { marketCode marketName marketShortName }
} }
page { hasNextPage pageNum pageSize } 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 IssueCode = string
export type DomesticMarketCode = 'XTKS' export type DomesticMarketCode = 'XTKS' | 'XNGO' | 'XFKA' | 'XSAP'
export type SKabuMarketCode = 'STK' export type SKabuMarketCode = 'STK'
export type UsStockMarketCode = 'XNAS' | 'XNYS' | 'ARCX' export type UsStockMarketCode = 'XNAS' | 'XNYS' | 'ARCX'
export type MarketCode = DomesticMarketCode | SKabuMarketCode | UsStockMarketCode export type MarketCode = DomesticMarketCode | SKabuMarketCode | UsStockMarketCode
@@ -647,9 +647,12 @@ export type Order = {
orderedAt?: string orderedAt?: string
expiresAt?: string expiresAt?: string
orderNumber?: string orderNumber?: string
orderSubNo?: string
tradeId?: string tradeId?: string
exchangeCode?: string exchangeCode?: string
accountInformation?: string accountInformation?: string
cancelable?: boolean
correctable?: boolean
} }
export type OrderList = { export type OrderList = {
@@ -658,6 +661,29 @@ export type OrderList = {
error?: SbiMethodError 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 = { export type OrderCorrectionPreOrderDetail = {
exchangeName?: string exchangeName?: string
marketLoanKbn?: string marketLoanKbn?: string
+15 -3
View File
@@ -8,7 +8,13 @@ const runtimeDefaults = {
CSBIE_RP_ID: '127.0.0.1', 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()) 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') messages.push('Set CSBIE_KEYRING_SECRET when CSBIE_KEYRING_BACKEND=sqlite')
} }
if (!present('SBI_AUTH_BASE_URL') || !present('SBI_MTS_BASE_URL')) { if (
messages.push('Set SBI_AUTH_BASE_URL and SBI_MTS_BASE_URL before connecting an SBI session') !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) { if (messages.length === 0) {