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