1514 lines
52 KiB
TypeScript
1514 lines
52 KiB
TypeScript
import { computed, ref, watch, type Ref } from 'vue'
|
|
import { createRpcSocket } from '../../api'
|
|
import {
|
|
cashOrderAccountTypeOptions as defaultCashOrderAccountTypeOptions,
|
|
cashOrderMarketOptions as defaultCashOrderMarketOptions,
|
|
searchableMarkets,
|
|
} from '../../constants/trade'
|
|
import { countryTimeZones, marketSessions, marketTimeZones } from '../../constants/market'
|
|
import type {
|
|
ChartMode,
|
|
ChartRange,
|
|
ChartNotice,
|
|
CashOrderAccountType,
|
|
CashOrderMarket,
|
|
CashOrderMethod,
|
|
CashOrderPriceCondition,
|
|
CashOrderTerm,
|
|
CashOrderTriggerZone,
|
|
JsonRpcResponse,
|
|
MarketIndex,
|
|
OrderDetail,
|
|
OrderKind,
|
|
OrderPreview,
|
|
OrderRow,
|
|
Position,
|
|
RealtimePricePoint,
|
|
RpcMessage,
|
|
Stock,
|
|
TradeRecordRow,
|
|
TradeSide,
|
|
} from '../../types/trading'
|
|
import {
|
|
asArray,
|
|
asRecord,
|
|
emptyStock,
|
|
fulfilledValues,
|
|
isOrderPreview,
|
|
issueFrom,
|
|
marketDateKey,
|
|
marketIndexFromApi,
|
|
numberValue,
|
|
orderDetailFromApi,
|
|
orderFromApi,
|
|
orderHistoryKey,
|
|
orderHistoryResultNotice,
|
|
chartNoticeFromIssueChart,
|
|
pricePointsFromIssueChart,
|
|
positionFromApi,
|
|
stockFromBoard,
|
|
stockFromIssue,
|
|
stockFromPosition,
|
|
tradeRecordFromApi,
|
|
textValue,
|
|
type RecordLike,
|
|
} from './trading-data'
|
|
|
|
type RpcResolver = {
|
|
resolve: (value: unknown) => void
|
|
reject: (reason: Error) => void
|
|
}
|
|
|
|
type CashOrderTermOption = {
|
|
label: string
|
|
value: CashOrderTerm
|
|
}
|
|
|
|
type CashOrderAccountTypeOption = {
|
|
label: string
|
|
value: CashOrderAccountType
|
|
}
|
|
|
|
type CashOrderMarketOption = {
|
|
label: string
|
|
value: CashOrderMarket
|
|
}
|
|
|
|
type CashOrderDateOption = {
|
|
label: string
|
|
value: string
|
|
}
|
|
|
|
const priceBasedCashOrderConditions = new Set<CashOrderPriceCondition>([
|
|
'limit',
|
|
'limitAtOpen',
|
|
'limitAtClose',
|
|
'limitIoc',
|
|
'funari',
|
|
])
|
|
|
|
const cashOrderPriceConditionRequiresPrice = (condition: CashOrderPriceCondition) =>
|
|
priceBasedCashOrderConditions.has(condition)
|
|
|
|
const chartRangeOptions = {
|
|
'1D': { period: 'minute', unit: 5, count: 120 },
|
|
'3D': { period: 'minute', unit: 15, count: 9999 },
|
|
'3M': { period: 'day', unit: 1, count: 93 },
|
|
'1Y': { period: 'day', unit: 1, count: 365 },
|
|
ALL: { period: 'month', unit: 1, count: 9999 },
|
|
} as const satisfies Record<
|
|
ChartRange,
|
|
{ period: 'minute' | 'day' | 'week' | 'month'; unit: number; count: number }
|
|
>
|
|
|
|
const timeZoneForStock = (stock: Stock) =>
|
|
marketTimeZones[stock.market.toUpperCase()] ?? countryTimeZones[stock.country] ?? null
|
|
|
|
const marketClockFormatter = (timeZone: string) =>
|
|
new Intl.DateTimeFormat('en-US', {
|
|
timeZone,
|
|
hourCycle: 'h23',
|
|
weekday: 'short',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
|
|
const marketClockParts = (timeZone: string, now = new Date()) => {
|
|
const parts = Object.fromEntries(
|
|
marketClockFormatter(timeZone)
|
|
.formatToParts(now)
|
|
.filter((part) => part.type !== 'literal')
|
|
.map((part) => [part.type, part.value]),
|
|
) as Partial<Record<'weekday' | 'hour' | 'minute', string>>
|
|
|
|
return {
|
|
weekday: parts.weekday ?? '',
|
|
hour: Number(parts.hour ?? 0),
|
|
minute: Number(parts.minute ?? 0),
|
|
}
|
|
}
|
|
|
|
const hasMarketOpenedToday = (market: string, timeZone: string) => {
|
|
const sessions = marketSessions[market.toUpperCase()]
|
|
const openMinutes = sessions?.[0]?.[0]
|
|
if (openMinutes == null) return true
|
|
|
|
const parts = marketClockParts(timeZone)
|
|
if (parts.weekday === 'Sat' || parts.weekday === 'Sun') return false
|
|
return parts.hour * 60 + parts.minute >= openMinutes
|
|
}
|
|
|
|
const isMarketSessionOpen = (market: string, timeZone: string) => {
|
|
const sessions = marketSessions[market.toUpperCase()]
|
|
if (!sessions?.length) return true
|
|
|
|
const parts = marketClockParts(timeZone)
|
|
if (parts.weekday === 'Sat' || parts.weekday === 'Sun') return false
|
|
|
|
const minutes = parts.hour * 60 + parts.minute
|
|
return sessions.some(([open, close]) => minutes >= open && minutes < close)
|
|
}
|
|
|
|
const normalizeApkOrderTermDate = (value: string) => {
|
|
const digits = value.replace(/\D/g, '')
|
|
if (digits.length === 6) return `20${digits}`
|
|
if (digits.length === 8) return digits
|
|
return ''
|
|
}
|
|
|
|
const displayApkOrderTermDate = (value: string) => {
|
|
const normalized = normalizeApkOrderTermDate(value)
|
|
if (!normalized) return value
|
|
return `${normalized.slice(0, 4)}/${normalized.slice(4, 6)}/${normalized.slice(6, 8)}`
|
|
}
|
|
|
|
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[] = []
|
|
for (let index = 0; index < value.length; index += 3) {
|
|
const code = value.slice(index, index + 3) as CashOrderMarket
|
|
if (searchableMarkets.includes(code) && !markets.includes(code)) markets.push(code)
|
|
}
|
|
return markets
|
|
}
|
|
|
|
const priceMatchesStep = (price: number, step: number) => {
|
|
if (!Number.isFinite(price) || !Number.isFinite(step) || step <= 0) return true
|
|
return Math.abs(price / step - Math.round(price / step)) < 1e-8
|
|
}
|
|
|
|
export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|
const selectedStockCode = ref('')
|
|
const selectedStockId = ref('')
|
|
const viewedStockCodes = ref<string[]>([])
|
|
const tradeSide = ref<TradeSide>('buy')
|
|
const orderKind = ref<OrderKind>('standard')
|
|
const cashOrderAccountType = ref<CashOrderAccountType>('specific')
|
|
const cashOrderMarket = ref<CashOrderMarket>('auto')
|
|
const cashOrderPriceCondition = ref<CashOrderPriceCondition>('market')
|
|
const cashOrderTerm = ref<CashOrderTerm>('day')
|
|
const cashOrderDateInput = ref('')
|
|
const cashOrderMethod = ref<CashOrderMethod>('normal')
|
|
const cashOrderTriggerZone = ref<CashOrderTriggerZone>('above')
|
|
const cashOrderTriggerPriceInput = ref('')
|
|
const cashOrderSecondaryPriceCondition = ref<CashOrderPriceCondition>('limit')
|
|
const cashOrderSecondaryPriceInput = ref('')
|
|
const quantityInput = ref('')
|
|
const priceInput = ref('')
|
|
const chartMode = ref<ChartMode>('line')
|
|
const chartRange = ref<ChartRange>('1D')
|
|
const showSearch = ref(false)
|
|
const searchQuery = ref('')
|
|
const countryFilter = ref('all')
|
|
const marketFilter = ref('all')
|
|
const showEstimateDialog = ref(false)
|
|
const showOrderDialog = ref(false)
|
|
const pendingCashEstimateId = ref<number | null>(null)
|
|
const lastCashEstimate = ref<OrderPreview | null>(null)
|
|
const lastCashEstimateKey = ref('')
|
|
const cashPreOrder = ref<RecordLike | null>(null)
|
|
const ws = ref<WebSocket | null>(null)
|
|
const rpcPending = new Map<number, RpcResolver>()
|
|
const sbiConnected = ref(false)
|
|
const dataLoading = ref(false)
|
|
const searchLoading = ref(false)
|
|
const totalAssetValueFromAssets = ref<number | null>(null)
|
|
const buyingPower = ref(0)
|
|
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)
|
|
const orderHistoryNotice = ref('')
|
|
const positions = ref<Position[]>([])
|
|
const stocks = ref<Stock[]>([])
|
|
const historicalPricePoints = ref<RealtimePricePoint[]>([])
|
|
const chartNotice = ref<ChartNotice | null>(null)
|
|
const realtimePricePoints = ref<RealtimePricePoint[]>([])
|
|
const pricePolling = ref(false)
|
|
let rpcId = 0
|
|
let boardPollingSubscriptionId = ''
|
|
let boardPollingRequestId = 0
|
|
let chartHistoryRequestId = 0
|
|
let cashPreOrderRequestId = 0
|
|
|
|
const maxRealtimePricePoints = 120
|
|
const errorMessage = (cause: unknown, fallback: string) =>
|
|
cause instanceof Error ? cause.message : fallback
|
|
|
|
const stockId = (stock: Pick<Stock, 'code' | 'market'>) =>
|
|
stock.market ? `${stock.market}:${stock.code}` : stock.code
|
|
|
|
const stockRefFromId = (id: string) => {
|
|
const normalized = id.trim()
|
|
const separator = normalized.indexOf(':')
|
|
if (separator <= 0) return { code: normalized, market: '' }
|
|
|
|
const market = normalized.slice(0, separator).toUpperCase()
|
|
const code = normalized.slice(separator + 1)
|
|
if (!searchableMarkets.includes(market as CashOrderMarket)) {
|
|
return { code: normalized, market: '' }
|
|
}
|
|
return { code, market }
|
|
}
|
|
|
|
const codeFromStockId = (id: string) => stockRefFromId(id).code
|
|
|
|
const reportDataError = (message: string, cause?: unknown) => {
|
|
if (cause) {
|
|
console.error(`[mnie-ui] データ取得エラー: ${message}`, cause)
|
|
return
|
|
}
|
|
console.error(`[mnie-ui] データ取得エラー: ${message}`)
|
|
}
|
|
|
|
const selectedStock = computed(() => {
|
|
const selectedRef = stockRefFromId(selectedStockId.value)
|
|
const exact =
|
|
stockById.value.get(selectedStockId.value) ??
|
|
stocks.value.find((stock) => stock.symbol === selectedStockId.value)
|
|
if (exact) return exact
|
|
|
|
if (!selectedRef.market) {
|
|
const codeMatch = stockByCode.value.get(selectedStockCode.value)
|
|
if (codeMatch) return codeMatch
|
|
}
|
|
|
|
if (selectedStockCode.value) {
|
|
return stockFromIssue({
|
|
code: selectedStockCode.value,
|
|
market: selectedRef.market,
|
|
name: selectedStockCode.value,
|
|
})
|
|
}
|
|
|
|
return stocks.value[0] ?? emptyStock
|
|
})
|
|
const socketReady = computed(() => ws.value?.readyState === WebSocket.OPEN)
|
|
const connected = computed(() => sbiConnected.value && socketReady.value)
|
|
const orderQuantity = computed(() => Number(quantityInput.value || 0))
|
|
const orderPrice = computed(() => Number(priceInput.value || selectedStock.value.price))
|
|
const cashOrderPrimaryRequiresPrice = computed(() =>
|
|
cashOrderPriceConditionRequiresPrice(cashOrderPriceCondition.value),
|
|
)
|
|
const cashOrderSecondaryRequiresPrice = computed(() =>
|
|
cashOrderPriceConditionRequiresPrice(cashOrderSecondaryPriceCondition.value),
|
|
)
|
|
const cashOrderTriggerPrice = computed(() => Number(cashOrderTriggerPriceInput.value || 0))
|
|
const cashOrderSecondaryPrice = computed(() => Number(cashOrderSecondaryPriceInput.value || 0))
|
|
const selectedStockIsUs = computed(() => usMarkets.has(selectedStock.value.market))
|
|
const resolvedCashOrderMarket = computed(() => {
|
|
return cashOrderMarket.value === 'auto' ? selectedStock.value.market : cashOrderMarket.value
|
|
})
|
|
const cashOrderKind = computed(() =>
|
|
orderKind.value === 'standard' ? undefined : orderKind.value,
|
|
)
|
|
const cashOrderPreOrderMarket = computed(() =>
|
|
cashOrderKind.value === 's' ? resolvedCashOrderMarket.value : undefined,
|
|
)
|
|
const cashOrderRequestMarket = computed(() =>
|
|
cashOrderKind.value === 's' ? 'STK' : resolvedCashOrderMarket.value,
|
|
)
|
|
const sKabuAvailable = computed(() => {
|
|
return true
|
|
})
|
|
const cashOrderIppanMarginPaymentLimit = computed(() => {
|
|
const margin = cashPreOrder.value ? asRecord(cashPreOrder.value.margin) : {}
|
|
return textValue(margin.ippanPaymentLimit) || undefined
|
|
})
|
|
const cashOrderAccountTypeOptions = computed<CashOrderAccountTypeOption[]>(() => {
|
|
const nisa = cashPreOrder.value ? asRecord(cashPreOrder.value.nisa) : {}
|
|
const options = defaultCashOrderAccountTypeOptions.filter(
|
|
(option) => option.value === 'specific' || option.value === 'general',
|
|
)
|
|
if (textValue(nisa.growthServiceKbn) === '1') {
|
|
const growth = defaultCashOrderAccountTypeOptions.find(
|
|
(option) => option.value === 'growthInvestment',
|
|
)
|
|
if (growth) options.push(growth)
|
|
}
|
|
if (textValue(nisa.serviceKbn) === '1') {
|
|
const nisaOption = defaultCashOrderAccountTypeOptions.find(
|
|
(option) => option.value === 'nisa',
|
|
)
|
|
if (nisaOption) options.push(nisaOption)
|
|
}
|
|
return options
|
|
})
|
|
const cashOrderMarketOptions = computed<CashOrderMarketOption[]>(() => {
|
|
if (selectedStockIsUs.value) {
|
|
return defaultCashOrderMarketOptions.filter(
|
|
(option) => option.value === 'auto' || option.value === selectedStock.value.market,
|
|
)
|
|
}
|
|
const exchangeList = textValue(cashPreOrder.value?.exchangeList)
|
|
if (!exchangeList) {
|
|
return defaultCashOrderMarketOptions.filter(
|
|
(option) => option.value === 'auto' || option.value === 'XTKS',
|
|
)
|
|
}
|
|
const markets = parseApkExchangeMarkets(exchangeList)
|
|
const options = markets
|
|
.map((market) => defaultCashOrderMarketOptions.find((option) => option.value === market))
|
|
.filter((option): option is CashOrderMarketOption => Boolean(option))
|
|
return options.length ? options : defaultCashOrderMarketOptions
|
|
})
|
|
const preferredCashOrderMarket = computed(() => {
|
|
if (selectedStockIsUs.value) return 'auto'
|
|
const index = Number(textValue(cashPreOrder.value?.exchangeListIndexFlag))
|
|
const indexedOption =
|
|
Number.isInteger(index) && index > 0 ? cashOrderMarketOptions.value[index - 1] : undefined
|
|
return (
|
|
indexedOption?.value ??
|
|
cashOrderMarketOptions.value.find((option) => option.value === selectedStock.value.market)
|
|
?.value ??
|
|
cashOrderMarketOptions.value[0]?.value ??
|
|
'auto'
|
|
)
|
|
})
|
|
const apkOrderTerms = computed(() =>
|
|
asArray(cashPreOrder.value?.orderTerms)
|
|
.map((value) => textValue(value))
|
|
.filter(Boolean),
|
|
)
|
|
const apkOrderTermDates = computed(() =>
|
|
asArray(cashPreOrder.value?.orderTermDates)
|
|
.map((value) => textValue(value))
|
|
.filter(Boolean),
|
|
)
|
|
const cashOrderTermOptions = computed<CashOrderTermOption[]>(() => {
|
|
if (selectedStockIsUs.value) return [{ label: '当日中', value: 'day' }]
|
|
const terms = apkOrderTerms.value
|
|
if (!terms.length) {
|
|
return [
|
|
{ label: '当日中', value: 'day' },
|
|
{ label: '今週中', value: 'week' },
|
|
{ label: '日付指定', value: 'date' },
|
|
]
|
|
}
|
|
const options: CashOrderTermOption[] = []
|
|
if (terms.some((term) => term === '当日中')) options.push({ label: '当日中', value: 'day' })
|
|
if (terms.some((term) => term === '今週中')) options.push({ label: '今週中', value: 'week' })
|
|
if (apkOrderTermDates.value.length || terms.some((term) => /\d/.test(term))) {
|
|
options.push({ label: '日付指定', value: 'date' })
|
|
}
|
|
return options.length ? options : [{ label: '当日中', value: 'day' }]
|
|
})
|
|
const cashOrderDateOptions = computed<CashOrderDateOption[]>(() => {
|
|
const dates = apkOrderTermDates.value.length
|
|
? apkOrderTermDates.value
|
|
: apkOrderTerms.value.filter((term) => /\d/.test(term))
|
|
return dates
|
|
.map((date) => ({
|
|
label: displayApkOrderTermDate(date),
|
|
value: normalizeApkOrderTermDate(date),
|
|
}))
|
|
.filter((option) => option.value)
|
|
})
|
|
const cashOrderPriceStep = computed(() => {
|
|
const steps = asArray(cashPreOrder.value?.priceSteps)
|
|
.map((value) => {
|
|
const record = asRecord(value)
|
|
return {
|
|
upper: numberValue(record.from),
|
|
step: numberValue(record.to),
|
|
}
|
|
})
|
|
.filter((step) => step.upper > 0 && step.step > 0)
|
|
.sort((left, right) => left.upper - right.upper)
|
|
if (!steps.length) return 1
|
|
const referencePrice = orderPrice.value > 0 ? orderPrice.value : selectedStock.value.price
|
|
return steps.find((step) => referencePrice <= step.upper)?.step ?? steps.at(-1)?.step ?? 1
|
|
})
|
|
const estimatedAmount = computed(() => Math.max(0, orderQuantity.value * orderPrice.value))
|
|
const hasQuote = (stock: Stock) => stock.price > 0
|
|
const hasAccountSummary = computed(
|
|
() =>
|
|
connected.value ||
|
|
Boolean(positions.value.length) ||
|
|
orderHistoryLoaded.value ||
|
|
holdingsMarketValue.value > 0 ||
|
|
buyingPower.value > 0,
|
|
)
|
|
const showPortfolioSpinner = computed(() => dataLoading.value || !hasAccountSummary.value)
|
|
const cashOrderKey = computed(() =>
|
|
JSON.stringify({
|
|
issueCode: selectedStock.value.code,
|
|
market: cashOrderRequestMarket.value,
|
|
side: tradeSide.value,
|
|
quantity: orderQuantity.value,
|
|
kind: cashOrderKind.value,
|
|
preOrderMarket: cashOrderPreOrderMarket.value,
|
|
accountType: cashOrderAccountType.value,
|
|
depositType: cashOrderAccountType.value,
|
|
priceCondition: cashOrderPriceCondition.value,
|
|
price: cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined,
|
|
orderTerm: cashOrderTerm.value,
|
|
orderDate: cashOrderTerm.value === 'date' ? cashOrderDateInput.value : undefined,
|
|
orderMethod: cashOrderMethod.value,
|
|
triggerZone: cashOrderMethod.value !== 'normal' ? cashOrderTriggerZone.value : undefined,
|
|
triggerPrice: cashOrderMethod.value !== 'normal' ? cashOrderTriggerPrice.value : undefined,
|
|
secondaryPriceCondition:
|
|
cashOrderMethod.value === 'oco' ? cashOrderSecondaryPriceCondition.value : undefined,
|
|
secondaryPrice:
|
|
cashOrderMethod.value === 'oco' && cashOrderSecondaryRequiresPrice.value
|
|
? cashOrderSecondaryPrice.value
|
|
: undefined,
|
|
ippanMarginPaymentLimit: cashOrderIppanMarginPaymentLimit.value,
|
|
}),
|
|
)
|
|
const canRequestCashEstimate = computed(() => {
|
|
if (!connected.value || !selectedStock.value.code || orderQuantity.value <= 0) return false
|
|
if (!resolvedCashOrderMarket.value) return false
|
|
if (cashOrderKind.value === 's' && selectedStockIsUs.value) return false
|
|
if (!sKabuAvailable.value) return false
|
|
if (cashOrderPrimaryRequiresPrice.value && orderPrice.value <= 0) return false
|
|
if (selectedStockIsUs.value && cashOrderMethod.value !== 'normal') return false
|
|
if (
|
|
cashOrderPrimaryRequiresPrice.value &&
|
|
!priceMatchesStep(orderPrice.value, cashOrderPriceStep.value)
|
|
) {
|
|
return false
|
|
}
|
|
if (cashOrderTerm.value === 'date' && !cashOrderDateInput.value) return false
|
|
if (cashOrderMethod.value !== 'normal' && cashOrderTriggerPrice.value <= 0) return false
|
|
if (
|
|
cashOrderMethod.value !== 'normal' &&
|
|
!priceMatchesStep(cashOrderTriggerPrice.value, cashOrderPriceStep.value)
|
|
) {
|
|
return false
|
|
}
|
|
if (
|
|
cashOrderMethod.value === 'oco' &&
|
|
cashOrderSecondaryRequiresPrice.value &&
|
|
cashOrderSecondaryPrice.value <= 0
|
|
) {
|
|
return false
|
|
}
|
|
if (
|
|
cashOrderMethod.value === 'oco' &&
|
|
cashOrderSecondaryRequiresPrice.value &&
|
|
!priceMatchesStep(cashOrderSecondaryPrice.value, cashOrderPriceStep.value)
|
|
) {
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
const canPlaceCashOrder = computed(
|
|
() =>
|
|
canRequestCashEstimate.value &&
|
|
Boolean(lastCashEstimate.value) &&
|
|
lastCashEstimateKey.value === cashOrderKey.value,
|
|
)
|
|
const countries = computed(() => [...new Set(stocks.value.map((stock) => stock.country))])
|
|
const markets = computed(() => [
|
|
...new Set([
|
|
...searchableMarkets.filter((market) => market !== 'auto'),
|
|
...stocks.value.map((stock) => stock.market).filter(Boolean),
|
|
]),
|
|
])
|
|
const stockById = computed(() => new Map(stocks.value.map((stock) => [stockId(stock), stock])))
|
|
const stockByCode = computed(() => new Map(stocks.value.map((stock) => [stock.code, stock])))
|
|
const viewedStocks = computed(() =>
|
|
viewedStockCodes.value
|
|
.map((code) => stockById.value.get(code) ?? stockByCode.value.get(code))
|
|
.filter((stock): stock is Stock => Boolean(stock)),
|
|
)
|
|
const filteredStocks = computed(() => {
|
|
const query = searchQuery.value.trim().toLowerCase()
|
|
const matchesFilters = (stock: Stock) => {
|
|
const matchesCountry = countryFilter.value === 'all' || stock.country === countryFilter.value
|
|
const matchesMarket = marketFilter.value === 'all' || stock.market === marketFilter.value
|
|
return matchesCountry && matchesMarket
|
|
}
|
|
const baseStocks = query
|
|
? stocks.value.filter((stock) => {
|
|
const matchesQuery =
|
|
stock.name.toLowerCase().includes(query) ||
|
|
stock.code.includes(query) ||
|
|
stock.symbol.toLowerCase().includes(query) ||
|
|
stock.searchText?.toLowerCase().includes(query)
|
|
return matchesQuery && matchesFilters(stock)
|
|
})
|
|
: viewedStocks.value.filter(matchesFilters)
|
|
|
|
if (!selectedStockCode.value) return baseStocks
|
|
|
|
const exists = baseStocks.some((stock) => stockId(stock) === selectedStockId.value)
|
|
const selected =
|
|
stockById.value.get(selectedStockId.value) ?? stockByCode.value.get(selectedStockCode.value)
|
|
if (!exists && selected && matchesFilters(selected)) {
|
|
return [selected, ...baseStocks]
|
|
}
|
|
return baseStocks
|
|
})
|
|
const selectedPosition = computed(() =>
|
|
positions.value.find(
|
|
(position) =>
|
|
position.code === selectedStock.value.code &&
|
|
position.market === selectedStock.value.market,
|
|
),
|
|
)
|
|
const recentOrders = computed(() => orders.value.slice(0, 2))
|
|
const totalAssetValue = computed(
|
|
() => totalAssetValueFromAssets.value ?? holdingsMarketValue.value + buyingPower.value,
|
|
)
|
|
const stockAssetRatio = computed(() => {
|
|
if (!totalAssetValue.value) return 0
|
|
return (holdingsMarketValue.value / totalAssetValue.value) * 100
|
|
})
|
|
const cashAssetRatio = computed(() => {
|
|
if (!totalAssetValue.value) return 0
|
|
return (buyingPower.value / totalAssetValue.value) * 100
|
|
})
|
|
const selectedStockTimeZone = computed(() => timeZoneForStock(selectedStock.value))
|
|
const chartPricePoints = computed(() => {
|
|
const points = [...historicalPricePoints.value, ...realtimePricePoints.value]
|
|
const timeZone = selectedStockTimeZone.value
|
|
|
|
if (chartRange.value !== '1D') {
|
|
return points
|
|
}
|
|
|
|
if (!timeZone) return points
|
|
|
|
const keyForPoint = (point: RealtimePricePoint) => {
|
|
const time = Date.parse(point.at)
|
|
return Number.isFinite(time) ? marketDateKey(timeZone, new Date(time)) : ''
|
|
}
|
|
const todayKey = marketDateKey(timeZone)
|
|
const latestHistorical = historicalPricePoints.value.reduce<{
|
|
key: string
|
|
time: number
|
|
} | null>((latest, point) => {
|
|
const time = Date.parse(point.at)
|
|
if (!Number.isFinite(time) || (latest && time <= latest.time)) return latest
|
|
return { key: marketDateKey(timeZone, new Date(time)), time }
|
|
}, null)
|
|
const hasHistoricalToday = historicalPricePoints.value.some(
|
|
(point) => keyForPoint(point) === todayKey,
|
|
)
|
|
const targetKey =
|
|
hasHistoricalToday || hasMarketOpenedToday(selectedStock.value.market, timeZone)
|
|
? todayKey
|
|
: (latestHistorical?.key ?? todayKey)
|
|
const targetPoints =
|
|
targetKey === todayKey
|
|
? [...historicalPricePoints.value, ...realtimePricePoints.value]
|
|
: historicalPricePoints.value
|
|
|
|
return targetPoints.filter((point) => {
|
|
return keyForPoint(point) === targetKey
|
|
})
|
|
})
|
|
const recordViewedStock = (code: string) => {
|
|
if (!code) return
|
|
viewedStockCodes.value = [code, ...viewedStockCodes.value.filter((entry) => entry !== code)]
|
|
}
|
|
|
|
const selectStock = (stock: Stock) => {
|
|
selectedStockCode.value = stock.code
|
|
selectedStockId.value = stockId(stock)
|
|
recordViewedStock(stockId(stock))
|
|
showSearch.value = false
|
|
lastCashEstimate.value = null
|
|
lastCashEstimateKey.value = ''
|
|
}
|
|
|
|
const selectStockByCode = (id: string) => {
|
|
if (!id) return
|
|
const code = codeFromStockId(id)
|
|
selectedStockCode.value = code
|
|
selectedStockId.value = id
|
|
recordViewedStock(id)
|
|
showSearch.value = false
|
|
lastCashEstimate.value = null
|
|
lastCashEstimateKey.value = ''
|
|
}
|
|
|
|
const rejectPendingRpc = (reason: Error) => {
|
|
for (const pending of rpcPending.values()) pending.reject(reason)
|
|
rpcPending.clear()
|
|
}
|
|
|
|
const appendRealtimePricePoint = (price: number, at = new Date()) => {
|
|
if (!Number.isFinite(price) || price <= 0) return
|
|
realtimePricePoints.value = [
|
|
...realtimePricePoints.value,
|
|
{ at: at.toISOString(), price, open: price, high: price, low: price, close: price },
|
|
].slice(-maxRealtimePricePoints)
|
|
}
|
|
|
|
const stopBoardPolling = () => {
|
|
const subscriptionId = boardPollingSubscriptionId
|
|
boardPollingSubscriptionId = ''
|
|
boardPollingRequestId += 1
|
|
pricePolling.value = false
|
|
if (subscriptionId) {
|
|
call('market.issue.pollBoard.unsubscribe', { subscriptionId })
|
|
}
|
|
}
|
|
|
|
const handleBoardPollingUpdate = (params: unknown) => {
|
|
const payload = asRecord(params)
|
|
const subscriptionId = textValue(payload.subscriptionId)
|
|
if (!subscriptionId || subscriptionId !== boardPollingSubscriptionId) return
|
|
|
|
const stock = stockFromBoard(payload.board, {
|
|
code: selectedStock.value.code,
|
|
market: selectedStock.value.market,
|
|
name: selectedStock.value.name,
|
|
})
|
|
if (stock.code && stock.code !== selectedStock.value.code) return
|
|
|
|
mergeStocks([stock])
|
|
const timeZone = selectedStockTimeZone.value
|
|
if (!timeZone || !isMarketSessionOpen(stock.market, timeZone)) {
|
|
stopBoardPolling()
|
|
return
|
|
}
|
|
appendRealtimePricePoint(stock.price)
|
|
}
|
|
|
|
const handleRpcMessage = (data: string) => {
|
|
let response: JsonRpcResponse
|
|
try {
|
|
response = JSON.parse(data) as JsonRpcResponse
|
|
} catch {
|
|
return
|
|
}
|
|
if (response.method === 'market.issue.pollBoard.update') {
|
|
handleBoardPollingUpdate(response.params)
|
|
return
|
|
}
|
|
if (response.method === 'market.issue.pollBoard.error') {
|
|
const payload = asRecord(response.params)
|
|
if (textValue(payload.subscriptionId) === boardPollingSubscriptionId) {
|
|
pricePolling.value = false
|
|
reportDataError(textValue(payload.message, '価格ポーリングに失敗しました'))
|
|
}
|
|
return
|
|
}
|
|
if (typeof response.id !== 'number') return
|
|
const pending = rpcPending.get(response.id)
|
|
if (!pending) return
|
|
rpcPending.delete(response.id)
|
|
if (response.error) {
|
|
pending.reject(new Error(response.error.message || 'RPC request failed'))
|
|
} else {
|
|
pending.resolve(response.result)
|
|
}
|
|
}
|
|
|
|
const call = (method: string, params?: unknown) => {
|
|
const socket = ws.value
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) return undefined
|
|
const payload: RpcMessage = { id: ++rpcId, method, params }
|
|
socket.send(JSON.stringify({ jsonrpc: '2.0', ...payload }))
|
|
return payload.id
|
|
}
|
|
|
|
const rpcCall = async <T>(method: string, params?: unknown): Promise<T> => {
|
|
const id = call(method, params)
|
|
if (!id) throw new Error('SBI session is not connected')
|
|
return new Promise<T>((resolve, reject) => {
|
|
rpcPending.set(id, {
|
|
resolve: (value) => resolve(value as T),
|
|
reject,
|
|
})
|
|
})
|
|
}
|
|
|
|
const rpcCallOptional = async <T>(
|
|
method: string,
|
|
params?: unknown,
|
|
timeoutMs = 8_000,
|
|
): Promise<T> => {
|
|
const id = call(method, params)
|
|
if (!id) throw new Error('SBI session is not connected')
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timeout = window.setTimeout(() => {
|
|
rpcPending.delete(id)
|
|
reject(new Error(`${method} timed out`))
|
|
}, timeoutMs)
|
|
rpcPending.set(id, {
|
|
resolve: (value) => {
|
|
window.clearTimeout(timeout)
|
|
resolve(value as T)
|
|
},
|
|
reject: (reason) => {
|
|
window.clearTimeout(timeout)
|
|
const message = reason instanceof Error ? reason.message : 'RPC request failed'
|
|
reject(new Error(`${method}: ${message}`))
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
const mergeStocks = (nextStocks: Stock[]) => {
|
|
const merged = new Map(stocks.value.map((stock) => [stockId(stock), stock]))
|
|
for (const stock of nextStocks) {
|
|
if (!stock.code) continue
|
|
const id = stockId(stock)
|
|
const current = merged.get(id)
|
|
merged.set(id, {
|
|
...current,
|
|
...stock,
|
|
name: stock.name || current?.name || stock.code,
|
|
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),
|
|
})
|
|
}
|
|
stocks.value = [...merged.values()]
|
|
if (!selectedStockCode.value) {
|
|
const nextCode = stocks.value[0]?.code ?? ''
|
|
if (nextCode) {
|
|
selectedStockCode.value = nextCode
|
|
selectedStockId.value = stockId(stocks.value[0] ?? { code: nextCode, market: '' })
|
|
recordViewedStock(selectedStockId.value)
|
|
}
|
|
}
|
|
}
|
|
|
|
const loadOrderHistoryFromSdk = async () => {
|
|
orderHistoryLoaded.value = false
|
|
orderHistoryNotice.value = ''
|
|
const orderResults = await Promise.allSettled([
|
|
rpcCallOptional<RecordLike>('orders.inquiry.open'),
|
|
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday'),
|
|
...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(orderResults)
|
|
.flatMap((orderList) => asArray(orderList.orders))
|
|
.map(orderFromApi)
|
|
.filter((order): order is OrderRow => Boolean(order))
|
|
|
|
const deduped = new Map<string, OrderRow>()
|
|
for (const order of nextOrders) deduped.set(orderHistoryKey(order), order)
|
|
orders.value = [...deduped.values()]
|
|
orderHistoryLoaded.value = true
|
|
if (!orders.value.length) {
|
|
const notices = fulfilledValues(orderResults).map(orderHistoryResultNotice).filter(Boolean)
|
|
orderHistoryNotice.value = [...new Set(notices)].join(' / ')
|
|
}
|
|
}
|
|
|
|
const optionalNumber = (value: unknown) => {
|
|
const parsed = numberValue(value, Number.NaN)
|
|
return Number.isFinite(parsed) ? parsed : null
|
|
}
|
|
|
|
const applyAccountAssets = (value: RecordLike) => {
|
|
const summary = asRecord(value.summary)
|
|
const summaryWithoutDeposit = asRecord(value.summaryWithoutDeposit)
|
|
const valuation = optionalNumber(summary.valuation)
|
|
const valuationWithoutDeposit = optionalNumber(summaryWithoutDeposit.valuation)
|
|
const profitLoss = optionalNumber(summary.profitLoss)
|
|
const profitLossRate = optionalNumber(summary.profitLossRate)
|
|
|
|
if (valuation !== null) totalAssetValueFromAssets.value = valuation
|
|
if (valuationWithoutDeposit !== null) holdingsMarketValue.value = valuationWithoutDeposit
|
|
if (valuation !== null && valuationWithoutDeposit !== null) {
|
|
buyingPower.value = Math.max(valuation - valuationWithoutDeposit, 0)
|
|
}
|
|
if (profitLoss !== null) totalProfitLoss.value = profitLoss
|
|
if (profitLossRate !== null) totalProfitLossRate.value = profitLossRate
|
|
}
|
|
|
|
const loadTradingData = async () => {
|
|
dataLoading.value = true
|
|
try {
|
|
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),
|
|
...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
|
|
.flatMap((list) => asArray(list.positions))
|
|
.map(positionFromApi)
|
|
.filter((position): position is Position => Boolean(position))
|
|
positions.value = nextPositions
|
|
mergeStocks(nextPositions.map(stockFromPosition))
|
|
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 =
|
|
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) {
|
|
applyAccountAssets(assetsResult.value)
|
|
} else {
|
|
totalAssetValueFromAssets.value = null
|
|
reportDataError(
|
|
errorMessage(assetsResult.reason, 'My資産の取得に失敗しました'),
|
|
assetsResult.reason,
|
|
)
|
|
}
|
|
|
|
const [orderHistoryResult, powerResult] = await Promise.allSettled([
|
|
loadOrderHistoryFromSdk(),
|
|
rpcCallOptional<RecordLike>('account.power.buyingPower'),
|
|
])
|
|
|
|
if (!hasAccountAssets && powerResult.status === 'fulfilled') {
|
|
buyingPower.value = numberValue(
|
|
powerResult.value.cashBuyingPower ?? powerResult.value.withdrawableAmount,
|
|
)
|
|
}
|
|
if (orderHistoryResult.status === 'rejected') {
|
|
reportDataError(
|
|
errorMessage(orderHistoryResult.reason, '取引履歴の取得に失敗しました'),
|
|
orderHistoryResult.reason,
|
|
)
|
|
}
|
|
|
|
const boards = await Promise.allSettled(
|
|
nextPositions.slice(0, 20).map((position) =>
|
|
rpcCallOptional<RecordLike>(
|
|
'market.issue.board',
|
|
{
|
|
issueCode: position.code,
|
|
market: position.market,
|
|
},
|
|
8_000,
|
|
),
|
|
),
|
|
)
|
|
mergeStocks(
|
|
boards.flatMap((result, index) =>
|
|
result.status === 'fulfilled'
|
|
? [
|
|
stockFromBoard(result.value, {
|
|
code: nextPositions[index]?.code ?? '',
|
|
market: nextPositions[index]?.market ?? '',
|
|
name: nextPositions[index]?.name ?? '',
|
|
}),
|
|
]
|
|
: [],
|
|
),
|
|
)
|
|
} finally {
|
|
dataLoading.value = false
|
|
}
|
|
}
|
|
|
|
const connect = () => {
|
|
const previousSocket = ws.value
|
|
rejectPendingRpc(new Error('RPC socket reconnecting'))
|
|
stopBoardPolling()
|
|
previousSocket?.close()
|
|
sbiConnected.value = false
|
|
dataLoading.value = true
|
|
if (!selectedPasskeyId.value) {
|
|
dataLoading.value = false
|
|
reportDataError('SBIパスキーを選択してください')
|
|
return
|
|
}
|
|
const socket = createRpcSocket()
|
|
socket.addEventListener('open', async () => {
|
|
try {
|
|
await rpcCall('sbi.connect', { passkeyId: selectedPasskeyId.value })
|
|
sbiConnected.value = true
|
|
await loadTradingData()
|
|
} catch (cause) {
|
|
sbiConnected.value = false
|
|
reportDataError(errorMessage(cause, '接続に失敗しました'), cause)
|
|
socket.close()
|
|
} finally {
|
|
dataLoading.value = false
|
|
}
|
|
})
|
|
socket.addEventListener('message', (event) => handleRpcMessage(String(event.data)))
|
|
socket.addEventListener('error', () => {
|
|
if (ws.value !== socket) return
|
|
reportDataError('SBI接続に失敗しました')
|
|
})
|
|
socket.addEventListener('close', () => {
|
|
if (ws.value !== socket) return
|
|
rejectPendingRpc(new Error('RPC socket closed'))
|
|
boardPollingSubscriptionId = ''
|
|
chartHistoryRequestId += 1
|
|
historicalPricePoints.value = []
|
|
chartNotice.value = null
|
|
realtimePricePoints.value = []
|
|
pricePolling.value = false
|
|
sbiConnected.value = false
|
|
dataLoading.value = false
|
|
})
|
|
ws.value = socket
|
|
}
|
|
|
|
const startBoardPolling = async () => {
|
|
stopBoardPolling()
|
|
realtimePricePoints.value = []
|
|
|
|
const stock = selectedStock.value
|
|
if (!connected.value || !stock.code) return
|
|
const timeZone = selectedStockTimeZone.value
|
|
if (!timeZone || !isMarketSessionOpen(stock.market, timeZone)) return
|
|
|
|
appendRealtimePricePoint(stock.price)
|
|
|
|
const requestId = ++boardPollingRequestId
|
|
try {
|
|
const subscribed = await rpcCall<RecordLike>('market.issue.pollBoard.subscribe', {
|
|
issueCode: stock.code,
|
|
market: stock.market,
|
|
})
|
|
if (requestId !== boardPollingRequestId) {
|
|
const staleSubscriptionId = textValue(subscribed.subscriptionId)
|
|
if (staleSubscriptionId) {
|
|
call('market.issue.pollBoard.unsubscribe', { subscriptionId: staleSubscriptionId })
|
|
}
|
|
return
|
|
}
|
|
boardPollingSubscriptionId = textValue(subscribed.subscriptionId)
|
|
pricePolling.value = Boolean(boardPollingSubscriptionId)
|
|
} catch (cause) {
|
|
if (requestId === boardPollingRequestId) {
|
|
pricePolling.value = false
|
|
reportDataError(errorMessage(cause, '価格ポーリングの開始に失敗しました'), cause)
|
|
}
|
|
}
|
|
}
|
|
|
|
const loadSelectedStockChart = async () => {
|
|
historicalPricePoints.value = []
|
|
chartNotice.value = null
|
|
const stock = selectedStock.value
|
|
if (!connected.value || !stock.code) return
|
|
|
|
const requestId = ++chartHistoryRequestId
|
|
const chartOptions = chartRangeOptions[chartRange.value]
|
|
try {
|
|
const timeZone = selectedStockTimeZone.value
|
|
if (!timeZone) {
|
|
throw new Error(`Unsupported market timezone for ${stock.market || stock.country}`)
|
|
}
|
|
const chart = await rpcCall<RecordLike>('market.issue.chart', {
|
|
issueCode: stock.code,
|
|
market: stock.market,
|
|
period: chartOptions.period,
|
|
unit: chartOptions.unit,
|
|
count: chartOptions.count,
|
|
})
|
|
if (requestId !== chartHistoryRequestId) return
|
|
historicalPricePoints.value = pricePointsFromIssueChart(chart, timeZone)
|
|
chartNotice.value = chartNoticeFromIssueChart(chart)
|
|
} catch (cause) {
|
|
if (requestId === chartHistoryRequestId) {
|
|
reportDataError(errorMessage(cause, '価格履歴の取得に失敗しました'), cause)
|
|
}
|
|
}
|
|
}
|
|
|
|
const suggestIssues = async (query: string) => {
|
|
if (!connected.value || query.trim().length < 2) return
|
|
const marketsToSearch =
|
|
marketFilter.value !== 'all'
|
|
? [marketFilter.value as CashOrderMarket]
|
|
: searchableMarkets.filter((market) => market !== 'auto')
|
|
const results = await Promise.allSettled(
|
|
marketsToSearch.map((market) =>
|
|
rpcCall<RecordLike>('market.issue.suggest', { query, market, limit: 12 }),
|
|
),
|
|
)
|
|
const issues = fulfilledValues(results)
|
|
.flatMap((result) => asArray(result.issues))
|
|
.map(issueFrom)
|
|
.filter((issue) => issue.code)
|
|
mergeStocks(issues.map(stockFromIssue))
|
|
const boards = await Promise.allSettled(
|
|
issues.map((issue) =>
|
|
rpcCall<RecordLike>('market.issue.board', {
|
|
issueCode: issue.code,
|
|
market: issue.market,
|
|
}).then((board) => stockFromBoard(board, issue)),
|
|
),
|
|
)
|
|
mergeStocks(
|
|
boards
|
|
.filter((result): result is PromiseFulfilledResult<Stock> => result.status === 'fulfilled')
|
|
.map((result) => result.value),
|
|
)
|
|
}
|
|
|
|
const estimateCashOrder = async () => {
|
|
if (!canRequestCashEstimate.value) return
|
|
lastCashEstimate.value = null
|
|
lastCashEstimateKey.value = ''
|
|
pendingCashEstimateId.value = null
|
|
const preview = await rpcCall<unknown>('orders.cash.estimate', cashOrderParams())
|
|
if (isOrderPreview(preview)) {
|
|
lastCashEstimate.value = preview
|
|
lastCashEstimateKey.value = cashOrderKey.value
|
|
showEstimateDialog.value = true
|
|
}
|
|
}
|
|
|
|
const cashOrderParams = () => ({
|
|
issueCode: selectedStock.value.code,
|
|
market: cashOrderRequestMarket.value,
|
|
side: tradeSide.value,
|
|
quantity: orderQuantity.value,
|
|
kind: cashOrderKind.value,
|
|
preOrderMarket: cashOrderPreOrderMarket.value,
|
|
accountType: cashOrderAccountType.value,
|
|
depositType: cashOrderAccountType.value,
|
|
price: cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined,
|
|
priceCondition: cashOrderPriceCondition.value,
|
|
orderTerm: cashOrderTerm.value,
|
|
orderDate: cashOrderTerm.value === 'date' ? cashOrderDateInput.value : undefined,
|
|
orderMethod: cashOrderMethod.value,
|
|
triggerZone: cashOrderMethod.value !== 'normal' ? cashOrderTriggerZone.value : undefined,
|
|
triggerPrice: cashOrderMethod.value !== 'normal' ? cashOrderTriggerPrice.value : undefined,
|
|
secondaryPriceCondition:
|
|
cashOrderMethod.value === 'oco' ? cashOrderSecondaryPriceCondition.value : undefined,
|
|
secondaryPrice:
|
|
cashOrderMethod.value === 'oco' && cashOrderSecondaryRequiresPrice.value
|
|
? cashOrderSecondaryPrice.value
|
|
: undefined,
|
|
ippanMarginPaymentLimit: cashOrderIppanMarginPaymentLimit.value,
|
|
})
|
|
|
|
const refreshCashPreOrder = async () => {
|
|
const requestId = ++cashPreOrderRequestId
|
|
if (!connected.value || !selectedStock.value.code || !resolvedCashOrderMarket.value) {
|
|
cashPreOrder.value = null
|
|
return
|
|
}
|
|
try {
|
|
const preOrder = await rpcCall<RecordLike>('orders.cash.preOrder', {
|
|
issueCode: selectedStock.value.code,
|
|
market: cashOrderRequestMarket.value,
|
|
side: tradeSide.value,
|
|
kind: cashOrderKind.value,
|
|
preOrderMarket: cashOrderPreOrderMarket.value,
|
|
accountType: cashOrderAccountType.value,
|
|
depositType: cashOrderAccountType.value,
|
|
})
|
|
if (requestId === cashPreOrderRequestId) cashPreOrder.value = preOrder
|
|
} catch (cause) {
|
|
if (requestId === cashPreOrderRequestId) {
|
|
cashPreOrder.value = null
|
|
reportDataError(errorMessage(cause, '注文前情報の取得に失敗しました'), cause)
|
|
}
|
|
}
|
|
}
|
|
|
|
const askPlaceOrder = () => {
|
|
if (!canPlaceCashOrder.value) return
|
|
showEstimateDialog.value = false
|
|
showOrderDialog.value = true
|
|
}
|
|
|
|
const placeCashOrder = async () => {
|
|
if (!canPlaceCashOrder.value || !lastCashEstimate.value) return
|
|
const receipt = await rpcCall<RecordLike>('orders.cash.place', {
|
|
...cashOrderParams(),
|
|
confirmationId: lastCashEstimate.value.confirmationId,
|
|
allowTrading: true,
|
|
})
|
|
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,
|
|
side: tradeSide.value,
|
|
kind: orderKind.value,
|
|
quantity: orderQuantity.value,
|
|
price: orderPrice.value,
|
|
status: '注文中',
|
|
},
|
|
...orders.value,
|
|
]
|
|
showOrderDialog.value = false
|
|
await loadTradingData()
|
|
}
|
|
|
|
const cancelOrder = async (order: OrderRow) => {
|
|
if (!order.orderNumber) {
|
|
reportDataError('注文番号を取得できないため取消できません')
|
|
return
|
|
}
|
|
const key = orderHistoryKey(order)
|
|
if (cancelingOrderKey.value) return
|
|
cancelingOrderKey.value = key
|
|
try {
|
|
const params = {
|
|
orderNumber: order.orderNumber,
|
|
orderId: order.orderSubNo || order.id,
|
|
issueCode: order.code,
|
|
market: order.market,
|
|
tradeId: order.tradeId || undefined,
|
|
}
|
|
await rpcCall('orders.cash.placeCancel', {
|
|
...params,
|
|
allowTrading: true,
|
|
})
|
|
order.status = '取消済'
|
|
await loadTradingData()
|
|
} catch (cause) {
|
|
reportDataError(errorMessage(cause, '注文取消に失敗しました'), cause)
|
|
} finally {
|
|
cancelingOrderKey.value = ''
|
|
}
|
|
}
|
|
|
|
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) =>
|
|
[
|
|
selectedStock.value.code,
|
|
selectedStock.value.name,
|
|
selectedStock.value.symbol,
|
|
selectedStock.value.market,
|
|
price,
|
|
]
|
|
.map((value) => `"${String(value).replaceAll('"', '""')}"`)
|
|
.join(','),
|
|
)
|
|
const blob = new Blob([[header, ...rows].join('\n')], { type: 'text/csv;charset=utf-8' })
|
|
const url = URL.createObjectURL(blob)
|
|
const anchor = document.createElement('a')
|
|
anchor.href = url
|
|
anchor.download = `${selectedStock.value.code}-history.csv`
|
|
anchor.click()
|
|
URL.revokeObjectURL(url)
|
|
}
|
|
|
|
const openTradeForStock = (stock: Stock, navigate: () => void) => {
|
|
selectStock(stock)
|
|
navigate()
|
|
}
|
|
|
|
const openTradeForPosition = (code: string, navigate: () => void) => {
|
|
const stock = stocks.value.find((candidate) => candidate.code === code)
|
|
openTradeForStock(stock ?? selectedStock.value, navigate)
|
|
}
|
|
|
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
let searchRequestId = 0
|
|
watch(orderKind, () => {
|
|
cashOrderMarket.value = 'auto'
|
|
cashOrderPriceCondition.value = 'market'
|
|
cashOrderTerm.value = 'day'
|
|
cashOrderDateInput.value = ''
|
|
cashOrderMethod.value = 'normal'
|
|
cashOrderTriggerPriceInput.value = ''
|
|
cashOrderSecondaryPriceInput.value = ''
|
|
priceInput.value = ''
|
|
})
|
|
|
|
watch(cashOrderPriceCondition, (condition) => {
|
|
if (!cashOrderPriceConditionRequiresPrice(condition)) priceInput.value = ''
|
|
})
|
|
|
|
watch(cashOrderSecondaryPriceCondition, (condition) => {
|
|
if (!cashOrderPriceConditionRequiresPrice(condition)) cashOrderSecondaryPriceInput.value = ''
|
|
})
|
|
|
|
watch(cashOrderTerm, (term) => {
|
|
if (term !== 'date') {
|
|
cashOrderDateInput.value = ''
|
|
return
|
|
}
|
|
const firstDate = cashOrderDateOptions.value[0]?.value
|
|
if (firstDate && !cashOrderDateInput.value) cashOrderDateInput.value = firstDate
|
|
})
|
|
|
|
watch(cashOrderAccountTypeOptions, (options) => {
|
|
if (options.some((option) => option.value === cashOrderAccountType.value)) return
|
|
cashOrderAccountType.value = options[0]?.value ?? 'specific'
|
|
})
|
|
|
|
watch(cashOrderMarketOptions, (options) => {
|
|
if (options.some((option) => option.value === cashOrderMarket.value)) return
|
|
cashOrderMarket.value = preferredCashOrderMarket.value
|
|
})
|
|
|
|
watch(cashOrderTermOptions, (options) => {
|
|
if (options.some((option) => option.value === cashOrderTerm.value)) return
|
|
cashOrderTerm.value = options[0]?.value ?? 'day'
|
|
})
|
|
|
|
watch(cashOrderDateOptions, (options) => {
|
|
if (cashOrderTerm.value !== 'date') return
|
|
const firstOption = options[0]
|
|
if (!firstOption) return
|
|
if (options.some((option) => option.value === cashOrderDateInput.value)) return
|
|
cashOrderDateInput.value = firstOption.value
|
|
})
|
|
|
|
watch(cashOrderMethod, (method) => {
|
|
if (method === 'normal') {
|
|
cashOrderTriggerPriceInput.value = ''
|
|
cashOrderSecondaryPriceInput.value = ''
|
|
return
|
|
}
|
|
if (method === 'stop') cashOrderSecondaryPriceInput.value = ''
|
|
})
|
|
|
|
watch(
|
|
[
|
|
connected,
|
|
() => selectedStock.value.code,
|
|
() => selectedStock.value.market,
|
|
resolvedCashOrderMarket,
|
|
tradeSide,
|
|
orderKind,
|
|
cashOrderAccountType,
|
|
],
|
|
() => {
|
|
void refreshCashPreOrder()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(searchQuery, (query) => {
|
|
clearTimeout(searchTimer)
|
|
const trimmed = query.trim()
|
|
if (trimmed.length < 2 || !connected.value) {
|
|
searchLoading.value = false
|
|
return
|
|
}
|
|
searchLoading.value = true
|
|
searchTimer = setTimeout(async () => {
|
|
const requestId = ++searchRequestId
|
|
try {
|
|
await suggestIssues(query)
|
|
} catch (cause) {
|
|
reportDataError(errorMessage(cause, '銘柄検索に失敗しました'), cause)
|
|
} finally {
|
|
if (requestId === searchRequestId) {
|
|
searchLoading.value = false
|
|
}
|
|
}
|
|
}, 350)
|
|
})
|
|
|
|
watch(
|
|
[connected, () => selectedStock.value.code, () => selectedStock.value.market, chartRange],
|
|
() => {
|
|
void loadSelectedStockChart()
|
|
void startBoardPolling()
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
return {
|
|
selectedStockCode,
|
|
selectedStockId,
|
|
tradeSide,
|
|
orderKind,
|
|
cashOrderAccountType,
|
|
cashOrderMarket,
|
|
cashOrderPriceCondition,
|
|
cashOrderTerm,
|
|
cashOrderDateInput,
|
|
cashOrderMethod,
|
|
cashOrderTriggerZone,
|
|
cashOrderTriggerPriceInput,
|
|
cashOrderSecondaryPriceCondition,
|
|
cashOrderSecondaryPriceInput,
|
|
quantityInput,
|
|
priceInput,
|
|
chartMode,
|
|
chartRange,
|
|
showSearch,
|
|
searchQuery,
|
|
countryFilter,
|
|
marketFilter,
|
|
showEstimateDialog,
|
|
showOrderDialog,
|
|
lastCashEstimate,
|
|
connected,
|
|
dataLoading,
|
|
searchLoading,
|
|
buyingPower,
|
|
holdingsMarketValue,
|
|
totalProfitLoss,
|
|
totalProfitLossRate,
|
|
marketIndexes,
|
|
orders,
|
|
cancelingOrderKey,
|
|
orderHistoryLoaded,
|
|
orderHistoryNotice,
|
|
positions,
|
|
realtimePricePoints,
|
|
chartPricePoints,
|
|
chartNotice,
|
|
pricePolling,
|
|
selectedStock,
|
|
orderQuantity,
|
|
orderPrice,
|
|
cashOrderPrimaryRequiresPrice,
|
|
cashOrderTriggerPrice,
|
|
cashOrderSecondaryPrice,
|
|
cashOrderAccountTypeOptions,
|
|
cashOrderMarketOptions,
|
|
cashOrderTermOptions,
|
|
cashOrderDateOptions,
|
|
cashOrderPriceStep,
|
|
estimatedAmount,
|
|
showPortfolioSpinner,
|
|
canRequestCashEstimate,
|
|
canPlaceCashOrder,
|
|
countries,
|
|
markets,
|
|
viewedStocks,
|
|
filteredStocks,
|
|
selectedPosition,
|
|
recentOrders,
|
|
totalAssetValue,
|
|
stockAssetRatio,
|
|
cashAssetRatio,
|
|
hasQuote,
|
|
selectStock,
|
|
selectStockByCode,
|
|
connect,
|
|
loadTradingData,
|
|
estimateCashOrder,
|
|
askPlaceOrder,
|
|
placeCashOrder,
|
|
cancelOrder,
|
|
loadOrderDetail,
|
|
loadTradeRecords,
|
|
loadPositionDetail,
|
|
estimateOrderCorrection,
|
|
placeOrderCorrection,
|
|
downloadCsv,
|
|
openTradeForStock,
|
|
openTradeForPosition,
|
|
}
|
|
}
|