feat: introduce better mcp

This commit is contained in:
Shotaro Nakamura
2026-06-21 14:09:05 +09:00
parent 17c5a1156e
commit 207f9c2164
19 changed files with 1862 additions and 254 deletions
+204 -104
View File
@@ -7,13 +7,7 @@ import { eq } from 'drizzle-orm'
import * as z from 'zod/v4'
import type { AppBindings, AuthContext } from '../context'
import { sbiPasskeys } from '../db/schema'
import {
invokeSbiMethod,
isCashOrderMethod,
isTradingMethod,
RPC_METHODS,
type RpcMethod,
} from '../rpc/methods'
import { invokeSbiMethod, isCashOrderMethod, isTradingMethod, type RpcMethod } from '../rpc/methods'
import { connectSbi } from '../rpc/sbi-session'
import type { StoredSbiPasskeySecret } from './admin'
import {
@@ -33,8 +27,6 @@ const requireAuthenticated = (auth: AuthContext) => {
if (!auth.authenticated) throw new Error('unauthorized')
}
const toolNameForMethod = (method: RpcMethod) => `csbie_sbi_${method.replaceAll('.', '_')}`
const ORDER_SUBMIT_TICKET_TTL_MS = 10 * 60 * 1000
type OrderSubmitTicket = {
@@ -81,10 +73,6 @@ const orderSubmitMethodByEstimateMethod = {
const submitMethodForEstimateMethod = (method: RpcMethod) =>
orderSubmitMethodByEstimateMethod[method as keyof typeof orderSubmitMethodByEstimateMethod]
const isDirectOrderSubmitMethod = (method: RpcMethod) => isTradingMethod(method)
const mcpExposedRpcMethods = RPC_METHODS.filter((method) => !isDirectOrderSubmitMethod(method))
const orderSubmitParams = (value: unknown, confirmationId?: string) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return { allowTrading: true }
return {
@@ -179,6 +167,17 @@ const orderInquiryOptionsSchema = z.object({
status: z.enum(['open', 'executed', 'cancelled', 'expired', 'rejected', 'unknown']).optional(),
})
const orderDetailSchema = z.object({
orderNumber: z.string().min(1).optional().describe('Order number shown in order inquiry'),
orderId: orderIdSchema.optional().describe('Order id shown in order inquiry'),
issueCode: issueCodeSchema.optional(),
market: marketCodeSchema.describe('US stock market code for order detail'),
})
const tradeRecordInquiryOptionsSchema = orderInquiryOptionsSchema.extend({
accountType: accountTypeSchema.optional(),
})
const boardOptionsSchema = issueOptionsSchema.extend({
accountType: accountTypeSchema.optional(),
side: z
@@ -337,6 +336,8 @@ const placeOrderCorrectionSchema = orderCorrectionSchema.extend({
const orderCancelSchema = z.object({
orderNumber: z.string().min(1).describe('Order number shown in order inquiry'),
orderId: orderIdSchema.optional().describe('Original order id shown in order inquiry'),
issueCode: issueCodeSchema.optional().describe('Original issue code shown in order inquiry'),
market: marketCodeSchema.optional().describe('Original market code shown in order inquiry'),
tradeId: z.string().min(1).optional().describe('Original trade id code'),
cancelType: z.string().min(1).optional().describe('Additional cancel flag'),
})
@@ -628,8 +629,10 @@ const methodParamSchemas = {
'market.ranking.sbi': undefined,
'news.list': undefined,
'watchlist.list': undefined,
'orders.inquiry.detail': orderDetailSchema,
'orders.inquiry.executionsToday': orderInquiryOptionsSchema.optional(),
'orders.inquiry.open': orderInquiryOptionsSchema.optional(),
'orders.inquiry.tradeRecords': tradeRecordInquiryOptionsSchema,
'orders.cash.preOrder': cashOrderPreOrderSchema,
'orders.cash.estimate': cashOrderSchema,
'orders.cash.place': placeCashOrderSchema,
@@ -665,6 +668,84 @@ const methodParamSchemas = {
'orders.exchange.place': placeExchangeOrderSchema,
} satisfies Record<RpcMethod, z.ZodType | undefined>
const getActionToMethod = {
sessionProfile: 'session.profile',
accountProfile: 'account.profile',
currentAssets: 'account.assets.current',
buyingPower: 'account.power.buyingPower',
collateralRatio: 'account.power.collateralRatio',
cashPositions: 'account.positions.cash',
cashPositionDetail: 'account.positions.cashDetail',
cashPositionForIssue: 'account.positions.cashForIssue',
marginPositions: 'account.positions.margin',
marginPositionDetail: 'account.positions.marginDetail',
marginPositionForIssue: 'account.positions.marginForIssue',
marginSummaryForIssue: 'account.positions.marginSummaryForIssue',
marginDetailsForIssue: 'account.positions.marginDetailsForIssue',
closeableMarginPositions: 'account.positions.closeableMargin',
deliverableMarginPositions: 'account.positions.deliverableMargin',
unrealizedProfitLoss: 'account.profitLoss.unrealized',
issueSearch: 'market.issue.search',
issueSuggest: 'market.issue.suggest',
allowedPrices: 'market.issue.allowedPrices',
board: 'market.issue.board',
chart: 'market.issue.chart',
openOrdersForIssue: 'market.issue.openOrders',
tradingInfo: 'market.issue.tradingInfo',
majorIndexes: 'market.index.major',
marketOverview: 'market.overview',
marketRanking: 'market.ranking.market',
sectorRanking: 'market.ranking.sector',
sbiRanking: 'market.ranking.sbi',
news: 'news.list',
watchlist: 'watchlist.list',
orderDetail: 'orders.inquiry.detail',
executionsToday: 'orders.inquiry.executionsToday',
openOrders: 'orders.inquiry.open',
tradeRecords: 'orders.inquiry.tradeRecords',
cashOrderPreflight: 'orders.cash.preOrder',
marginOpenPreflight: 'orders.margin.preOrderOpen',
marginClosePreflight: 'orders.margin.preOrderClose',
actualDeliveryPreflight: 'orders.margin.preOrderActualDelivery',
themeInvestmentList: 'orders.themeInvestment.list',
exchangeRate: 'orders.exchange.rate',
} as const satisfies Record<string, RpcMethod>
const changeActionToEstimateMethod = {
cashOrder: 'orders.cash.estimate',
cashOrderCorrection: 'orders.cash.estimateCorrection',
cashOrderCorrectionConfirm: 'orders.cash.estimateCorrectionConfirm',
cashOrderCancel: 'orders.cash.estimateCancel',
marginOpenOrder: 'orders.margin.estimateOpen',
marginCloseOrder: 'orders.margin.estimateClose',
marginCloseSummaryOrder: 'orders.margin.estimateSummary',
marginCloseSummaryConfirm: 'orders.margin.estimateCloseSummary',
actualDeliveryOrder: 'orders.margin.estimateActualDelivery',
ifdOrder: 'orders.ifd.estimate',
ifdOrderCorrection: 'orders.ifd.estimateCorrection',
ifdOrderCancel: 'orders.ifd.estimateCancel',
themeInvestmentOrder: 'orders.themeInvestment.estimate',
exchangeOrder: 'orders.exchange.estimate',
} as const satisfies Record<string, keyof typeof orderSubmitMethodByEstimateMethod>
const getActions = Object.keys(getActionToMethod) as Array<keyof typeof getActionToMethod>
const changeActions = Object.keys(changeActionToEstimateMethod) as Array<
keyof typeof changeActionToEstimateMethod
>
const getActionSchema = z.enum(['capabilities', 'passkeys', ...getActions])
const changeActionSchema = z.enum(changeActions)
const parseMethodParams = (method: RpcMethod, input: unknown) => {
const schema = methodParamSchemas[method]
if (!schema) {
if (input !== undefined && input !== null) {
throw new Error(`${method} does not accept input`)
}
return undefined
}
return schema.parse(input)
}
const createMcpServer = (c: Context<AppBindings>) => {
const db = c.get('db')
const config = c.get('config')
@@ -675,59 +756,33 @@ const createMcpServer = (c: Context<AppBindings>) => {
version: '0.1.0',
})
server.registerTool(
'csbie_sbi_methods',
{
title: 'List SBI RPC Methods',
description: 'List SBI client methods exposed through CSBIE.',
inputSchema: {},
},
async () => {
requireAuthenticated(auth)
return textResult({
methods: mcpExposedRpcMethods,
submitTool: 'csbie_sbi_submit_order',
const listPasskeys = async () => {
const rows = await db
.select({
id: sbiPasskeys.id,
label: sbiPasskeys.label,
keyringAccount: sbiPasskeys.keyringAccount,
createdAt: sbiPasskeys.createdAt,
updatedAt: sbiPasskeys.updatedAt,
})
},
)
.from(sbiPasskeys)
.orderBy(sbiPasskeys.createdAt)
return Promise.all(
rows.map(async ({ keyringAccount, ...row }) => {
const secret = await readSecret<StoredSbiPasskeySecret>(keyringAccount)
const hasDeviceId = Boolean(effectiveSbiDeviceId(secret))
const hasTradePassword = Boolean(effectiveSbiTradePassword(secret))
return {
...row,
hasTradePassword,
hasDeviceId,
cashOrderReady: hasTradePassword && hasDeviceId,
}
}),
)
}
server.registerTool(
'csbie_sbi_passkeys',
{
title: 'List SBI Passkeys',
description: 'List saved SBI passkey profiles. Secret material is never returned.',
inputSchema: {},
},
async () => {
requireAuthenticated(auth)
const rows = await db
.select({
id: sbiPasskeys.id,
label: sbiPasskeys.label,
keyringAccount: sbiPasskeys.keyringAccount,
createdAt: sbiPasskeys.createdAt,
updatedAt: sbiPasskeys.updatedAt,
})
.from(sbiPasskeys)
.orderBy(sbiPasskeys.createdAt)
const passkeys = await Promise.all(
rows.map(async ({ keyringAccount, ...row }) => {
const secret = await readSecret<StoredSbiPasskeySecret>(keyringAccount)
const hasDeviceId = Boolean(effectiveSbiDeviceId(secret))
const hasTradePassword = Boolean(effectiveSbiTradePassword(secret))
return {
...row,
hasTradePassword,
hasDeviceId,
cashOrderReady: hasTradePassword && hasDeviceId,
}
}),
)
return textResult({ passkeys })
},
)
const callSbiMethod = async (method: RpcMethod, passkeyId: string, params: unknown) => {
const invokeCheckedSbiMethod = async (method: RpcMethod, passkeyId: string, params: unknown) => {
requireAuthenticated(auth)
if (auth.type === 'apiKey') {
@@ -767,41 +822,105 @@ const createMcpServer = (c: Context<AppBindings>) => {
}
const client = await connectSbi(db, config, passkeyId)
const result = await invokeSbiMethod(client, method, params)
const submitMethod = submitMethodForEstimateMethod(method)
if (!submitMethod) return textResult(result)
return invokeSbiMethod(client, method, params)
}
const createChangeRequest = async (
action: keyof typeof changeActionToEstimateMethod,
passkeyId: string,
input: unknown,
) => {
const estimateMethod = changeActionToEstimateMethod[action]
const submitMethod = submitMethodForEstimateMethod(estimateMethod)
if (!submitMethod) throw new Error(`${action} cannot create a confirmable request`)
const params = parseMethodParams(estimateMethod, input)
const preview = await invokeCheckedSbiMethod(estimateMethod, passkeyId, params)
cleanupExpiredOrderSubmitTickets()
const uuid = randomUUID()
const expiresAt = new Date(Date.now() + ORDER_SUBMIT_TICKET_TTL_MS)
const confirmationId = confirmationIdFromPreview(result)
const confirmationId = confirmationIdFromPreview(preview)
orderSubmitTickets.set(uuid, {
passkeyId,
estimateMethod: method,
estimateMethod,
submitMethod,
params,
confirmationId,
authKey: authKey(auth),
expiresAt,
})
return textResult({
preview: result,
submit: {
uuid,
tool: 'csbie_sbi_submit_order',
expiresAt: expiresAt.toISOString(),
},
})
return {
uuid,
expiresAt: expiresAt.toISOString(),
preview,
confirmTool: 'confirm-request',
}
}
server.registerTool(
'csbie_sbi_submit_order',
'csbie-get',
{
title: 'Submit Estimated SBI Order',
title: 'Get SBI Data',
description:
'Submit the same SBI order as a previous MCP estimate result by UUID. The UUID expires shortly and is bound to the same authenticated caller.',
'Read SBI data through a small abstract action API. This tool never places, corrects, cancels, or otherwise changes real orders.',
inputSchema: {
uuid: z.string().uuid().describe('UUID returned by an order estimate tool'),
action: getActionSchema.describe('Read action to perform'),
passkeyId: z
.string()
.optional()
.describe('Saved SBI passkey id. Required for SBI-backed read actions.'),
input: z.unknown().optional().describe('Action input object'),
},
},
async ({ action, passkeyId, input }) => {
requireAuthenticated(auth)
if (action === 'capabilities') {
return textResult({
getActions,
changeActions,
changeTool: 'csbie-request-change',
confirmTool: 'confirm-request',
})
}
if (action === 'passkeys') return textResult({ passkeys: await listPasskeys() })
const method = getActionToMethod[action]
if (!method) throw new Error(`unsupported get action: ${String(action)}`)
if (!passkeyId) throw new Error(`${action} requires passkeyId`)
const params = parseMethodParams(method, input)
return textResult(await invokeCheckedSbiMethod(method, passkeyId, params))
},
)
server.registerTool(
'csbie-request-change',
{
title: 'Create SBI Change Request',
description:
'Prepare a real SBI change by running the corresponding estimate/preview and returning a UUID. This tool never submits the change; pass the UUID to confirm-request.',
inputSchema: {
action: changeActionSchema.describe('Change action to prepare'),
passkeyId: z.string().describe('Saved SBI passkey id'),
input: z.unknown().describe('Change action input object'),
},
},
async ({ action, passkeyId, input }) =>
textResult(await createChangeRequest(action, passkeyId, input)),
)
server.registerTool(
'confirm-request',
{
title: 'Confirm SBI Change Request',
description:
'Submit a previously prepared SBI change request by UUID. The UUID expires shortly, is single-use, and is bound to the same authenticated caller.',
inputSchema: {
uuid: z.string().uuid().describe('UUID returned by csbie-request-change'),
},
},
async ({ uuid }) => {
@@ -809,40 +928,21 @@ const createMcpServer = (c: Context<AppBindings>) => {
cleanupExpiredOrderSubmitTickets()
const ticket = orderSubmitTickets.get(uuid)
if (!ticket) throw new Error('order submit uuid not found or expired')
if (!ticket) throw new Error('request uuid not found or expired')
if (ticket.authKey !== authKey(auth)) {
throw new Error('order submit uuid was created by a different authenticated caller')
throw new Error('request uuid was created by a different authenticated caller')
}
orderSubmitTickets.delete(uuid)
return callSbiMethod(
const result = await invokeCheckedSbiMethod(
ticket.submitMethod,
ticket.passkeyId,
orderSubmitParams(ticket.params, ticket.confirmationId),
)
return textResult(result)
},
)
for (const method of mcpExposedRpcMethods) {
const paramsSchema = methodParamSchemas[method]
server.registerTool(
toolNameForMethod(method),
{
title: `Call ${method}`,
description: `Connect with one saved SBI passkey and call ${method}. API key method permissions and trading limits are enforced.`,
inputSchema: {
passkeyId: z.string().describe('Saved SBI passkey id from csbie_sbi_passkeys'),
...(paramsSchema
? {
params: paramsSchema.describe(`${method} params`),
}
: {}),
},
},
async ({ passkeyId, params }) => callSbiMethod(method, passkeyId, params),
)
}
return server
}
+2
View File
@@ -31,8 +31,10 @@ export const RPC_METHODS = [
'market.ranking.sbi',
'news.list',
'watchlist.list',
'orders.inquiry.detail',
'orders.inquiry.executionsToday',
'orders.inquiry.open',
'orders.inquiry.tradeRecords',
'orders.cash.preOrder',
'orders.cash.estimate',
'orders.cash.place',
+12
View File
@@ -105,6 +105,7 @@ const {
holdingsMarketValue,
totalProfitLoss,
totalProfitLossRate,
marketIndexes,
orders,
cancelingOrderKey,
orderHistoryLoaded,
@@ -146,6 +147,11 @@ const {
askPlaceOrder,
placeCashOrder,
cancelOrder,
loadOrderDetail,
loadTradeRecords,
loadPositionDetail,
estimateOrderCorrection,
placeOrderCorrection,
downloadCsv,
openTradeForStock,
openTradeForPosition,
@@ -232,6 +238,7 @@ onMounted(async () => {
:holdings-market-value="holdingsMarketValue"
:total-profit-loss="totalProfitLoss"
:total-profit-loss-rate="totalProfitLossRate"
:market-indexes="marketIndexes"
:stock-asset-ratio="stockAssetRatio"
:cash-asset-ratio="cashAssetRatio"
:positions="positions"
@@ -241,6 +248,7 @@ onMounted(async () => {
:connected="connected"
:order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice"
:load-position-detail="loadPositionDetail"
@connect="connect"
@open-position="
(code) =>
@@ -304,6 +312,10 @@ onMounted(async () => {
:canceling-order-key="cancelingOrderKey"
:order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice"
:load-order-detail="loadOrderDetail"
:load-trade-records="loadTradeRecords"
:estimate-order-correction="estimateOrderCorrection"
:place-order-correction="placeOrderCorrection"
@refresh="loadTradingData"
@cancel="cancelOrder"
/>
@@ -39,8 +39,10 @@ const rpcMethods = [
'market.ranking.sbi',
'news.list',
'watchlist.list',
'orders.inquiry.detail',
'orders.inquiry.executionsToday',
'orders.inquiry.open',
'orders.inquiry.tradeRecords',
'orders.cash.estimate',
'orders.cash.place',
'orders.cash.estimateCorrection',
+2 -2
View File
@@ -8,14 +8,14 @@ export type PageMeta = {
}
export const pageMeta: Record<RouteName, PageMeta> = {
portfolio: { title: 'ポートフォリオ', icon: WalletCards },
portfolio: { title: 'ホーム', icon: WalletCards },
trade: { title: '取引', icon: ArrowLeftRight },
history: { title: '取引履歴', icon: History },
settings: { title: '設定', icon: Settings },
}
export const sidebarItems: Array<{ name: RouteName; label: string; icon: Component }> = [
{ name: 'portfolio', label: '資産一覧', icon: pageMeta.portfolio.icon },
{ name: 'portfolio', label: 'ホーム', icon: pageMeta.portfolio.icon },
{ name: 'trade', label: '取引', icon: pageMeta.trade.icon },
{ name: 'settings', label: '設定', icon: pageMeta.settings.icon },
]
+12 -1
View File
@@ -34,12 +34,23 @@ export const cashOrderMarketOptions: Array<{
}> = [
{ label: '自動', value: 'auto' },
{ label: '東証', value: 'XTKS' },
{ label: '名証', value: 'XNGO' },
{ label: '福証', value: 'XFKA' },
{ label: '札証', value: 'XSAP' },
{ label: 'NASDAQ', value: 'XNAS' },
{ label: 'NYSE', value: 'XNYS' },
{ label: 'NYSE Arca', value: 'ARCX' },
]
export const searchableMarkets: CashOrderMarket[] = ['XTKS', 'XNAS', 'XNYS', 'ARCX']
export const searchableMarkets: CashOrderMarket[] = [
'XTKS',
'XNGO',
'XFKA',
'XSAP',
'XNAS',
'XNYS',
'ARCX',
]
export const tradeRouteIdFromStockId = (id: string) => {
const separator = id.indexOf(':')
@@ -1,21 +1,40 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { AnimatePresence } from 'motion-v'
import { ArrowLeft, Ban, RefreshCw } from 'lucide-vue-next'
import {
ArrowLeft,
Ban,
FileText,
Pencil,
ReceiptText,
RefreshCw,
ShieldCheck,
} from 'lucide-vue-next'
import Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
import type { OrderRow } from '../../types/trading'
import type { OrderDetail, OrderPreview, OrderRow, TradeRecordRow } from '../../types/trading'
import { currencyForMarket } from '../../utils/format'
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
defineProps<{
const props = defineProps<{
orders: OrderRow[]
connected: boolean
dataLoading: boolean
cancelingOrderKey: string
orderHistoryLoaded: boolean
orderHistoryNotice: string
loadOrderDetail: (order: OrderRow) => Promise<OrderDetail>
loadTradeRecords: () => Promise<TradeRecordRow[]>
estimateOrderCorrection: (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
) => Promise<OrderPreview>
placeOrderCorrection: (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
) => Promise<void>
}>()
const emit = defineEmits<{
@@ -25,11 +44,51 @@ const emit = defineEmits<{
const cancelCandidate = ref<OrderRow | null>(null)
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
const isUsMarket = (market: string) => usMarkets.has(market)
const canCancel = (order: OrderRow) =>
order.status === '注文中' && Boolean(order.orderNumber) && !usMarkets.has(order.market)
order.status === '注文中' && Boolean(order.orderNumber) && order.cancelable !== false
const canShowUsDetail = (order: OrderRow) => isUsMarket(order.market)
const canCorrect = (order: OrderRow) =>
order.status === '注文中' &&
Boolean(order.orderNumber) &&
isUsMarket(order.market) &&
order.correctable !== false
const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
orderHistoryKey(order) === cancelingOrderKey
const cancelTitle = computed(() => cancelCandidate.value?.stock ?? '')
const orderDetail = ref<OrderDetail | null>(null)
const orderDetailLoadingKey = ref('')
const orderActionError = ref('')
const tradeRecords = ref<TradeRecordRow[]>([])
const showTradeRecords = ref(false)
const tradeRecordsLoading = ref(false)
const correctionCandidate = ref<OrderRow | null>(null)
const correctionQuantityInput = ref('')
const correctionPriceCondition = ref<'market' | 'limit'>('limit')
const correctionPriceInput = ref('')
const correctionPreview = ref<OrderPreview | null>(null)
const correctionLoading = ref(false)
const correctionSubmitting = ref(false)
const orderActionKey = (order: OrderRow) => orderHistoryKey(order)
const numericInput = (value: string) => {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
const correctionDraft = () => {
const quantity = numericInput(correctionQuantityInput.value)
if (!quantity || quantity <= 0) throw new Error('数量を入力してください')
if (correctionPriceCondition.value === 'market') {
return { quantity, priceCondition: 'market' as const }
}
const price = numericInput(correctionPriceInput.value)
if (!price || price <= 0) throw new Error('指値を入力してください')
return { quantity, priceCondition: 'limit' as const, price }
}
const priceText = (value: number | null | undefined, market: string) =>
value == null ? '-' : currencyForMarket(value, market)
const tradeAmountText = (record: TradeRecordRow) =>
record.amount == null ? '-' : currencyForMarket(record.amount, record.market)
const askCancel = (order: OrderRow) => {
if (!canCancel(order)) return
@@ -41,27 +100,108 @@ const confirmCancel = () => {
emit('cancel', cancelCandidate.value)
cancelCandidate.value = null
}
const showOrderDetail = async (order: OrderRow) => {
if (!canShowUsDetail(order) || orderDetailLoadingKey.value) return
orderActionError.value = ''
orderDetailLoadingKey.value = orderActionKey(order)
try {
orderDetail.value = await props.loadOrderDetail(order)
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '注文詳細の取得に失敗しました'
} finally {
orderDetailLoadingKey.value = ''
}
}
const loadTradeRecords = async () => {
if (tradeRecordsLoading.value) return
orderActionError.value = ''
tradeRecordsLoading.value = true
try {
tradeRecords.value = await props.loadTradeRecords()
showTradeRecords.value = true
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '約定履歴の取得に失敗しました'
} finally {
tradeRecordsLoading.value = false
}
}
const askCorrection = (order: OrderRow) => {
if (!canCorrect(order)) return
correctionCandidate.value = order
correctionQuantityInput.value = String(order.unexecutedQuantity ?? order.quantity ?? '')
correctionPriceCondition.value = order.price == null ? 'market' : 'limit'
correctionPriceInput.value = order.price == null ? '' : String(order.price)
correctionPreview.value = null
orderActionError.value = ''
}
const estimateCorrection = async () => {
if (!correctionCandidate.value || correctionLoading.value) return
orderActionError.value = ''
correctionLoading.value = true
try {
correctionPreview.value = await props.estimateOrderCorrection(
correctionCandidate.value,
correctionDraft(),
)
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '注文訂正の見積に失敗しました'
} finally {
correctionLoading.value = false
}
}
const confirmCorrection = async () => {
if (!correctionCandidate.value || correctionSubmitting.value) return
orderActionError.value = ''
correctionSubmitting.value = true
try {
await props.placeOrderCorrection(correctionCandidate.value, correctionDraft())
correctionCandidate.value = null
correctionPreview.value = null
} catch (cause) {
orderActionError.value = cause instanceof Error ? cause.message : '注文訂正に失敗しました'
} finally {
correctionSubmitting.value = false
}
}
</script>
<template>
<section :class="ui.panel">
<div :class="ui.panelHead">
<h2>取引履歴</h2>
<button
:class="ui.ghostButton"
type="button"
:disabled="!connected || dataLoading"
@click="emit('refresh')"
>
<RefreshCw class="h-4 w-4" aria-hidden="true" />
履歴を更新
</button>
<span :class="ui.rowActions">
<button
:class="ui.ghostButton"
type="button"
:disabled="!connected || tradeRecordsLoading"
@click="loadTradeRecords"
>
<Spinner v-if="tradeRecordsLoading" size="sm" />
<ReceiptText v-else class="h-4 w-4" aria-hidden="true" />
約定履歴
</button>
<button
:class="ui.ghostButton"
type="button"
:disabled="!connected || dataLoading"
@click="emit('refresh')"
>
<RefreshCw class="h-4 w-4" aria-hidden="true" />
更新
</button>
</span>
</div>
<p v-if="orderActionError" :class="ui.dialogNote">{{ orderActionError }}</p>
<div :class="ui.list">
<div v-for="order in orders" :key="order.id" :class="ui.orderRow">
<span>
<strong>{{ order.stock }}</strong>
<small>{{ order.date }} / 通常単元</small>
<small>{{ order.code }} / {{ order.market }} / {{ order.date }}</small>
</span>
<span :class="order.side === 'buy' ? ui.positive : ui.negative">
{{ order.side === 'buy' ? '購入' : '売却' }}
@@ -71,17 +211,43 @@ const confirmCancel = () => {
<span :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]">
{{ order.status }}
</span>
<button
v-if="canCancel(order)"
:class="ui.ghostButton"
type="button"
:disabled="Boolean(cancelingOrderKey)"
@click="askCancel(order)"
>
<Spinner v-if="isCanceling(order, cancelingOrderKey)" size="sm" />
<Ban v-else class="h-4 w-4" aria-hidden="true" />
{{ isCanceling(order, cancelingOrderKey) ? '取消中' : '取消' }}
</button>
<span :class="ui.rowActions">
<button
v-if="canShowUsDetail(order)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(orderDetailLoadingKey)"
@click="showOrderDetail(order)"
>
<Spinner v-if="orderDetailLoadingKey === orderActionKey(order)" size="sm" />
<FileText v-else class="h-3.5 w-3.5" aria-hidden="true" />
詳細
</button>
<button
v-if="canCorrect(order)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(cancelingOrderKey)"
@click="askCorrection(order)"
>
<Pencil class="h-3.5 w-3.5" aria-hidden="true" />
訂正
</button>
<button
v-if="canCancel(order)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(cancelingOrderKey)"
@click="askCancel(order)"
>
<Spinner v-if="isCanceling(order, cancelingOrderKey)" size="sm" />
<Ban v-else class="h-3.5 w-3.5" aria-hidden="true" />
{{ isCanceling(order, cancelingOrderKey) ? '取消中' : '取消' }}
</button>
</span>
</div>
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
<Spinner />
@@ -98,6 +264,195 @@ const confirmCancel = () => {
</div>
</section>
<AnimatePresence>
<UiModal
v-if="orderDetail"
key="order-detail-dialog"
eyebrow="注文詳細"
:title="orderDetail.stock"
@close="orderDetail = null"
>
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>注文番号</dt>
<dd>{{ orderDetail.orderNumber ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>枝番</dt>
<dd>{{ orderDetail.orderSubNo ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>銘柄</dt>
<dd>{{ orderDetail.code }} / {{ orderDetail.market }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>売買</dt>
<dd>{{ orderDetail.side === 'buy' ? '購入' : '売却' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>数量</dt>
<dd>{{ orderQuantityText(orderDetail) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>未約定数量</dt>
<dd>{{ orderDetail.unexecutedQuantity ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>価格</dt>
<dd>{{ priceText(orderDetail.price, orderDetail.market) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>状態</dt>
<dd>{{ orderDetail.statusText ?? orderDetail.status }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>有効期限</dt>
<dd>{{ orderDetail.expiresAt ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>取消/訂正</dt>
<dd>
{{ orderDetail.cancelable === false ? '取消不可' : '取消可' }} /
{{ orderDetail.correctable === false ? '訂正不可' : '訂正可' }}
</dd>
</div>
</dl>
<div :class="ui.actions">
<UiButton variant="ghost" @click="orderDetail = null">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
閉じる
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="showTradeRecords"
key="trade-records-dialog"
eyebrow="約定履歴"
title="米国株"
@close="showTradeRecords = false"
>
<div v-if="tradeRecords.length" :class="ui.confirmList">
<div v-for="record in tradeRecords" :key="record.id" :class="ui.row">
<span class="grid gap-1">
<strong>{{ record.stock }}</strong>
<small>{{ record.code }} / {{ record.market }} / {{ record.type }}</small>
<small :class="ui.muted">
{{ record.tradeDate ?? '-' }} 約定 / {{ record.valueDate ?? '-' }} 受渡
</small>
</span>
<span class="grid justify-items-end gap-1 text-right">
<strong>{{ tradeAmountText(record) }}</strong>
<small :class="ui.muted">
{{ record.quantity ?? '-' }}株 @ {{ priceText(record.price, record.market) }}
</small>
</span>
</div>
</div>
<p v-else :class="ui.dialogNote">該当する約定履歴はありません</p>
<div :class="ui.actions">
<UiButton variant="ghost" @click="showTradeRecords = false">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
閉じる
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="correctionCandidate"
key="correction-dialog"
eyebrow="注文訂正"
:title="correctionCandidate.stock"
@close="correctionCandidate = null"
>
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>注文番号</dt>
<dd>{{ correctionCandidate.orderNumber }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>現在数量</dt>
<dd>{{ orderQuantityText(correctionCandidate) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>現在価格</dt>
<dd>{{ priceText(correctionCandidate.price, correctionCandidate.market) }}</dd>
</div>
</dl>
<div class="grid gap-3">
<label :class="ui.label">
数量
<input
v-model="correctionQuantityInput"
:class="ui.input"
inputmode="decimal"
@input="correctionPreview = null"
/>
</label>
<label :class="ui.label">
執行条件
<select
v-model="correctionPriceCondition"
:class="ui.input"
@change="correctionPreview = null"
>
<option value="limit">指値</option>
<option value="market">成行</option>
</select>
</label>
<label v-if="correctionPriceCondition === 'limit'" :class="ui.label">
指値
<input
v-model="correctionPriceInput"
:class="ui.input"
inputmode="decimal"
@input="correctionPreview = null"
/>
</label>
</div>
<dl v-if="correctionPreview" :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>見積数量</dt>
<dd>{{ correctionPreview.quantity }}株</dd>
</div>
<div :class="ui.confirmRow">
<dt>見積金額</dt>
<dd>
{{
correctionPreview.price
? priceText(correctionPreview.price.value, correctionCandidate.market)
: '-'
}}
</dd>
</div>
</dl>
<div :class="ui.actions">
<UiButton variant="ghost" @click="correctionCandidate = null">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
戻る
</UiButton>
<UiButton :disabled="correctionLoading" @click="estimateCorrection">
<Spinner v-if="correctionLoading" size="sm" />
<ShieldCheck v-else class="h-4 w-4" aria-hidden="true" />
見積
</UiButton>
<UiButton
variant="danger"
:disabled="!correctionPreview || correctionSubmitting"
@click="confirmCorrection"
>
<Spinner v-if="correctionSubmitting" size="sm" />
<Pencil v-else class="h-4 w-4" aria-hidden="true" />
訂正
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="cancelCandidate"
@@ -1,28 +1,30 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { AnimatePresence } from 'motion-v'
import { ArrowLeft, Ban, Plug } from 'lucide-vue-next'
import { ArrowLeft, Ban, FileText, Plug } from 'lucide-vue-next'
import Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
import type { OrderRow, Position } from '../../types/trading'
import type { MarketIndex, OrderRow, Position } from '../../types/trading'
import {
currency,
currencyForMarket,
number as formatNumber,
signedCurrency,
signedCurrencyForMarket,
signedPercent,
} from '../../utils/format'
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
defineProps<{
const props = defineProps<{
showPortfolioSpinner: boolean
totalAssetValue: number
buyingPower: number
holdingsMarketValue: number
totalProfitLoss: number
totalProfitLossRate: number
marketIndexes: MarketIndex[]
stockAssetRatio: number
cashAssetRatio: number
positions: Position[]
@@ -32,6 +34,7 @@ defineProps<{
connected: boolean
orderHistoryLoaded: boolean
orderHistoryNotice: string
loadPositionDetail: (position: Position) => Promise<Position>
}>()
const emit = defineEmits<{
@@ -41,12 +44,47 @@ const emit = defineEmits<{
}>()
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
const isUsMarket = (market: string) => usMarkets.has(market)
const canCancel = (order: OrderRow) =>
order.status === '注文中' && Boolean(order.orderNumber) && !usMarkets.has(order.market)
order.status === '注文中' && Boolean(order.orderNumber) && order.cancelable !== false
const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
orderHistoryKey(order) === cancelingOrderKey
const cancelCandidate = ref<OrderRow | null>(null)
const positionDetail = ref<Position | null>(null)
const positionDetailLoadingKey = ref('')
const positionDetailError = ref('')
const cancelTitle = computed(() => cancelCandidate.value?.stock ?? '')
const indexValueText = (index: MarketIndex) =>
index.valueText || (index.value == null ? '-' : formatNumber(index.value))
const indexChangeText = (index: MarketIndex) => {
const change = index.changeText || (index.change == null ? '' : formatNumber(index.change))
const rate =
index.changeRateText || (index.changeRate == null ? '' : signedPercent(index.changeRate))
return [change, rate].filter(Boolean).join(' / ') || '-'
}
const indexTone = (index: MarketIndex) => {
if (index.sign === 'positive') return ui.positive
if (index.sign === 'negative') return ui.negative
return ui.muted
}
const positionKey = (position: Position) =>
[position.market, position.code, position.accountType].filter(Boolean).join(':')
const canLoadPositionDetail = (position: Position) => isUsMarket(position.market)
const showPositionDetail = async (position: Position) => {
if (!canLoadPositionDetail(position) || positionDetailLoadingKey.value) return
const key = positionKey(position)
positionDetailLoadingKey.value = key
positionDetailError.value = ''
try {
positionDetail.value = await props.loadPositionDetail(position)
} catch (cause) {
positionDetailError.value =
cause instanceof Error ? cause.message : '保有詳細の取得に失敗しました'
} finally {
positionDetailLoadingKey.value = ''
}
}
const askCancel = (order: OrderRow) => {
if (!canCancel(order)) return
@@ -69,6 +107,13 @@ const confirmCancel = () => {
<template v-if="!showPortfolioSpinner">{{ currency(totalAssetValue) }}</template>
<Spinner v-else size="lg" />
</strong>
<small :class="[ui.assetOverviewSubtext, totalProfitLoss >= 0 ? ui.positive : ui.negative]">
通算評価損益:
<template v-if="!showPortfolioSpinner">
{{ signedCurrency(totalProfitLoss) }} · {{ signedPercent(totalProfitLossRate) }}
</template>
<Spinner v-else size="sm" />
</small>
<small :class="ui.assetOverviewSubtext">
余力:
<template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template>
@@ -120,17 +165,6 @@ const confirmCancel = () => {
</div>
</article>
<article :class="ui.metricPanel">
<span :class="ui.metricLabel">通算評価損益</span>
<strong :class="[ui.metricValue, totalProfitLoss >= 0 ? ui.positive : ui.negative]">
<template v-if="!showPortfolioSpinner">
{{ signedCurrency(totalProfitLoss) }}
<small> · {{ signedPercent(totalProfitLossRate) }}</small>
</template>
<Spinner v-else size="lg" />
</strong>
</article>
<article :class="ui.holdingsPanel">
<div :class="ui.panelHead">
<h2>保有銘柄</h2>
@@ -151,19 +185,18 @@ const confirmCancel = () => {
<span>数量</span>
<span>評価額</span>
<span>評価損益</span>
<span>操作</span>
</div>
<div v-if="positions.length" :class="ui.holdingsRows">
<button
v-for="position in positions"
:key="position.code"
:class="ui.holdingRow"
type="button"
@click="emit('openPosition', position.code)"
>
<span class="grid gap-1">
<div v-for="position in positions" :key="positionKey(position)" :class="ui.holdingRow">
<button
class="grid gap-1 text-left text-[#e3e3e9]"
type="button"
@click="emit('openPosition', position.code)"
>
<strong>{{ position.name }}</strong>
<small>{{ position.code }}</small>
</span>
</button>
<b :class="ui.typePill">{{
position.type ?? (position.quantity >= 100 ? '単元' : 'S株')
}}</b>
@@ -178,12 +211,27 @@ const confirmCancel = () => {
<strong>{{ signedCurrencyForMarket(position.profitLoss, position.market) }}</strong>
<small>{{ signedPercent(position.profitLossRate) }}</small>
</span>
</button>
<span :class="ui.rowActions">
<button
v-if="canLoadPositionDetail(position)"
:class="ui.ghostButton"
class="min-h-8 px-3 text-xs"
type="button"
:disabled="Boolean(positionDetailLoadingKey)"
@click="showPositionDetail(position)"
>
<Spinner v-if="positionDetailLoadingKey === positionKey(position)" size="sm" />
<FileText v-else class="h-3.5 w-3.5" aria-hidden="true" />
詳細
</button>
</span>
</div>
</div>
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
<Spinner />
</div>
<p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると保有銘柄を表示します</p>
<p v-if="positionDetailError" :class="ui.dialogNote">{{ positionDetailError }}</p>
</div>
</article>
@@ -234,8 +282,85 @@ const confirmCancel = () => {
</p>
</div>
</article>
<article :class="ui.marketIndexPanel">
<div :class="ui.panelHead">
<h2>指数</h2>
</div>
<div v-if="marketIndexes.length" :class="ui.marketIndexGrid">
<div
v-for="index in marketIndexes"
:key="index.code ?? index.name"
:class="ui.marketIndexCard"
>
<span :class="ui.metricLabel">{{ index.name }}</span>
<strong :class="ui.marketIndexValue">{{ indexValueText(index) }}</strong>
<small :class="indexTone(index)">{{ indexChangeText(index) }}</small>
</div>
</div>
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
<Spinner />
</div>
<p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると指数を表示します</p>
</article>
</section>
<AnimatePresence>
<UiModal
v-if="positionDetail"
key="position-detail-dialog"
eyebrow="保有詳細"
:title="positionDetail.name"
@close="positionDetail = null"
>
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>銘柄</dt>
<dd>{{ positionDetail.code }} / {{ positionDetail.market }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>預り区分</dt>
<dd>{{ positionDetail.type ?? positionDetail.accountType ?? '-' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>数量</dt>
<dd>{{ positionDetail.quantity }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>現在値</dt>
<dd>
{{
positionDetail.currentPrice == null
? '-'
: currencyForMarket(positionDetail.currentPrice, positionDetail.market)
}}
</dd>
</div>
<div :class="ui.confirmRow">
<dt>平均取得単価</dt>
<dd>{{ currencyForMarket(positionDetail.avgPrice, positionDetail.market) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>評価額</dt>
<dd>{{ currencyForMarket(positionDetail.marketValue, positionDetail.market) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>評価損益</dt>
<dd :class="positionDetail.profitLoss >= 0 ? ui.positive : ui.negative">
{{ signedCurrencyForMarket(positionDetail.profitLoss, positionDetail.market) }} /
{{ signedPercent(positionDetail.profitLossRate) }}
</dd>
</div>
</dl>
<div :class="ui.actions">
<UiButton variant="ghost" @click="positionDetail = null">
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
閉じる
</UiButton>
</div>
</UiModal>
</AnimatePresence>
<AnimatePresence>
<UiModal
v-if="cancelCandidate"
@@ -1,19 +1,29 @@
import type {
ChartNotice,
MarketIndex,
OrderDetail,
OrderPreview,
OrderRow,
Position,
RealtimePricePoint,
Stock,
TradeRecordRow,
} from '../../types/trading'
import { currency } from '../../utils/format'
export type RecordLike = Record<string, unknown>
type IssueLike = {
code: string
market: string
name: string
searchText?: string
}
export const emptyStock: Stock = {
code: '',
name: '未選択',
symbol: '',
searchText: '',
country: '日本',
market: '',
sector: '',
@@ -75,13 +85,27 @@ const countryFromMarket = (market: string) =>
export const issueFrom = (value: unknown) => {
const issue = asRecord(value)
const code = textValue(issue.code, textValue(issue.issueCode))
const market = textValue(issue.market, textValue(issue.marketCode, ''))
const name = textValue(
issue.name,
textValue(issue.issueName, textValue(issue.stockName, textValue(issue.displayName))),
)
const searchText = [
code,
market,
name,
textValue(issue.extract),
textValue(issue.extractWord),
textValue(issue.hitString),
]
.filter(Boolean)
.join(' ')
return {
code: textValue(issue.code, textValue(issue.issueCode)),
market: textValue(issue.market, textValue(issue.marketCode, '')),
name: textValue(
issue.name,
textValue(issue.issueName, textValue(issue.stockName, textValue(issue.displayName))),
),
code,
market,
name,
searchText,
}
}
@@ -112,10 +136,11 @@ const boxFromHistory = (history: number[]) => {
return { min: at(0), q1: at(0.25), median: at(0.5), q3: at(0.75), max: at(1) }
}
export const stockFromIssue = (issue: ReturnType<typeof issueFrom>): Stock => ({
export const stockFromIssue = (issue: IssueLike): Stock => ({
code: issue.code,
name: issue.name || issue.code,
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
searchText: issue.searchText,
country: countryFromMarket(issue.market),
market: issue.market,
sector: '',
@@ -146,10 +171,16 @@ export const stockFromPosition = (position: Position): Stock => {
}
}
export const stockFromBoard = (
value: unknown,
fallbackIssue?: ReturnType<typeof issueFrom>,
): Stock => {
const accountTypeLabels: Record<string, string> = {
specific: '特定',
general: '一般',
growthInvestment: 'NISA成長投資枠',
nisa: 'NISA',
}
const accountTypeLabel = (value: string) => accountTypeLabels[value] ?? value
export const stockFromBoard = (value: unknown, fallbackIssue?: IssueLike): Stock => {
const board = asRecord(value)
const quoteRecord = asRecord(board.quote)
const boardIssue = issueFrom(board.issue)
@@ -158,6 +189,7 @@ export const stockFromBoard = (
code: quoteIssue.code || boardIssue.code || fallbackIssue?.code || '',
market: quoteIssue.market || boardIssue.market || fallbackIssue?.market || '',
name: quoteIssue.name || boardIssue.name || fallbackIssue?.name || '',
searchText: quoteIssue.searchText || boardIssue.searchText || fallbackIssue?.searchText || '',
}
const quote = quoteFrom(quoteRecord)
const codeAsNumber = Number(issue.code)
@@ -169,6 +201,7 @@ export const stockFromBoard = (
code: issue.code,
name: issue.name,
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
searchText: issue.searchText,
country: countryFromMarket(issue.market),
market: issue.market,
sector: '',
@@ -186,6 +219,53 @@ export const stockFromBoard = (
}
}
const signedValueFrom = (value: unknown) => {
const record = asRecord(value)
const parsedValue = nullableNumberValue(record.value)
const text = textValue(record.text, parsedValue == null ? '' : String(parsedValue))
const rawSign = textValue(record.sign)
const sign: MarketIndex['sign'] =
rawSign === 'positive' || rawSign === 'negative' || rawSign === 'zero'
? rawSign
: parsedValue == null || parsedValue === 0
? 'zero'
: parsedValue > 0
? 'positive'
: 'negative'
return { value: parsedValue, text, sign }
}
const percentValueFrom = (value: unknown) => {
const record = asRecord(value)
const parsedValue = nullableNumberValue(record.value)
return {
value: parsedValue,
text: textValue(record.text, parsedValue == null ? '' : `${parsedValue}%`),
}
}
export const marketIndexFromApi = (value: unknown): MarketIndex | null => {
const index = asRecord(value)
const name = textValue(index.name)
if (!name) return null
const parsedValue = nullableNumberValue(index.value)
const change = signedValueFrom(index.change)
const changeRate = percentValueFrom(index.changeRate)
return {
code: textValue(index.code) || undefined,
name,
value: parsedValue,
valueText: textValue(index.valueText, parsedValue == null ? '' : String(parsedValue)),
change: change.value,
changeText: change.text,
changeRate: changeRate.value,
changeRateText: changeRate.text,
sign: change.sign,
timestamp: textValue(index.timestamp) || undefined,
}
}
const zonedPartsFormatter = (timeZone: string) =>
new Intl.DateTimeFormat('en-US', {
timeZone,
@@ -357,16 +437,20 @@ export const positionFromApi = (value: unknown): Position | null => {
const costBasis = avgPrice * quantity
const profitLossRate =
numberValue(item.profitLossRate) || (costBasis ? (profitLoss / costBasis) * 100 : 0)
const accountType = textValue(item.accountType, textValue(item.depositType))
return {
code: issue.code,
name: issue.name,
market: issue.market,
quantity,
avgPrice,
currentPrice: nullableNumberValue(item.currentPrice),
marketValue: numberValue(item.marketValue ?? item.valuationPrice),
profitLoss,
profitLossRate,
type: textValue(item.depositTypeText) || undefined,
type:
textValue(item.depositTypeText) || (accountType ? accountTypeLabel(accountType) : undefined),
accountType: accountType || undefined,
}
}
@@ -396,16 +480,59 @@ export const orderFromApi = (value: unknown): OrderRow | null => {
: (nullableNumberValue(item.price) ?? nullableNumberValue(item.executedPrice))
return {
id: textValue(item.id, textValue(item.orderNumber, `${issue.code}-${item.orderedAt ?? ''}`)),
code: issue.code,
date: textValue(item.orderedAt, textValue(item.expiresAt)),
stock: issue.name || issue.code,
market: issue.market,
side,
kind,
quantity,
unexecutedQuantity,
executedQuantity,
price,
status,
orderNumber: textValue(item.orderNumber),
orderSubNo: textValue(item.orderSubNo),
tradeId: textValue(item.tradeId),
accountType: textValue(item.accountType, textValue(item.depositType)),
cancelable: typeof item.cancelable === 'boolean' ? item.cancelable : undefined,
correctable: typeof item.correctable === 'boolean' ? item.correctable : undefined,
}
}
export const orderDetailFromApi = (value: unknown): OrderDetail | null => {
const row = orderFromApi(value)
if (!row) return null
const item = asRecord(value)
return {
...row,
expiresAt: textValue(item.expiresAt) || undefined,
statusText: textValue(item.statusText) || undefined,
depositType: textValue(item.depositType) || undefined,
accountInformation: textValue(item.accountInformation) || undefined,
}
}
export const tradeRecordFromApi = (value: unknown): TradeRecordRow | null => {
const item = asRecord(value)
const issue = issueFrom(item.issue)
if (!issue.code) return null
const tradeDate = textValue(item.tradeDate)
const valueDate = textValue(item.valueDate)
const type = textValue(item.tradeRecordTypeCode, textValue(item.type, '取引'))
return {
id: textValue(item.id, [issue.code, tradeDate, valueDate, type].filter(Boolean).join(':')),
code: issue.code,
stock: issue.name || issue.code,
market: issue.market,
type,
quantity: nullableNumberValue(item.quantity),
price: nullableNumberValue(item.price),
amount: nullableNumberValue(item.amount),
tradeDate: tradeDate || undefined,
valueDate: valueDate || undefined,
accountType: textValue(item.accountType) || undefined,
settlementCurrencyCode: textValue(item.settlementCurrencyCode) || undefined,
}
}
@@ -17,6 +17,8 @@ import type {
CashOrderTerm,
CashOrderTriggerZone,
JsonRpcResponse,
MarketIndex,
OrderDetail,
OrderKind,
OrderPreview,
OrderRow,
@@ -24,6 +26,7 @@ import type {
RealtimePricePoint,
RpcMessage,
Stock,
TradeRecordRow,
TradeSide,
} from '../../types/trading'
import {
@@ -34,7 +37,9 @@ import {
isOrderPreview,
issueFrom,
marketDateKey,
marketIndexFromApi,
numberValue,
orderDetailFromApi,
orderFromApi,
orderHistoryKey,
orderHistoryResultNotice,
@@ -44,6 +49,7 @@ import {
stockFromBoard,
stockFromIssue,
stockFromPosition,
tradeRecordFromApi,
textValue,
type RecordLike,
} from './trading-data'
@@ -157,6 +163,8 @@ const displayApkOrderTermDate = (value: string) => {
}
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
const isUsMarket = (market: string) => usMarkets.has(market)
const usOrderMarkets = ['XNAS', 'XNYS', 'ARCX'] as const
const parseApkExchangeMarkets = (value: string) => {
const markets: CashOrderMarket[] = []
@@ -212,6 +220,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const holdingsMarketValue = ref(0)
const totalProfitLoss = ref(0)
const totalProfitLossRate = ref(0)
const marketIndexes = ref<MarketIndex[]>([])
const orders = ref<OrderRow[]>([])
const cancelingOrderKey = ref('')
const orderHistoryLoaded = ref(false)
@@ -522,7 +531,8 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const matchesQuery =
stock.name.toLowerCase().includes(query) ||
stock.code.includes(query) ||
stock.symbol.toLowerCase().includes(query)
stock.symbol.toLowerCase().includes(query) ||
stock.searchText?.toLowerCase().includes(query)
return matchesQuery && matchesFilters(stock)
})
: viewedStocks.value.filter(matchesFilters)
@@ -753,6 +763,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
price: stock.price || current?.price || 0,
change: stock.change || current?.change || 0,
changeAmount: stock.changeAmount || current?.changeAmount || 0,
searchText: stock.searchText || current?.searchText || '',
history: stock.price ? stock.history : (current?.history ?? stock.history),
box: stock.price ? stock.box : (current?.box ?? stock.box),
})
@@ -771,16 +782,19 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const loadOrderHistoryFromSdk = async () => {
orderHistoryLoaded.value = false
orderHistoryNotice.value = ''
const [openOrdersResult, executionsTodayResult] = await Promise.allSettled([
const orderResults = await Promise.allSettled([
rpcCallOptional<RecordLike>('orders.inquiry.open'),
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday'),
rpcCallOptional<RecordLike>('orders.inquiry.open', { market: 'XNAS' }),
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday', { market: 'XNAS' }),
...usOrderMarkets.flatMap((market) => [
rpcCallOptional<RecordLike>('orders.inquiry.open', { market }),
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday', { market }),
]),
])
const [openOrdersResult, executionsTodayResult] = orderResults
if (openOrdersResult.status === 'rejected' && executionsTodayResult.status === 'rejected') {
throw openOrdersResult.reason
}
const nextOrders = fulfilledValues([openOrdersResult, executionsTodayResult])
const nextOrders = fulfilledValues(orderResults)
.flatMap((orderList) => asArray(orderList.orders))
.map(orderFromApi)
.filter((order): order is OrderRow => Boolean(order))
@@ -790,9 +804,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
orders.value = [...deduped.values()]
orderHistoryLoaded.value = true
if (!orders.value.length) {
const notices = fulfilledValues([openOrdersResult, executionsTodayResult])
.map(orderHistoryResultNotice)
.filter(Boolean)
const notices = fulfilledValues(orderResults).map(orderHistoryResultNotice).filter(Boolean)
orderHistoryNotice.value = [...new Set(notices)].join(' / ')
}
}
@@ -822,11 +834,25 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const loadTradingData = async () => {
dataLoading.value = true
try {
const [assetsResult, ...positionResults] = await Promise.allSettled([
const [assetsResult, indexResult, ...positionResults] = await Promise.allSettled([
rpcCallOptional<RecordLike>('account.assets.current', undefined, 20_000),
rpcCallOptional<unknown[]>('market.index.major', undefined, 15_000),
rpcCallOptional<RecordLike>('account.positions.cash', undefined, 15_000),
rpcCallOptional<RecordLike>('account.positions.cash', { market: 'XNAS' }, 15_000),
...usOrderMarkets.map((market) =>
rpcCallOptional<RecordLike>('account.positions.cash', { market }, 15_000),
),
])
if (indexResult.status === 'fulfilled') {
marketIndexes.value = asArray(indexResult.value)
.map(marketIndexFromApi)
.filter((index): index is MarketIndex => Boolean(index))
} else {
marketIndexes.value = []
reportDataError(
errorMessage(indexResult.reason, '指数の取得に失敗しました'),
indexResult.reason,
)
}
const cashPositionLists = fulfilledValues(positionResults)
const cashPositions = cashPositionLists[0] ?? {}
const nextPositions = cashPositionLists
@@ -835,13 +861,26 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
.filter((position): position is Position => Boolean(position))
positions.value = nextPositions
mergeStocks(nextPositions.map(stockFromPosition))
const nextHoldingsMarketValue = numberValue(
cashPositions.totalMarketValue,
nextPositions.reduce((sum, position) => sum + position.marketValue, 0),
const summedHoldingsMarketValue = nextPositions.reduce(
(sum, position) => sum + position.marketValue,
0,
)
const summedProfitLoss = nextPositions.reduce((sum, position) => sum + position.profitLoss, 0)
const summedCostBasis = nextPositions.reduce(
(sum, position) => sum + (position.marketValue - position.profitLoss),
0,
)
const nextHoldingsMarketValue =
nextPositions.length > 0
? summedHoldingsMarketValue
: numberValue(cashPositions.totalMarketValue)
holdingsMarketValue.value = nextHoldingsMarketValue
totalProfitLoss.value = numberValue(cashPositions.totalProfitLoss)
totalProfitLossRate.value = numberValue(cashPositions.totalProfitLossRate)
totalProfitLoss.value =
nextPositions.length > 0 ? summedProfitLoss : numberValue(cashPositions.totalProfitLoss)
totalProfitLossRate.value =
nextPositions.length > 0 && summedCostBasis
? (summedProfitLoss / summedCostBasis) * 100
: numberValue(cashPositions.totalProfitLossRate)
const hasAccountAssets = assetsResult.status === 'fulfilled'
if (hasAccountAssets) {
@@ -1011,7 +1050,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
}
}
const searchIssues = async (query: string) => {
const suggestIssues = async (query: string) => {
if (!connected.value || query.trim().length < 2) return
const marketsToSearch =
marketFilter.value !== 'all'
@@ -1019,7 +1058,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
: searchableMarkets.filter((market) => market !== 'auto')
const results = await Promise.allSettled(
marketsToSearch.map((market) =>
rpcCall<RecordLike>('market.issue.search', { query, market, limit: 12 }),
rpcCall<RecordLike>('market.issue.suggest', { query, market, limit: 12 }),
),
)
const issues = fulfilledValues(results)
@@ -1121,6 +1160,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
orders.value = [
{
id: textValue(receipt.orderId, `ord-${Date.now()}`),
code: selectedStock.value.code,
date: textValue(receipt.acceptedAt, new Date().toLocaleString('ja-JP')),
stock: selectedStock.value.name,
market: selectedStock.value.market,
@@ -1147,7 +1187,9 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
try {
const params = {
orderNumber: order.orderNumber,
orderId: order.id,
orderId: order.orderSubNo || order.id,
issueCode: order.code,
market: order.market,
tradeId: order.tradeId || undefined,
}
await rpcCall('orders.cash.placeCancel', {
@@ -1163,6 +1205,85 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
}
}
const loadOrderDetail = async (order: OrderRow): Promise<OrderDetail> => {
if (!isUsMarket(order.market)) {
throw new Error('注文詳細は米国株のみ対応しています')
}
if (!order.orderNumber && !order.id && !order.orderSubNo) {
throw new Error('注文番号を取得できないため詳細を取得できません')
}
const detail = await rpcCall<RecordLike>('orders.inquiry.detail', {
orderNumber: order.orderNumber,
orderId: order.orderSubNo || order.id,
issueCode: order.code,
market: order.market,
})
const parsed = orderDetailFromApi(detail)
if (!parsed) throw new Error('注文詳細を読み取れませんでした')
return parsed
}
const loadTradeRecords = async (): Promise<TradeRecordRow[]> => {
const result = await rpcCall<RecordLike>('orders.inquiry.tradeRecords', { limit: 50 })
return asArray(result.records)
.map(tradeRecordFromApi)
.filter((record): record is TradeRecordRow => Boolean(record))
}
const loadPositionDetail = async (position: Position): Promise<Position> => {
if (!isUsMarket(position.market)) {
throw new Error('保有詳細は米国株のみ対応しています')
}
const detail = await rpcCall<RecordLike>('account.positions.cashDetail', {
issueCode: position.code,
market: position.market,
accountType: position.accountType,
limit: 1,
})
const parsed = asArray(detail.positions).map(positionFromApi)[0]
if (!parsed) throw new Error('保有詳細を読み取れませんでした')
return parsed
}
const orderCorrectionParams = (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
) => ({
orderNumber: order.orderNumber,
orderId: order.orderSubNo || order.id,
issueCode: order.code,
market: order.market,
quantity: draft.quantity,
priceCondition: draft.priceCondition,
price: draft.priceCondition === 'limit' ? draft.price : undefined,
orderMethod: 'normal',
})
const estimateOrderCorrection = async (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
): Promise<OrderPreview> => {
if (!isUsMarket(order.market)) throw new Error('注文訂正は米国株のみ対応しています')
const preview = await rpcCall<unknown>(
'orders.cash.estimateCorrection',
orderCorrectionParams(order, draft),
)
if (!isOrderPreview(preview)) throw new Error('注文訂正の見積を読み取れませんでした')
return preview
}
const placeOrderCorrection = async (
order: OrderRow,
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
) => {
if (!isUsMarket(order.market)) throw new Error('注文訂正は米国株のみ対応しています')
await rpcCall('orders.cash.placeCorrection', {
...orderCorrectionParams(order, draft),
allowTrading: true,
})
await loadTradingData()
}
const downloadCsv = () => {
const header = ['code', 'name', 'symbol', 'market', 'price'].join(',')
const rows = selectedStock.value.history.map((price) =>
@@ -1284,7 +1405,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
searchTimer = setTimeout(async () => {
const requestId = ++searchRequestId
try {
await searchIssues(query)
await suggestIssues(query)
} catch (cause) {
reportDataError(errorMessage(cause, '銘柄検索に失敗しました'), cause)
} finally {
@@ -1337,6 +1458,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
holdingsMarketValue,
totalProfitLoss,
totalProfitLossRate,
marketIndexes,
orders,
cancelingOrderKey,
orderHistoryLoaded,
@@ -1379,6 +1501,11 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
askPlaceOrder,
placeCashOrder,
cancelOrder,
loadOrderDetail,
loadTradeRecords,
loadPositionDetail,
estimateOrderCorrection,
placeOrderCorrection,
downloadCsv,
openTradeForStock,
openTradeForPosition,
+14 -9
View File
@@ -24,12 +24,12 @@ export const ui = {
panelHead: 'flex flex-wrap items-center justify-between gap-3',
eyebrow: 'mb-1 text-xs font-black uppercase text-[#9aa0a9]',
dashboardGrid:
'grid min-h-0 flex-1 grid-cols-1 items-stretch gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)] lg:grid-rows-[auto_minmax(0,1fr)] lg:gap-6 lg:overflow-hidden',
'grid min-h-0 flex-1 grid-cols-1 items-stretch gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)] lg:grid-rows-[auto_minmax(20rem,auto)_auto] lg:gap-6',
metricPanel:
'grid min-h-36 content-center gap-3 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:min-h-40 sm:p-7',
assetOverviewPanel:
'grid min-h-40 grid-cols-1 items-center gap-x-7 gap-y-5 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] sm:p-7',
assetOverviewHead: 'grid gap-2',
'grid min-h-48 grid-cols-1 items-center gap-x-7 gap-y-5 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 pb-8 shadow-lg shadow-black/15 sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] sm:p-7 sm:pb-10 lg:col-span-2',
assetOverviewHead: 'grid gap-3',
assetOverviewSubtext: 'text-sm font-semibold text-[#c3c7cf]',
assetBreakdownPanel: 'grid gap-3',
assetBreakdownTitle: 'text-xs font-extrabold text-[#9aa0a9]',
@@ -52,19 +52,24 @@ export const ui = {
assetBreakdownAmount: 'text-sm font-black text-[#e3e3e9]',
assetBreakdownRatio: 'text-xs font-semibold text-[#8f949d]',
holdingsPanel:
'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7',
'flex min-h-80 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7',
holdingsBody: 'flex min-h-0 flex-1 flex-col overflow-visible lg:overflow-hidden',
holdingsRows: 'grid min-h-0 flex-1 content-start overflow-y-auto',
holdingsHead:
'hidden grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 border-b border-[#33383f] py-3 text-xs font-extrabold text-[#8f949d] md:grid',
'hidden grid-cols-[1.6fr_0.7fr_0.6fr_0.9fr_0.9fr_auto] items-center gap-4 border-b border-[#33383f] py-3 text-xs font-extrabold text-[#8f949d] md:grid',
holdingRow:
'grid min-h-16 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-2xl bg-[#111418] px-3 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] md:grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] md:gap-4 md:bg-transparent',
'grid min-h-16 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-2xl bg-[#111418] px-3 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] md:grid-cols-[1.6fr_0.7fr_0.6fr_0.9fr_0.9fr_auto] md:gap-4 md:bg-transparent',
typePill: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs text-[#d3e3fd]',
muted: 'text-[#8f949d]',
portfolioHistory:
'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7',
'flex min-h-80 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7',
historyList: 'flex min-h-0 flex-1 flex-col overflow-visible lg:overflow-hidden',
historyRows: 'grid min-h-0 flex-1 content-start gap-5 overflow-y-auto lg:gap-7',
marketIndexPanel:
'grid gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7 lg:col-span-2',
marketIndexGrid: 'grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4',
marketIndexCard: 'grid min-h-28 content-center gap-2 rounded-2xl bg-[#111418] p-4',
marketIndexValue: 'break-words text-xl font-black text-[#e3e3e9]',
emptyState: 'flex flex-1 items-center justify-center py-8 text-center',
miniOrder: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3',
tradeLayout:
@@ -153,13 +158,13 @@ export const ui = {
'inline-flex min-h-10 items-center justify-center gap-2 rounded-full border border-[#4a5058] bg-transparent px-5 font-extrabold text-[#d3e3fd] transition hover:bg-[#263141]',
list: 'grid gap-2',
orderRow:
'grid grid-cols-2 items-center gap-3 rounded-[20px] bg-[#111418] p-4 md:grid-cols-[minmax(180px,1fr)_auto_auto_auto_auto_auto]',
'grid grid-cols-2 items-center gap-3 rounded-[20px] bg-[#111418] p-4 md:grid-cols-[minmax(180px,1fr)_auto_auto_auto_auto_minmax(12rem,auto)]',
statusBadge: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs font-black text-[#d3e3fd]',
pendingBadge: 'bg-[#4a3720] text-[#ffddb3]',
apiLayout: 'grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.9fr)] lg:gap-7',
settingsLayout: 'grid gap-7',
row: 'grid grid-cols-1 items-center gap-3 rounded-[20px] bg-[#111418] p-4 sm:grid-cols-[minmax(0,1fr)_auto]',
rowActions: 'flex gap-2',
rowActions: 'flex flex-wrap items-center justify-end gap-2',
keyRow: 'grid gap-3 rounded-[24px] bg-[#111418] p-4',
profileRow:
'grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-[20px] bg-[#111418] p-4 sm:grid-cols-[auto_minmax(0,1fr)_auto]',
+60 -1
View File
@@ -1,7 +1,16 @@
export type TradeSide = 'buy' | 'sell'
export type OrderKind = 'standard' | 's'
export type CashOrderAccountType = 'specific' | 'general' | 'growthInvestment' | 'nisa'
export type CashOrderMarket = 'auto' | 'XTKS' | 'XNAS' | 'XNYS' | 'ARCX' | 'STK'
export type CashOrderMarket =
| 'auto'
| 'XTKS'
| 'XNGO'
| 'XFKA'
| 'XSAP'
| 'XNAS'
| 'XNYS'
| 'ARCX'
| 'STK'
export type CashOrderPriceCondition =
| 'limit'
| 'limitAtOpen'
@@ -31,6 +40,11 @@ export type OrderPreview = {
}
side: string
quantity: number
price?: {
value: number | null
text: string
currency?: string
}
warnings: string[]
confirmationId?: string
message?: string
@@ -61,10 +75,24 @@ export type ChartNotice = {
detail?: string
}
export type MarketIndex = {
code?: string
name: string
value: number | null
valueText: string
change: number | null
changeText: string
changeRate: number | null
changeRateText: string
sign: 'positive' | 'negative' | 'zero'
timestamp?: string
}
export type Stock = {
code: string
name: string
symbol: string
searchText?: string
country: string
market: string
sector: string
@@ -89,16 +117,45 @@ export type Stock = {
export type OrderRow = {
id: string
code: string
date: string
stock: string
market: string
side: TradeSide
kind: OrderKind
quantity: number | null
unexecutedQuantity?: number | null
executedQuantity?: number | null
price: number | null
status: '注文中' | '約定済' | '取消済'
orderNumber?: string
orderSubNo?: string
tradeId?: string
accountType?: string
cancelable?: boolean
correctable?: boolean
}
export type OrderDetail = OrderRow & {
expiresAt?: string
statusText?: string
depositType?: string
accountInformation?: string
}
export type TradeRecordRow = {
id: string
code: string
stock: string
market: string
type: string
quantity: number | null
price: number | null
amount: number | null
tradeDate?: string
valueDate?: string
accountType?: string
settlementCurrencyCode?: string
}
export type Position = {
@@ -107,8 +164,10 @@ export type Position = {
market: string
quantity: number
avgPrice: number
currentPrice?: number | null
marketValue: number
profitLoss: number
profitLossRate: number
type?: string
accountType?: CashOrderAccountType | string
}