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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user