fix
This commit is contained in:
@@ -19,5 +19,6 @@ CSBIE_SETUP_PASSWORD_HASH=
|
|||||||
SBI_AUTH_BASE_URL=
|
SBI_AUTH_BASE_URL=
|
||||||
SBI_MTS_BASE_URL=
|
SBI_MTS_BASE_URL=
|
||||||
SBI_IZANAGI_BASE_URL=
|
SBI_IZANAGI_BASE_URL=
|
||||||
|
SBI_MAIN_SITE_BASE_URL=
|
||||||
SBI_TRADE_PASSWORD=
|
SBI_TRADE_PASSWORD=
|
||||||
SBI_DEVICE_ID=
|
SBI_DEVICE_ID=
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
- Run `bun fmt` and `bun typecheck` before finishing.
|
- Run `bun fmt` and `bun typecheck` before finishing.
|
||||||
- Do not hardcode real endpoint
|
- Do not hardcode real endpoint
|
||||||
|
- origin のみ。パスは許可。
|
||||||
|
|
||||||
## Hints
|
## Hints
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,18 @@ export type ServerConfig = {
|
|||||||
authBaseUrl?: string
|
authBaseUrl?: string
|
||||||
mtsBaseUrl?: string
|
mtsBaseUrl?: string
|
||||||
izanagiBaseUrl?: string
|
izanagiBaseUrl?: string
|
||||||
|
foreignStockBaseUrl?: string
|
||||||
|
usStockBaseUrl?: string
|
||||||
|
foreignStockRestUrl?: string
|
||||||
|
foreignStockGraphqlBffUrl?: string
|
||||||
|
foreignStockGraphqlIntUrl?: string
|
||||||
|
mainSiteBaseUrl?: string
|
||||||
|
mainSiteEtGatePath?: string
|
||||||
|
mainSiteAssetsValuationsPath?: string
|
||||||
|
mainSiteExchangeOrderInputPath?: string
|
||||||
|
mainSiteExchangeOrderPasswordPath?: string
|
||||||
|
mainSiteExchangeOrderConfirmPath?: string
|
||||||
|
mainSiteExchangeOrderCompletePath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const optionalUrl = (value: string | undefined) => {
|
const optionalUrl = (value: string | undefined) => {
|
||||||
@@ -38,5 +50,17 @@ export const loadConfig = (): ServerConfig => {
|
|||||||
authBaseUrl: optionalUrl(process.env.SBI_AUTH_BASE_URL),
|
authBaseUrl: optionalUrl(process.env.SBI_AUTH_BASE_URL),
|
||||||
mtsBaseUrl: optionalUrl(process.env.SBI_MTS_BASE_URL),
|
mtsBaseUrl: optionalUrl(process.env.SBI_MTS_BASE_URL),
|
||||||
izanagiBaseUrl: optionalUrl(process.env.SBI_IZANAGI_BASE_URL),
|
izanagiBaseUrl: optionalUrl(process.env.SBI_IZANAGI_BASE_URL),
|
||||||
|
foreignStockBaseUrl: optionalUrl(process.env.SBI_FOREIGN_STOCK_BASE_URL),
|
||||||
|
usStockBaseUrl: optionalUrl(process.env.SBI_US_STOCK_BASE_URL),
|
||||||
|
foreignStockRestUrl: optionalUrl(process.env.SBI_FOREIGN_STOCK_REST_URL),
|
||||||
|
foreignStockGraphqlBffUrl: optionalUrl(process.env.SBI_FOREIGN_STOCK_GRAPHQL_BFF_URL),
|
||||||
|
foreignStockGraphqlIntUrl: optionalUrl(process.env.SBI_FOREIGN_STOCK_GRAPHQL_INT_URL),
|
||||||
|
mainSiteBaseUrl: optionalUrl(process.env.SBI_MAIN_SITE_BASE_URL),
|
||||||
|
mainSiteEtGatePath: process.env.SBI_MAIN_SITE_ET_GATE_PATH,
|
||||||
|
mainSiteAssetsValuationsPath: process.env.SBI_MAIN_SITE_ASSETS_VALUATIONS_PATH,
|
||||||
|
mainSiteExchangeOrderInputPath: process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_INPUT_PATH,
|
||||||
|
mainSiteExchangeOrderPasswordPath: process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_PASSWORD_PATH,
|
||||||
|
mainSiteExchangeOrderConfirmPath: process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_CONFIRM_PATH,
|
||||||
|
mainSiteExchangeOrderCompletePath: process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_COMPLETE_PATH,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ const orderSubmitMethodByEstimateMethod = {
|
|||||||
'orders.ifd.estimateCorrection': 'orders.ifd.placeCorrection',
|
'orders.ifd.estimateCorrection': 'orders.ifd.placeCorrection',
|
||||||
'orders.ifd.estimateCancel': 'orders.ifd.placeCancel',
|
'orders.ifd.estimateCancel': 'orders.ifd.placeCancel',
|
||||||
'orders.themeInvestment.estimate': 'orders.themeInvestment.place',
|
'orders.themeInvestment.estimate': 'orders.themeInvestment.place',
|
||||||
|
'orders.exchange.estimate': 'orders.exchange.place',
|
||||||
} as const satisfies Partial<Record<RpcMethod, RpcMethod>>
|
} as const satisfies Partial<Record<RpcMethod, RpcMethod>>
|
||||||
|
|
||||||
const submitMethodForEstimateMethod = (method: RpcMethod) =>
|
const submitMethodForEstimateMethod = (method: RpcMethod) =>
|
||||||
@@ -567,9 +568,39 @@ const placeThemeInvestmentOrderSchema = themeInvestmentOrderSchema.extend({
|
|||||||
.describe('Explicitly allows sending a live theme investment order'),
|
.describe('Explicitly allows sending a live theme investment order'),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const exchangeOrderSchema = z.object({
|
||||||
|
currencyCode: z.string().min(1).describe('Currency code, such as USD'),
|
||||||
|
side: z.enum(['buy', 'sell']).describe('Buy or sell the foreign currency'),
|
||||||
|
tradeQuantity: z.union([z.number().positive(), z.string().min(1)]).describe('Order quantity'),
|
||||||
|
specificMethod: z
|
||||||
|
.enum(['foreign', 'domestic'])
|
||||||
|
.optional()
|
||||||
|
.describe('foreign for foreign-currency quantity, domestic for yen amount'),
|
||||||
|
accountKind: z.enum(['GENERAL', 'JR_NISA']).optional().describe('SBI exchange account kind'),
|
||||||
|
sellMethod: z.enum(['SELL_PART', 'SELL_ALL']).optional().describe('Required for sell orders'),
|
||||||
|
orderAmount: z
|
||||||
|
.union([z.number().positive(), z.string().min(1)])
|
||||||
|
.optional()
|
||||||
|
.describe('Hidden order amount posted to SBI; required for domestic orders'),
|
||||||
|
tradePassword: z.string().optional().describe('Trading password used by SBI'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const exchangeRateSchema = exchangeOrderSchema.pick({
|
||||||
|
currencyCode: true,
|
||||||
|
side: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const placeExchangeOrderSchema = exchangeOrderSchema.extend({
|
||||||
|
allowTrading: z
|
||||||
|
.literal(true)
|
||||||
|
.optional()
|
||||||
|
.describe('Explicitly allows sending a live exchange order'),
|
||||||
|
})
|
||||||
|
|
||||||
const methodParamSchemas = {
|
const methodParamSchemas = {
|
||||||
'session.profile': undefined,
|
'session.profile': undefined,
|
||||||
'account.profile': undefined,
|
'account.profile': undefined,
|
||||||
|
'account.assets.current': undefined,
|
||||||
'account.power.buyingPower': undefined,
|
'account.power.buyingPower': undefined,
|
||||||
'account.power.collateralRatio': undefined,
|
'account.power.collateralRatio': undefined,
|
||||||
'account.positions.cash': cashPositionOptionsSchema.optional(),
|
'account.positions.cash': cashPositionOptionsSchema.optional(),
|
||||||
@@ -629,6 +660,9 @@ const methodParamSchemas = {
|
|||||||
'orders.themeInvestment.list': themeInvestmentPreOrderSchema,
|
'orders.themeInvestment.list': themeInvestmentPreOrderSchema,
|
||||||
'orders.themeInvestment.estimate': themeInvestmentOrderSchema,
|
'orders.themeInvestment.estimate': themeInvestmentOrderSchema,
|
||||||
'orders.themeInvestment.place': placeThemeInvestmentOrderSchema,
|
'orders.themeInvestment.place': placeThemeInvestmentOrderSchema,
|
||||||
|
'orders.exchange.rate': exchangeRateSchema,
|
||||||
|
'orders.exchange.estimate': exchangeOrderSchema,
|
||||||
|
'orders.exchange.place': placeExchangeOrderSchema,
|
||||||
} satisfies Record<RpcMethod, z.ZodType | undefined>
|
} satisfies Record<RpcMethod, z.ZodType | undefined>
|
||||||
|
|
||||||
const createMcpServer = (c: Context<AppBindings>) => {
|
const createMcpServer = (c: Context<AppBindings>) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { SbiClientMethods } from '@repo/sbi-client'
|
|||||||
export const RPC_METHODS = [
|
export const RPC_METHODS = [
|
||||||
'session.profile',
|
'session.profile',
|
||||||
'account.profile',
|
'account.profile',
|
||||||
|
'account.assets.current',
|
||||||
'account.power.buyingPower',
|
'account.power.buyingPower',
|
||||||
'account.power.collateralRatio',
|
'account.power.collateralRatio',
|
||||||
'account.positions.cash',
|
'account.positions.cash',
|
||||||
@@ -62,6 +63,9 @@ export const RPC_METHODS = [
|
|||||||
'orders.themeInvestment.list',
|
'orders.themeInvestment.list',
|
||||||
'orders.themeInvestment.estimate',
|
'orders.themeInvestment.estimate',
|
||||||
'orders.themeInvestment.place',
|
'orders.themeInvestment.place',
|
||||||
|
'orders.exchange.rate',
|
||||||
|
'orders.exchange.estimate',
|
||||||
|
'orders.exchange.place',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export type RpcMethod = (typeof RPC_METHODS)[number]
|
export type RpcMethod = (typeof RPC_METHODS)[number]
|
||||||
@@ -80,6 +84,7 @@ const tradingMethods = new Set<string>([
|
|||||||
'orders.ifd.placeCorrection',
|
'orders.ifd.placeCorrection',
|
||||||
'orders.ifd.placeCancel',
|
'orders.ifd.placeCancel',
|
||||||
'orders.themeInvestment.place',
|
'orders.themeInvestment.place',
|
||||||
|
'orders.exchange.place',
|
||||||
])
|
])
|
||||||
|
|
||||||
const cashOrderMethods = new Set<string>([
|
const cashOrderMethods = new Set<string>([
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ export const connectSbi = async (
|
|||||||
authBaseUrl: config.authBaseUrl,
|
authBaseUrl: config.authBaseUrl,
|
||||||
mtsBaseUrl: config.mtsBaseUrl,
|
mtsBaseUrl: config.mtsBaseUrl,
|
||||||
izanagiBaseUrl: config.izanagiBaseUrl,
|
izanagiBaseUrl: config.izanagiBaseUrl,
|
||||||
|
foreignStockBaseUrl: config.foreignStockBaseUrl,
|
||||||
|
usStockBaseUrl: config.usStockBaseUrl,
|
||||||
|
foreignStockRestUrl: config.foreignStockRestUrl,
|
||||||
|
foreignStockGraphqlBffUrl: config.foreignStockGraphqlBffUrl,
|
||||||
|
foreignStockGraphqlIntUrl: config.foreignStockGraphqlIntUrl,
|
||||||
|
mainSiteBaseUrl: config.mainSiteBaseUrl,
|
||||||
|
mainSiteEtGatePath: config.mainSiteEtGatePath,
|
||||||
|
mainSiteAssetsValuationsPath: config.mainSiteAssetsValuationsPath,
|
||||||
|
mainSiteExchangeOrderInputPath: config.mainSiteExchangeOrderInputPath,
|
||||||
|
mainSiteExchangeOrderPasswordPath: config.mainSiteExchangeOrderPasswordPath,
|
||||||
|
mainSiteExchangeOrderConfirmPath: config.mainSiteExchangeOrderConfirmPath,
|
||||||
|
mainSiteExchangeOrderCompletePath: config.mainSiteExchangeOrderCompletePath,
|
||||||
},
|
},
|
||||||
clientOptions,
|
clientOptions,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SbiClientMethods } from '@repo/sbi-client'
|
import type { MarketCode, SbiClientMethods } from '@repo/sbi-client'
|
||||||
import { createBunWebSocket } from 'hono/bun'
|
import { createBunWebSocket } from 'hono/bun'
|
||||||
import type { WSContext } from 'hono/ws'
|
import type { WSContext } from 'hono/ws'
|
||||||
import { randomUUID } from 'node:crypto'
|
import { randomUUID } from 'node:crypto'
|
||||||
@@ -27,7 +27,7 @@ type RpcSocketState = {
|
|||||||
|
|
||||||
type BoardPollingParams = {
|
type BoardPollingParams = {
|
||||||
issueCode: string
|
issueCode: string
|
||||||
market?: string
|
market: MarketCode
|
||||||
intervalSeconds?: number
|
intervalSeconds?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,8 +67,11 @@ const parseBoardPollingParams = (params: unknown): BoardPollingParams => {
|
|||||||
if (typeof value.issueCode !== 'string' || !value.issueCode) {
|
if (typeof value.issueCode !== 'string' || !value.issueCode) {
|
||||||
throw new Error('issueCode is required')
|
throw new Error('issueCode is required')
|
||||||
}
|
}
|
||||||
if (value.market != null && typeof value.market !== 'string') {
|
if (typeof value.market !== 'string' || !value.market) {
|
||||||
throw new Error('market must be a string')
|
throw new Error('market is required')
|
||||||
|
}
|
||||||
|
if (!['XTKS', 'XNAS', 'XNYS', 'ARCX'].includes(value.market)) {
|
||||||
|
throw new Error('market must be XTKS, XNAS, XNYS, or ARCX')
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
value.intervalSeconds != null &&
|
value.intervalSeconds != null &&
|
||||||
@@ -81,7 +84,7 @@ const parseBoardPollingParams = (params: unknown): BoardPollingParams => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
issueCode: value.issueCode,
|
issueCode: value.issueCode,
|
||||||
market: typeof value.market === 'string' ? value.market : undefined,
|
market: value.market as MarketCode,
|
||||||
intervalSeconds: typeof value.intervalSeconds === 'number' ? value.intervalSeconds : undefined,
|
intervalSeconds: typeof value.intervalSeconds === 'number' ? value.intervalSeconds : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
- 無駄な情報は入れない。プロンプトの内容に関連付けた内容も入れない。
|
||||||
|
- 開発者向けではなく、ユーザー視点で UI を構築する
|
||||||
|
- 重複した UI の内容は削除
|
||||||
|
- 重複するなら積極的に削除
|
||||||
|
- 明らかにわかる情報はいれない
|
||||||
|
- 変な英訳も入れない
|
||||||
|
- Tailwind
|
||||||
|
- Tailwind のデフォルトの数値を優先
|
||||||
|
- bg-[#xxxxxx] は控え、代わりに bg-xxx-xxx を使用する
|
||||||
|
- もし使いたい色があるのなら @color で css に定義してから使う
|
||||||
|
- rounded-[xxpx] の代わりに rounded-lg, md, ... などをつかう
|
||||||
|
- その他も同様
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>CSBIE</title>
|
<title>cSBIe</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AnimatePresence } from 'motion-v'
|
import { AnimatePresence } from 'motion-v'
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { revokeApiKey } from './api'
|
import { revokeApiKey } from './api'
|
||||||
import AppHeader from './components/layout/AppHeader.vue'
|
import AppHeader from './components/layout/AppHeader.vue'
|
||||||
@@ -16,6 +16,7 @@ import PortfolioView from './features/portfolio/PortfolioView.vue'
|
|||||||
import SettingsView from './features/settings/SettingsView.vue'
|
import SettingsView from './features/settings/SettingsView.vue'
|
||||||
import TradeView from './features/trade/TradeView.vue'
|
import TradeView from './features/trade/TradeView.vue'
|
||||||
import { useTradingSession } from './features/trading/useTradingSession'
|
import { useTradingSession } from './features/trading/useTradingSession'
|
||||||
|
import { stockIdFromTradeRouteId, tradeRouteIdFromStockId } from './constants/trade'
|
||||||
import { routeNames, type RouteName } from './router'
|
import { routeNames, type RouteName } from './router'
|
||||||
import { ui } from './styles/ui'
|
import { ui } from './styles/ui'
|
||||||
|
|
||||||
@@ -25,8 +26,26 @@ const activeTab = computed<RouteName>(() => {
|
|||||||
return routeNames.includes(route.name as RouteName) ? (route.name as RouteName) : 'portfolio'
|
return routeNames.includes(route.name as RouteName) ? (route.name as RouteName) : 'portfolio'
|
||||||
})
|
})
|
||||||
const showAuthGate = computed(() => true)
|
const showAuthGate = computed(() => true)
|
||||||
|
const decodedRouteParam = (value: string) => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value)
|
||||||
|
} catch {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const tradePath = (id: string) => `/trade/${encodeURIComponent(id)}`
|
||||||
|
const tradeId = computed(() =>
|
||||||
|
typeof route.params.id === 'string'
|
||||||
|
? stockIdFromTradeRouteId(decodedRouteParam(route.params.id))
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
const navigateTrade = (id = selectedStock.value.symbol || selectedStockCode.value) => {
|
||||||
|
if (!id) return
|
||||||
|
void router.push(tradePath(tradeRouteIdFromStockId(id)))
|
||||||
|
}
|
||||||
const navigate = (name: RouteName) => {
|
const navigate = (name: RouteName) => {
|
||||||
if (name === 'settings') return router.push('/settings/api-keys')
|
if (name === 'settings') return router.push('/settings/api-keys')
|
||||||
|
if (name === 'trade') return navigateTrade()
|
||||||
return router.push({ name })
|
return router.push({ name })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,6 +74,7 @@ const {
|
|||||||
|
|
||||||
const {
|
const {
|
||||||
selectedStockCode,
|
selectedStockCode,
|
||||||
|
selectedStockId,
|
||||||
tradeSide,
|
tradeSide,
|
||||||
orderKind,
|
orderKind,
|
||||||
cashOrderAccountType,
|
cashOrderAccountType,
|
||||||
@@ -86,6 +106,7 @@ const {
|
|||||||
totalProfitLoss,
|
totalProfitLoss,
|
||||||
totalProfitLossRate,
|
totalProfitLossRate,
|
||||||
orders,
|
orders,
|
||||||
|
cancelingOrderKey,
|
||||||
orderHistoryLoaded,
|
orderHistoryLoaded,
|
||||||
orderHistoryNotice,
|
orderHistoryNotice,
|
||||||
positions,
|
positions,
|
||||||
@@ -118,6 +139,7 @@ const {
|
|||||||
cashAssetRatio,
|
cashAssetRatio,
|
||||||
hasQuote,
|
hasQuote,
|
||||||
selectStock,
|
selectStock,
|
||||||
|
selectStockByCode,
|
||||||
connect,
|
connect,
|
||||||
loadTradingData,
|
loadTradingData,
|
||||||
estimateCashOrder,
|
estimateCashOrder,
|
||||||
@@ -149,6 +171,25 @@ const revokeAndRefresh = async (id: string) => {
|
|||||||
await refresh()
|
await refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
tradeId,
|
||||||
|
(id) => {
|
||||||
|
if (activeTab.value !== 'trade' || !id) return
|
||||||
|
selectStockByCode(id)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[activeTab, () => selectedStock.value.symbol, selectedStockId, selectedStockCode],
|
||||||
|
([tab]) => {
|
||||||
|
const id = selectedStock.value.symbol || selectedStockId.value || selectedStockCode.value
|
||||||
|
if (tab !== 'trade' || !id || tradeId.value === id) return
|
||||||
|
void router.replace(tradePath(tradeRouteIdFromStockId(id)))
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await refreshAndMaybeConnect()
|
await refreshAndMaybeConnect()
|
||||||
@@ -164,7 +205,7 @@ onMounted(async () => {
|
|||||||
<AppSidebar :active-tab="activeTab" @navigate="navigate" />
|
<AppSidebar :active-tab="activeTab" @navigate="navigate" />
|
||||||
|
|
||||||
<section :class="ui.workspace">
|
<section :class="ui.workspace">
|
||||||
<AppHeader :active-tab="activeTab" />
|
<AppHeader v-if="activeTab !== 'settings'" :active-tab="activeTab" />
|
||||||
|
|
||||||
<OAuthApprovalPanel
|
<OAuthApprovalPanel
|
||||||
v-if="oauthApproval.active && status.authenticated"
|
v-if="oauthApproval.active && status.authenticated"
|
||||||
@@ -195,12 +236,17 @@ onMounted(async () => {
|
|||||||
:cash-asset-ratio="cashAssetRatio"
|
:cash-asset-ratio="cashAssetRatio"
|
||||||
:positions="positions"
|
:positions="positions"
|
||||||
:recent-orders="recentOrders"
|
:recent-orders="recentOrders"
|
||||||
|
:canceling-order-key="cancelingOrderKey"
|
||||||
:data-loading="dataLoading"
|
:data-loading="dataLoading"
|
||||||
:connected="connected"
|
:connected="connected"
|
||||||
:order-history-loaded="orderHistoryLoaded"
|
:order-history-loaded="orderHistoryLoaded"
|
||||||
:order-history-notice="orderHistoryNotice"
|
:order-history-notice="orderHistoryNotice"
|
||||||
@connect="connect"
|
@connect="connect"
|
||||||
@open-position="(code) => openTradeForPosition(code, () => void navigate('trade'))"
|
@open-position="
|
||||||
|
(code) =>
|
||||||
|
openTradeForPosition(code, () => void navigateTrade(selectedStock.symbol || code))
|
||||||
|
"
|
||||||
|
@cancel-order="cancelOrder"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TradeView
|
<TradeView
|
||||||
@@ -239,7 +285,12 @@ onMounted(async () => {
|
|||||||
:price-polling="pricePolling"
|
:price-polling="pricePolling"
|
||||||
:has-quote="hasQuote"
|
:has-quote="hasQuote"
|
||||||
@open-search="showSearch = true"
|
@open-search="showSearch = true"
|
||||||
@select-stock="selectStock"
|
@select-stock="
|
||||||
|
(stock) => {
|
||||||
|
selectStock(stock)
|
||||||
|
navigateTrade(stock.symbol || stock.code)
|
||||||
|
}
|
||||||
|
"
|
||||||
@download-csv="downloadCsv"
|
@download-csv="downloadCsv"
|
||||||
@estimate="estimateCashOrder"
|
@estimate="estimateCashOrder"
|
||||||
@confirm-order="askPlaceOrder"
|
@confirm-order="askPlaceOrder"
|
||||||
@@ -250,6 +301,7 @@ onMounted(async () => {
|
|||||||
:orders="orders"
|
:orders="orders"
|
||||||
:connected="connected"
|
:connected="connected"
|
||||||
:data-loading="dataLoading"
|
:data-loading="dataLoading"
|
||||||
|
:canceling-order-key="cancelingOrderKey"
|
||||||
:order-history-loaded="orderHistoryLoaded"
|
:order-history-loaded="orderHistoryLoaded"
|
||||||
:order-history-notice="orderHistoryNotice"
|
:order-history-notice="orderHistoryNotice"
|
||||||
@refresh="loadTradingData"
|
@refresh="loadTradingData"
|
||||||
@@ -292,7 +344,9 @@ onMounted(async () => {
|
|||||||
:markets="markets"
|
:markets="markets"
|
||||||
:loading="searchLoading"
|
:loading="searchLoading"
|
||||||
@close="showSearch = false"
|
@close="showSearch = false"
|
||||||
@select="(stock) => openTradeForStock(stock, () => void navigate('trade'))"
|
@select="
|
||||||
|
(stock) => openTradeForStock(stock, () => void navigateTrade(stock.symbol || stock.code))
|
||||||
|
"
|
||||||
/>
|
/>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
@@ -301,6 +355,7 @@ onMounted(async () => {
|
|||||||
:show-estimate="showEstimateDialog"
|
:show-estimate="showEstimateDialog"
|
||||||
:show-order="showOrderDialog"
|
:show-order="showOrderDialog"
|
||||||
:stock-name="selectedStock.name"
|
:stock-name="selectedStock.name"
|
||||||
|
:stock-market="selectedStock.market"
|
||||||
:side="tradeSide"
|
:side="tradeSide"
|
||||||
:kind="orderKind"
|
:kind="orderKind"
|
||||||
:account-type="cashOrderAccountType"
|
:account-type="cashOrderAccountType"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const settings = defineModel<ApiKeySettings>({ required: true })
|
|||||||
const rpcMethods = [
|
const rpcMethods = [
|
||||||
'session.profile',
|
'session.profile',
|
||||||
'account.profile',
|
'account.profile',
|
||||||
|
'account.assets.current',
|
||||||
'account.power.buyingPower',
|
'account.power.buyingPower',
|
||||||
'account.power.collateralRatio',
|
'account.power.collateralRatio',
|
||||||
'account.positions.cash',
|
'account.positions.cash',
|
||||||
@@ -66,6 +67,9 @@ const rpcMethods = [
|
|||||||
'orders.themeInvestment.list',
|
'orders.themeInvestment.list',
|
||||||
'orders.themeInvestment.estimate',
|
'orders.themeInvestment.estimate',
|
||||||
'orders.themeInvestment.place',
|
'orders.themeInvestment.place',
|
||||||
|
'orders.exchange.rate',
|
||||||
|
'orders.exchange.estimate',
|
||||||
|
'orders.exchange.place',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const tradingMethods = [
|
const tradingMethods = [
|
||||||
@@ -81,6 +85,7 @@ const tradingMethods = [
|
|||||||
'orders.ifd.placeCorrection',
|
'orders.ifd.placeCorrection',
|
||||||
'orders.ifd.placeCancel',
|
'orders.ifd.placeCancel',
|
||||||
'orders.themeInvestment.place',
|
'orders.themeInvestment.place',
|
||||||
|
'orders.exchange.place',
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const tradingMethodSet = new Set<string>(tradingMethods)
|
const tradingMethodSet = new Set<string>(tradingMethods)
|
||||||
|
|||||||
@@ -1,18 +1,80 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
title: string
|
import { motion } from 'motion-v'
|
||||||
eyebrow?: string
|
import { uiMotion } from '../../constants/motion'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
eyebrow?: string
|
||||||
|
size?: 'sm' | 'lg'
|
||||||
|
closeOnBackdrop?: boolean
|
||||||
|
closeOnEscape?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
size: 'sm',
|
||||||
|
closeOnBackdrop: true,
|
||||||
|
closeOnEscape: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const overlayTransition = uiMotion.modal.openExpressiveSpatial
|
||||||
|
const sheetTransition = uiMotion.modal.sheetOpenExpressive
|
||||||
|
|
||||||
|
const sheetClass = computed(() => [
|
||||||
|
'grid max-h-[calc(100dvh-2rem)] w-full gap-5 overflow-y-auto rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-2xl shadow-black/35 outline-none',
|
||||||
|
props.size === 'lg' ? 'max-w-5xl' : 'max-w-[28rem]',
|
||||||
|
])
|
||||||
|
|
||||||
|
const closeFromBackdrop = () => {
|
||||||
|
if (props.closeOnBackdrop) emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeFromEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape' && props.closeOnEscape) emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('keydown', closeFromEscape))
|
||||||
|
onUnmounted(() => window.removeEventListener('keydown', closeFromEscape))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="fixed inset-0 z-20 grid place-items-center bg-[#101418]/75 backdrop-blur-sm">
|
<motion.div
|
||||||
<section
|
class="fixed inset-0 z-30 grid place-items-center overflow-y-auto bg-[#101418]/75 p-4 backdrop-blur-sm"
|
||||||
class="grid w-[28rem] gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-2xl shadow-black/35"
|
:initial="{ opacity: 0 }"
|
||||||
|
:animate="{ opacity: 1 }"
|
||||||
|
:exit="{ opacity: 0 }"
|
||||||
|
:transition="overlayTransition"
|
||||||
|
@click.self="closeFromBackdrop"
|
||||||
|
>
|
||||||
|
<motion.section
|
||||||
|
:class="sheetClass"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="title"
|
||||||
|
:initial="{ opacity: 0, y: 18, scale: 0.96 }"
|
||||||
|
:animate="{ opacity: 1, y: 0, scale: 1 }"
|
||||||
|
:exit="{ opacity: 0, y: 12, scale: 0.97 }"
|
||||||
|
:transition="sheetTransition"
|
||||||
>
|
>
|
||||||
<p v-if="eyebrow" class="text-xs font-black uppercase text-[#9aa0a9]">{{ eyebrow }}</p>
|
<header
|
||||||
<h2 class="text-lg font-bold text-[#e3e3e9]">{{ title }}</h2>
|
v-if="title || eyebrow || $slots.actions"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 border-b border-[#33383f] pb-4"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 gap-1">
|
||||||
|
<p v-if="eyebrow" class="text-xs font-black uppercase text-[#9aa0a9]">{{ eyebrow }}</p>
|
||||||
|
<h2 class="truncate text-xl font-black text-[#e3e3e9]">{{ title }}</h2>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" class="flex flex-wrap gap-2">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
</section>
|
</motion.section>
|
||||||
</div>
|
</motion.div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,31 +1,61 @@
|
|||||||
<script setup lang="ts" generic="T extends string">
|
<script setup lang="ts" generic="T extends string">
|
||||||
defineProps<{
|
import { LayoutGroup, motion } from 'motion-v'
|
||||||
|
import { uiMotion } from '../../constants/motion'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
modelValue: T
|
modelValue: T
|
||||||
options: Array<{ label: string; value: T; tone?: 'buy' | 'sell' }>
|
options: Array<{ label: string; value: T; tone?: 'buy' | 'sell' }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const groupId = `segmented-indicator-${Math.random().toString(36).slice(2)}`
|
||||||
|
const activeOption = computed(() =>
|
||||||
|
props.options.find((option) => option.value === props.modelValue),
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: T]
|
'update:modelValue': [value: T]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const toneClass = (tone?: 'buy' | 'sell') => {
|
||||||
|
if (tone === 'buy') return 'text-[#003824]'
|
||||||
|
if (tone === 'sell') return 'text-[#690005]'
|
||||||
|
return 'text-[#d3e3fd]'
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<LayoutGroup :id="groupId">
|
||||||
class="grid min-h-12 grid-cols-2 gap-1 rounded-full bg-[#111418] p-1 outline outline-1 outline-[#33383f]"
|
<div
|
||||||
>
|
class="relative grid min-h-12 overflow-hidden rounded-full bg-[#111418] p-1 outline outline-1 outline-[#33383f]"
|
||||||
<button
|
:style="{ gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))` }"
|
||||||
v-for="option in options"
|
|
||||||
:key="option.value"
|
|
||||||
type="button"
|
|
||||||
:class="[
|
|
||||||
'min-h-10 rounded-full bg-transparent text-sm font-bold text-[#9aa0a9] transition hover:bg-[#22272e]',
|
|
||||||
modelValue === option.value && option.tone === 'buy' && '!bg-[#40dba2] !text-[#003824]',
|
|
||||||
modelValue === option.value && option.tone === 'sell' && '!bg-[#ffb4ab] !text-[#690005]',
|
|
||||||
modelValue === option.value && !option.tone && '!bg-[#263141] !text-[#d3e3fd]',
|
|
||||||
]"
|
|
||||||
@click="emit('update:modelValue', option.value)"
|
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
<button
|
||||||
</button>
|
v-for="option in props.options"
|
||||||
</div>
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'relative min-h-10 rounded-full text-sm font-bold transition hover:bg-[#22272e]',
|
||||||
|
'text-[#9aa0a9]',
|
||||||
|
props.modelValue === option.value && toneClass(option.tone),
|
||||||
|
]"
|
||||||
|
@click="emit('update:modelValue', option.value)"
|
||||||
|
>
|
||||||
|
<motion.span
|
||||||
|
v-if="activeOption?.value === option.value"
|
||||||
|
:key="`${option.value}:active-indicator`"
|
||||||
|
:layout-id="`${groupId}-indicator`"
|
||||||
|
:animate="{ scaleX: [1, 1.1, 1], scaleY: [1, 0.92, 1] }"
|
||||||
|
:transition="uiMotion.trade.segmentedIndicator"
|
||||||
|
class="pointer-events-none absolute inset-0 rounded-full"
|
||||||
|
:class="[
|
||||||
|
option.tone === 'buy' && 'bg-[#40dba2]',
|
||||||
|
option.tone === 'sell' && 'bg-[#ffb4ab]',
|
||||||
|
!option.tone && 'bg-[#263141]',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<span class="relative z-10">{{ option.label }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</LayoutGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
export type MarketSession = readonly [openMinutes: number, closeMinutes: number]
|
||||||
|
|
||||||
|
const minutes = (hour: number, minute = 0) => hour * 60 + minute
|
||||||
|
|
||||||
|
export const marketTimeZones: Record<string, string> = {
|
||||||
|
ARCX: 'America/New_York',
|
||||||
|
XNAS: 'America/New_York',
|
||||||
|
XNYS: 'America/New_York',
|
||||||
|
XTKS: 'Asia/Tokyo',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const countryTimeZones: Record<string, string> = {
|
||||||
|
アメリカ: 'America/New_York',
|
||||||
|
日本: 'Asia/Tokyo',
|
||||||
|
米国: 'America/New_York',
|
||||||
|
US: 'America/New_York',
|
||||||
|
USA: 'America/New_York',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const marketSessions: Record<string, readonly MarketSession[]> = {
|
||||||
|
ARCX: [[minutes(9, 30), minutes(16)]],
|
||||||
|
XNAS: [[minutes(9, 30), minutes(16)]],
|
||||||
|
XNYS: [[minutes(9, 30), minutes(16)]],
|
||||||
|
XTKS: [
|
||||||
|
[minutes(9), minutes(11, 30)],
|
||||||
|
[minutes(12, 30), minutes(15, 30)],
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export const motionProfiles = {
|
||||||
|
expressive: {
|
||||||
|
spatial: {
|
||||||
|
fast: { duration: 0.35, ease: [0.42, 1.67, 0.21, 0.9] } as const,
|
||||||
|
default: { duration: 0.5, ease: [0.38, 1.21, 0.22, 1] } as const,
|
||||||
|
slow: { duration: 0.65, ease: [0.39, 1.29, 0.35, 0.98] } as const,
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
fast: { duration: 0.15, ease: [0.31, 0.94, 0.34, 1] } as const,
|
||||||
|
default: { duration: 0.2, ease: [0.34, 0.8, 0.34, 1] } as const,
|
||||||
|
slow: { duration: 0.3, ease: [0.34, 0.88, 0.34, 1] } as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
standard: {
|
||||||
|
spatial: {
|
||||||
|
fast: { duration: 0.35, ease: [0.27, 1.06, 0.18, 1] } as const,
|
||||||
|
default: { duration: 0.5, ease: [0.27, 1.06, 0.18, 1] } as const,
|
||||||
|
slow: { duration: 0.75, ease: [0.27, 1.06, 0.18, 1] } as const,
|
||||||
|
},
|
||||||
|
effects: {
|
||||||
|
fast: { duration: 0.15, ease: [0.31, 0.94, 0.34, 1] } as const,
|
||||||
|
default: { duration: 0.2, ease: [0.34, 0.8, 0.34, 1] } as const,
|
||||||
|
slow: { duration: 0.3, ease: [0.34, 0.88, 0.34, 1] } as const,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const uiMotion = {
|
||||||
|
overlay: {
|
||||||
|
fadeDefault: motionProfiles.standard.effects.default,
|
||||||
|
},
|
||||||
|
modal: {
|
||||||
|
sheetOpen: motionProfiles.standard.spatial.default,
|
||||||
|
sheetOpenExpressive: motionProfiles.expressive.spatial.fast,
|
||||||
|
openExpressiveSpatial: motionProfiles.expressive.spatial.fast,
|
||||||
|
},
|
||||||
|
trade: {
|
||||||
|
tabIndicator: motionProfiles.standard.spatial.default,
|
||||||
|
segmentedIndicator: motionProfiles.expressive.spatial.fast,
|
||||||
|
tabContentFade: motionProfiles.standard.effects.default,
|
||||||
|
advancedPanel: motionProfiles.expressive.effects.default,
|
||||||
|
advancedOptions: motionProfiles.expressive.spatial.fast,
|
||||||
|
searchSheet: motionProfiles.expressive.spatial.fast,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
@@ -33,19 +33,36 @@ export const cashOrderMarketOptions: Array<{
|
|||||||
value: CashOrderMarket
|
value: CashOrderMarket
|
||||||
}> = [
|
}> = [
|
||||||
{ label: '自動', value: 'auto' },
|
{ label: '自動', value: 'auto' },
|
||||||
{ label: '東証', value: 'TKY' },
|
{ label: '東証', value: 'XTKS' },
|
||||||
{ label: '名証', value: 'NGY' },
|
{ label: 'NASDAQ', value: 'XNAS' },
|
||||||
{ label: '福証', value: 'FKO' },
|
{ label: 'NYSE', value: 'XNYS' },
|
||||||
{ label: '札証', value: 'SPR' },
|
{ label: 'NYSE Arca', value: 'ARCX' },
|
||||||
{ label: 'SOR', value: 'SOR' },
|
|
||||||
{ label: 'PTS', value: 'PTS' },
|
|
||||||
{ label: 'PTS(X)', value: 'PTX' },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export const searchableMarkets: CashOrderMarket[] = ['XTKS', 'XNAS', 'XNYS', 'ARCX']
|
||||||
|
|
||||||
|
export const tradeRouteIdFromStockId = (id: string) => {
|
||||||
|
const separator = id.indexOf(':')
|
||||||
|
if (separator <= 0) return id
|
||||||
|
|
||||||
|
const market = id.slice(0, separator).toUpperCase()
|
||||||
|
if (!searchableMarkets.includes(market as CashOrderMarket)) return id
|
||||||
|
return `${market}.${id.slice(separator + 1)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stockIdFromTradeRouteId = (id: string) => {
|
||||||
|
const separator = id.indexOf('.')
|
||||||
|
if (separator <= 0) return id
|
||||||
|
|
||||||
|
const market = id.slice(0, separator).toUpperCase()
|
||||||
|
if (!searchableMarkets.includes(market as CashOrderMarket)) return id
|
||||||
|
return `${market}:${id.slice(separator + 1)}`
|
||||||
|
}
|
||||||
|
|
||||||
export const sKabuOrderMarketOptions: Array<{
|
export const sKabuOrderMarketOptions: Array<{
|
||||||
label: string
|
label: string
|
||||||
value: CashOrderMarket
|
value: CashOrderMarket
|
||||||
}> = [{ label: 'S株', value: 'STK' }]
|
}> = []
|
||||||
|
|
||||||
export const cashOrderPriceConditionOptions: Array<{
|
export const cashOrderPriceConditionOptions: Array<{
|
||||||
label: string
|
label: string
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Ban, RefreshCw } from 'lucide-vue-next'
|
import { computed, ref } from 'vue'
|
||||||
|
import { AnimatePresence } from 'motion-v'
|
||||||
|
import { ArrowLeft, Ban, RefreshCw } from 'lucide-vue-next'
|
||||||
import Spinner from '../../components/ui/Spinner.vue'
|
import Spinner from '../../components/ui/Spinner.vue'
|
||||||
|
import UiButton from '../../components/ui/UiButton.vue'
|
||||||
|
import UiModal from '../../components/ui/UiModal.vue'
|
||||||
import { ui } from '../../styles/ui'
|
import { ui } from '../../styles/ui'
|
||||||
import type { OrderRow } from '../../types/trading'
|
import type { OrderRow } from '../../types/trading'
|
||||||
import { orderAmountText, orderQuantityText } from '../trading/trading-data'
|
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
orders: OrderRow[]
|
orders: OrderRow[]
|
||||||
connected: boolean
|
connected: boolean
|
||||||
dataLoading: boolean
|
dataLoading: boolean
|
||||||
|
cancelingOrderKey: string
|
||||||
orderHistoryLoaded: boolean
|
orderHistoryLoaded: boolean
|
||||||
orderHistoryNotice: string
|
orderHistoryNotice: string
|
||||||
}>()
|
}>()
|
||||||
@@ -17,6 +22,25 @@ const emit = defineEmits<{
|
|||||||
refresh: []
|
refresh: []
|
||||||
cancel: [order: OrderRow]
|
cancel: [order: OrderRow]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const cancelCandidate = ref<OrderRow | null>(null)
|
||||||
|
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
|
||||||
|
const canCancel = (order: OrderRow) =>
|
||||||
|
order.status === '注文中' && Boolean(order.orderNumber) && !usMarkets.has(order.market)
|
||||||
|
const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
|
||||||
|
orderHistoryKey(order) === cancelingOrderKey
|
||||||
|
const cancelTitle = computed(() => cancelCandidate.value?.stock ?? '')
|
||||||
|
|
||||||
|
const askCancel = (order: OrderRow) => {
|
||||||
|
if (!canCancel(order)) return
|
||||||
|
cancelCandidate.value = order
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmCancel = () => {
|
||||||
|
if (!cancelCandidate.value) return
|
||||||
|
emit('cancel', cancelCandidate.value)
|
||||||
|
cancelCandidate.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -37,7 +61,7 @@ const emit = defineEmits<{
|
|||||||
<div v-for="order in orders" :key="order.id" :class="ui.orderRow">
|
<div v-for="order in orders" :key="order.id" :class="ui.orderRow">
|
||||||
<span>
|
<span>
|
||||||
<strong>{{ order.stock }}</strong>
|
<strong>{{ order.stock }}</strong>
|
||||||
<small>{{ order.date }} / {{ order.kind === 's' ? 'S株' : '通常単元' }}</small>
|
<small>{{ order.date }} / 通常単元</small>
|
||||||
</span>
|
</span>
|
||||||
<span :class="order.side === 'buy' ? ui.positive : ui.negative">
|
<span :class="order.side === 'buy' ? ui.positive : ui.negative">
|
||||||
{{ order.side === 'buy' ? '購入' : '売却' }}
|
{{ order.side === 'buy' ? '購入' : '売却' }}
|
||||||
@@ -48,13 +72,15 @@ const emit = defineEmits<{
|
|||||||
{{ order.status }}
|
{{ order.status }}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
v-if="order.status === '注文中'"
|
v-if="canCancel(order)"
|
||||||
:class="ui.ghostButton"
|
:class="ui.ghostButton"
|
||||||
type="button"
|
type="button"
|
||||||
@click="emit('cancel', order)"
|
:disabled="Boolean(cancelingOrderKey)"
|
||||||
|
@click="askCancel(order)"
|
||||||
>
|
>
|
||||||
<Ban class="h-4 w-4" aria-hidden="true" />
|
<Spinner v-if="isCanceling(order, cancelingOrderKey)" size="sm" />
|
||||||
取消
|
<Ban v-else class="h-4 w-4" aria-hidden="true" />
|
||||||
|
{{ isCanceling(order, cancelingOrderKey) ? '取消中' : '取消' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
|
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
|
||||||
@@ -71,4 +97,43 @@ const emit = defineEmits<{
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
<UiModal
|
||||||
|
v-if="cancelCandidate"
|
||||||
|
key="cancel-order-dialog"
|
||||||
|
eyebrow="注文取消"
|
||||||
|
:title="cancelTitle"
|
||||||
|
@close="cancelCandidate = null"
|
||||||
|
>
|
||||||
|
<dl :class="ui.confirmList">
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>注文番号</dt>
|
||||||
|
<dd>{{ cancelCandidate.orderNumber }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>売買</dt>
|
||||||
|
<dd>{{ cancelCandidate.side === 'buy' ? '購入' : '売却' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>数量</dt>
|
||||||
|
<dd>{{ orderQuantityText(cancelCandidate) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>注文金額</dt>
|
||||||
|
<dd>{{ orderAmountText(cancelCandidate) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div :class="ui.actions">
|
||||||
|
<UiButton variant="ghost" @click="cancelCandidate = null">
|
||||||
|
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||||
|
戻る
|
||||||
|
</UiButton>
|
||||||
|
<UiButton variant="danger" @click="confirmCancel">
|
||||||
|
<Ban class="h-4 w-4" aria-hidden="true" />
|
||||||
|
注文取消
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</UiModal>
|
||||||
|
</AnimatePresence>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ArrowLeft, Send, ShieldCheck } from 'lucide-vue-next'
|
import { ArrowLeft, Send, ShieldCheck } from 'lucide-vue-next'
|
||||||
|
import { AnimatePresence } from 'motion-v'
|
||||||
import UiButton from '../../components/ui/UiButton.vue'
|
import UiButton from '../../components/ui/UiButton.vue'
|
||||||
import UiModal from '../../components/ui/UiModal.vue'
|
import UiModal from '../../components/ui/UiModal.vue'
|
||||||
import { ui } from '../../styles/ui'
|
import { ui } from '../../styles/ui'
|
||||||
@@ -14,13 +15,14 @@ import type {
|
|||||||
OrderPreview,
|
OrderPreview,
|
||||||
TradeSide,
|
TradeSide,
|
||||||
} from '../../types/trading'
|
} from '../../types/trading'
|
||||||
import { currency } from '../../utils/format'
|
import { currencyForMarket } from '../../utils/format'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
estimate: OrderPreview | null
|
estimate: OrderPreview | null
|
||||||
showEstimate: boolean
|
showEstimate: boolean
|
||||||
showOrder: boolean
|
showOrder: boolean
|
||||||
stockName: string
|
stockName: string
|
||||||
|
stockMarket: string
|
||||||
side: TradeSide
|
side: TradeSide
|
||||||
kind: OrderKind
|
kind: OrderKind
|
||||||
accountType: CashOrderAccountType
|
accountType: CashOrderAccountType
|
||||||
@@ -76,138 +78,155 @@ const accountTypeLabel = (type: CashOrderAccountType) =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UiModal v-if="showEstimate && estimate" eyebrow="見積" :title="stockName">
|
<AnimatePresence>
|
||||||
<dl :class="ui.confirmList">
|
<UiModal
|
||||||
<div :class="ui.confirmRow">
|
v-if="showEstimate && estimate"
|
||||||
<dt>売買</dt>
|
key="estimate-dialog"
|
||||||
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
|
eyebrow="見積"
|
||||||
</div>
|
:title="stockName"
|
||||||
<div :class="ui.confirmRow">
|
>
|
||||||
<dt>数量</dt>
|
<dl :class="ui.confirmList">
|
||||||
<dd>{{ quantity }}株</dd>
|
|
||||||
</div>
|
|
||||||
<div :class="ui.confirmRow">
|
|
||||||
<dt>預り区分</dt>
|
|
||||||
<dd>{{ accountTypeLabel(accountType) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div :class="ui.confirmRow">
|
|
||||||
<dt>注文市場</dt>
|
|
||||||
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
|
|
||||||
</div>
|
|
||||||
<template v-if="kind !== 's'">
|
|
||||||
<div :class="ui.confirmRow">
|
<div :class="ui.confirmRow">
|
||||||
<dt>執行条件</dt>
|
<dt>売買</dt>
|
||||||
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
|
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
|
||||||
</div>
|
|
||||||
<div v-if="price > 0" :class="ui.confirmRow">
|
|
||||||
<dt>注文価格</dt>
|
|
||||||
<dd>{{ currency(price) }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div :class="ui.confirmRow">
|
<div :class="ui.confirmRow">
|
||||||
<dt>有効期限</dt>
|
<dt>数量</dt>
|
||||||
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
|
<dd>{{ quantity }}株</dd>
|
||||||
</div>
|
</div>
|
||||||
<div :class="ui.confirmRow">
|
<div :class="ui.confirmRow">
|
||||||
<dt>特殊注文</dt>
|
<dt>預り区分</dt>
|
||||||
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
|
<dd>{{ accountTypeLabel(accountType) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
|
<div :class="ui.confirmRow">
|
||||||
<dt>逆指値</dt>
|
<dt>注文市場</dt>
|
||||||
<dd>{{ currency(triggerPrice) }} {{ triggerZoneLabel(triggerZone) }}</dd>
|
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
|
<template>
|
||||||
<dt>OCO条件</dt>
|
<div :class="ui.confirmRow">
|
||||||
<dd>
|
<dt>執行条件</dt>
|
||||||
{{ priceConditionLabel(secondaryPriceCondition) }}
|
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
|
||||||
<template v-if="secondaryPrice > 0"> {{ currency(secondaryPrice) }}</template>
|
</div>
|
||||||
</dd>
|
<div v-if="price > 0" :class="ui.confirmRow">
|
||||||
|
<dt>注文価格</dt>
|
||||||
|
<dd>{{ currencyForMarket(price, stockMarket) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>有効期限</dt>
|
||||||
|
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>特殊注文</dt>
|
||||||
|
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
|
||||||
|
<dt>逆指値</dt>
|
||||||
|
<dd>
|
||||||
|
{{ currencyForMarket(triggerPrice, stockMarket) }} {{ triggerZoneLabel(triggerZone) }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
|
||||||
|
<dt>OCO条件</dt>
|
||||||
|
<dd>
|
||||||
|
{{ priceConditionLabel(secondaryPriceCondition) }}
|
||||||
|
<template v-if="secondaryPrice > 0">
|
||||||
|
{{ currencyForMarket(secondaryPrice, stockMarket) }}</template
|
||||||
|
>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>概算</dt>
|
||||||
|
<dd>{{ currencyForMarket(amount, stockMarket) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</dl>
|
||||||
<div :class="ui.confirmRow">
|
<p :class="ui.dialogNote">
|
||||||
<dt>概算</dt>
|
{{ estimate.message ?? estimate.warnings.join(' ') }}
|
||||||
<dd>{{ currency(amount) }}</dd>
|
</p>
|
||||||
|
<div :class="ui.actions">
|
||||||
|
<UiButton variant="ghost" @click="emit('closeEstimate')">
|
||||||
|
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||||
|
戻る
|
||||||
|
</UiButton>
|
||||||
|
<UiButton @click="emit('proceed')">
|
||||||
|
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
|
||||||
|
注文確認へ
|
||||||
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</UiModal>
|
||||||
<p :class="ui.dialogNote">
|
</AnimatePresence>
|
||||||
{{ estimate.message ?? estimate.warnings.join(' ') }}
|
|
||||||
</p>
|
|
||||||
<div :class="ui.actions">
|
|
||||||
<UiButton variant="ghost" @click="emit('closeEstimate')">
|
|
||||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
|
||||||
戻る
|
|
||||||
</UiButton>
|
|
||||||
<UiButton @click="emit('proceed')">
|
|
||||||
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
|
|
||||||
注文確認へ
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</UiModal>
|
|
||||||
|
|
||||||
<UiModal v-if="showOrder" eyebrow="注文確認" :title="stockName">
|
<AnimatePresence>
|
||||||
<dl :class="ui.confirmList">
|
<UiModal v-if="showOrder" key="order-dialog" eyebrow="注文確認" :title="stockName">
|
||||||
<div :class="ui.confirmRow">
|
<dl :class="ui.confirmList">
|
||||||
<dt>区分</dt>
|
|
||||||
<dd>{{ kind === 's' ? 'S株' : '通常単元' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div :class="ui.confirmRow">
|
|
||||||
<dt>売買</dt>
|
|
||||||
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
|
|
||||||
</div>
|
|
||||||
<div :class="ui.confirmRow">
|
|
||||||
<dt>数量</dt>
|
|
||||||
<dd>{{ quantity }}株</dd>
|
|
||||||
</div>
|
|
||||||
<div :class="ui.confirmRow">
|
|
||||||
<dt>預り区分</dt>
|
|
||||||
<dd>{{ accountTypeLabel(accountType) }}</dd>
|
|
||||||
</div>
|
|
||||||
<div :class="ui.confirmRow">
|
|
||||||
<dt>注文市場</dt>
|
|
||||||
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
|
|
||||||
</div>
|
|
||||||
<template v-if="kind !== 's'">
|
|
||||||
<div :class="ui.confirmRow">
|
<div :class="ui.confirmRow">
|
||||||
<dt>執行条件</dt>
|
<dt>区分</dt>
|
||||||
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
|
<dd>通常単元</dd>
|
||||||
</div>
|
|
||||||
<div v-if="price > 0" :class="ui.confirmRow">
|
|
||||||
<dt>注文価格</dt>
|
|
||||||
<dd>{{ currency(price) }}</dd>
|
|
||||||
</div>
|
</div>
|
||||||
<div :class="ui.confirmRow">
|
<div :class="ui.confirmRow">
|
||||||
<dt>有効期限</dt>
|
<dt>売買</dt>
|
||||||
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
|
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div :class="ui.confirmRow">
|
<div :class="ui.confirmRow">
|
||||||
<dt>特殊注文</dt>
|
<dt>数量</dt>
|
||||||
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
|
<dd>{{ quantity }}株</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
|
<div :class="ui.confirmRow">
|
||||||
<dt>逆指値</dt>
|
<dt>預り区分</dt>
|
||||||
<dd>{{ currency(triggerPrice) }} {{ triggerZoneLabel(triggerZone) }}</dd>
|
<dd>{{ accountTypeLabel(accountType) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
|
<div :class="ui.confirmRow">
|
||||||
<dt>OCO条件</dt>
|
<dt>注文市場</dt>
|
||||||
<dd>
|
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
|
||||||
{{ priceConditionLabel(secondaryPriceCondition) }}
|
|
||||||
<template v-if="secondaryPrice > 0"> {{ currency(secondaryPrice) }}</template>
|
|
||||||
</dd>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<template>
|
||||||
<div :class="ui.confirmRow">
|
<div :class="ui.confirmRow">
|
||||||
<dt>概算</dt>
|
<dt>執行条件</dt>
|
||||||
<dd>{{ currency(amount) }}</dd>
|
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="price > 0" :class="ui.confirmRow">
|
||||||
|
<dt>注文価格</dt>
|
||||||
|
<dd>{{ currencyForMarket(price, stockMarket) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>有効期限</dt>
|
||||||
|
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>特殊注文</dt>
|
||||||
|
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
|
||||||
|
<dt>逆指値</dt>
|
||||||
|
<dd>
|
||||||
|
{{ currencyForMarket(triggerPrice, stockMarket) }} {{ triggerZoneLabel(triggerZone) }}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
|
||||||
|
<dt>OCO条件</dt>
|
||||||
|
<dd>
|
||||||
|
{{ priceConditionLabel(secondaryPriceCondition) }}
|
||||||
|
<template v-if="secondaryPrice > 0">
|
||||||
|
{{ currencyForMarket(secondaryPrice, stockMarket) }}</template
|
||||||
|
>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>概算</dt>
|
||||||
|
<dd>{{ currencyForMarket(amount, stockMarket) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div :class="ui.actions">
|
||||||
|
<UiButton variant="ghost" @click="emit('closeOrder')">
|
||||||
|
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||||
|
戻る
|
||||||
|
</UiButton>
|
||||||
|
<UiButton variant="danger" @click="emit('place')">
|
||||||
|
<Send class="h-4 w-4" aria-hidden="true" />
|
||||||
|
発注
|
||||||
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</UiModal>
|
||||||
<div :class="ui.actions">
|
</AnimatePresence>
|
||||||
<UiButton variant="ghost" @click="emit('closeOrder')">
|
|
||||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
|
||||||
戻る
|
|
||||||
</UiButton>
|
|
||||||
<UiButton variant="danger" @click="emit('place')">
|
|
||||||
<Send class="h-4 w-4" aria-hidden="true" />
|
|
||||||
発注
|
|
||||||
</UiButton>
|
|
||||||
</div>
|
|
||||||
</UiModal>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Plug } from 'lucide-vue-next'
|
import { computed, ref } from 'vue'
|
||||||
|
import { AnimatePresence } from 'motion-v'
|
||||||
|
import { ArrowLeft, Ban, Plug } from 'lucide-vue-next'
|
||||||
import Spinner from '../../components/ui/Spinner.vue'
|
import Spinner from '../../components/ui/Spinner.vue'
|
||||||
|
import UiButton from '../../components/ui/UiButton.vue'
|
||||||
|
import UiModal from '../../components/ui/UiModal.vue'
|
||||||
import { ui } from '../../styles/ui'
|
import { ui } from '../../styles/ui'
|
||||||
import type { OrderRow, Position } from '../../types/trading'
|
import type { OrderRow, Position } from '../../types/trading'
|
||||||
import { currency, signedCurrency, signedPercent } from '../../utils/format'
|
import {
|
||||||
import { orderAmountText, orderQuantityText } from '../trading/trading-data'
|
currency,
|
||||||
|
currencyForMarket,
|
||||||
|
signedCurrency,
|
||||||
|
signedCurrencyForMarket,
|
||||||
|
signedPercent,
|
||||||
|
} from '../../utils/format'
|
||||||
|
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
showPortfolioSpinner: boolean
|
showPortfolioSpinner: boolean
|
||||||
@@ -17,6 +27,7 @@ defineProps<{
|
|||||||
cashAssetRatio: number
|
cashAssetRatio: number
|
||||||
positions: Position[]
|
positions: Position[]
|
||||||
recentOrders: OrderRow[]
|
recentOrders: OrderRow[]
|
||||||
|
cancelingOrderKey: string
|
||||||
dataLoading: boolean
|
dataLoading: boolean
|
||||||
connected: boolean
|
connected: boolean
|
||||||
orderHistoryLoaded: boolean
|
orderHistoryLoaded: boolean
|
||||||
@@ -26,7 +37,27 @@ defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
connect: []
|
connect: []
|
||||||
openPosition: [code: string]
|
openPosition: [code: string]
|
||||||
|
cancelOrder: [order: OrderRow]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
|
||||||
|
const canCancel = (order: OrderRow) =>
|
||||||
|
order.status === '注文中' && Boolean(order.orderNumber) && !usMarkets.has(order.market)
|
||||||
|
const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
|
||||||
|
orderHistoryKey(order) === cancelingOrderKey
|
||||||
|
const cancelCandidate = ref<OrderRow | null>(null)
|
||||||
|
const cancelTitle = computed(() => cancelCandidate.value?.stock ?? '')
|
||||||
|
|
||||||
|
const askCancel = (order: OrderRow) => {
|
||||||
|
if (!canCancel(order)) return
|
||||||
|
cancelCandidate.value = order
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmCancel = () => {
|
||||||
|
if (!cancelCandidate.value) return
|
||||||
|
emit('cancelOrder', cancelCandidate.value)
|
||||||
|
cancelCandidate.value = null
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -137,12 +168,14 @@ const emit = defineEmits<{
|
|||||||
position.type ?? (position.quantity >= 100 ? '単元' : 'S株')
|
position.type ?? (position.quantity >= 100 ? '単元' : 'S株')
|
||||||
}}</b>
|
}}</b>
|
||||||
<span>{{ position.quantity }}</span>
|
<span>{{ position.quantity }}</span>
|
||||||
<span :class="ui.muted">{{ currency(position.marketValue) }}</span>
|
<span :class="ui.muted">{{
|
||||||
|
currencyForMarket(position.marketValue, position.market)
|
||||||
|
}}</span>
|
||||||
<span
|
<span
|
||||||
class="grid justify-items-end gap-0.5"
|
class="grid justify-items-end gap-0.5"
|
||||||
:class="position.profitLoss >= 0 ? ui.positive : ui.negative"
|
:class="position.profitLoss >= 0 ? ui.positive : ui.negative"
|
||||||
>
|
>
|
||||||
<strong>{{ signedCurrency(position.profitLoss) }}</strong>
|
<strong>{{ signedCurrencyForMarket(position.profitLoss, position.market) }}</strong>
|
||||||
<small>{{ signedPercent(position.profitLossRate) }}</small>
|
<small>{{ signedPercent(position.profitLossRate) }}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -162,14 +195,28 @@ const emit = defineEmits<{
|
|||||||
<span class="grid gap-1">
|
<span class="grid gap-1">
|
||||||
<strong>{{ order.stock }}</strong>
|
<strong>{{ order.stock }}</strong>
|
||||||
<small
|
<small
|
||||||
>{{ order.side === 'buy' ? '買付' : '売却' }}({{
|
>{{ order.side === 'buy' ? '買付' : '売却' }}(単元) ・
|
||||||
order.kind === 's' ? 'S株' : '単元'
|
{{ orderQuantityText(order) }}</small
|
||||||
}}) ・ {{ orderQuantityText(order) }}</small
|
|
||||||
>
|
>
|
||||||
|
<small :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]">
|
||||||
|
{{ order.status }}
|
||||||
|
</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="grid justify-items-end gap-1">
|
<span class="grid justify-items-end gap-1">
|
||||||
<small>{{ order.date.slice(5, 10) }}</small>
|
<small>{{ order.date.slice(5, 10) }}</small>
|
||||||
<strong>{{ orderAmountText(order) }}</strong>
|
<strong>{{ orderAmountText(order) }}</strong>
|
||||||
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,4 +235,43 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
<UiModal
|
||||||
|
v-if="cancelCandidate"
|
||||||
|
key="portfolio-cancel-order-dialog"
|
||||||
|
eyebrow="注文取消"
|
||||||
|
:title="cancelTitle"
|
||||||
|
@close="cancelCandidate = null"
|
||||||
|
>
|
||||||
|
<dl :class="ui.confirmList">
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>注文番号</dt>
|
||||||
|
<dd>{{ cancelCandidate.orderNumber }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>売買</dt>
|
||||||
|
<dd>{{ cancelCandidate.side === 'buy' ? '購入' : '売却' }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>数量</dt>
|
||||||
|
<dd>{{ orderQuantityText(cancelCandidate) }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.confirmRow">
|
||||||
|
<dt>注文金額</dt>
|
||||||
|
<dd>{{ orderAmountText(cancelCandidate) }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div :class="ui.actions">
|
||||||
|
<UiButton variant="ghost" @click="cancelCandidate = null">
|
||||||
|
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||||
|
戻る
|
||||||
|
</UiButton>
|
||||||
|
<UiButton variant="danger" @click="confirmCancel">
|
||||||
|
<Ban class="h-4 w-4" aria-hidden="true" />
|
||||||
|
注文取消
|
||||||
|
</UiButton>
|
||||||
|
</div>
|
||||||
|
</UiModal>
|
||||||
|
</AnimatePresence>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,24 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { X } from 'lucide-vue-next'
|
import { X } from 'lucide-vue-next'
|
||||||
|
import { type ComponentPublicInstance, nextTick, ref, watch } from 'vue'
|
||||||
import { motion } from 'motion-v'
|
import { motion } from 'motion-v'
|
||||||
import Spinner from '../../components/ui/Spinner.vue'
|
import Spinner from '../../components/ui/Spinner.vue'
|
||||||
import UiButton from '../../components/ui/UiButton.vue'
|
import UiButton from '../../components/ui/UiButton.vue'
|
||||||
import UiField from '../../components/ui/UiField.vue'
|
import UiField from '../../components/ui/UiField.vue'
|
||||||
import searchEmptyImage from '../../assets/search-empty.png'
|
import searchEmptyImage from '../../assets/search-empty.png'
|
||||||
|
import { uiMotion } from '../../constants/motion'
|
||||||
import { ui } from '../../styles/ui'
|
import { ui } from '../../styles/ui'
|
||||||
import type { Stock } from '../../types/trading'
|
import type { Stock } from '../../types/trading'
|
||||||
import { currency } from '../../utils/format'
|
import { currencyForMarket } from '../../utils/format'
|
||||||
|
|
||||||
const overlayTransition = { duration: 0.2, ease: 'easeOut' } as const
|
const overlayTransition = uiMotion.overlay.fadeDefault
|
||||||
const sheetTransition = { type: 'spring', damping: 28, stiffness: 320 } as const
|
const sheetTransition = uiMotion.trade.searchSheet
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
stocks: Stock[]
|
stocks: Stock[]
|
||||||
selectedStockCode: string
|
selectedStockCode: string
|
||||||
countries: string[]
|
countries: string[]
|
||||||
markets: string[]
|
markets: string[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
const selectedButtonRefs = ref(new Map<string, Element | null>())
|
||||||
|
const setSelectedButtonRef =
|
||||||
|
(code: string) => (element: Element | ComponentPublicInstance | null) => {
|
||||||
|
if (element instanceof Element) {
|
||||||
|
selectedButtonRefs.value.set(code, element)
|
||||||
|
} else {
|
||||||
|
selectedButtonRefs.value.delete(code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scrollToSelectedStock = async () => {
|
||||||
|
await nextTick()
|
||||||
|
const element = selectedButtonRefs.value.get(props.selectedStockCode)
|
||||||
|
if (!(element instanceof HTMLButtonElement)) return
|
||||||
|
element.scrollIntoView({
|
||||||
|
block: 'nearest',
|
||||||
|
inline: 'nearest',
|
||||||
|
behavior: 'smooth',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.stocks, props.selectedStockCode],
|
||||||
|
() => {
|
||||||
|
void scrollToSelectedStock()
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
const searchQuery = defineModel<string>('searchQuery', { required: true })
|
const searchQuery = defineModel<string>('searchQuery', { required: true })
|
||||||
const countryFilter = defineModel<string>('countryFilter', { required: true })
|
const countryFilter = defineModel<string>('countryFilter', { required: true })
|
||||||
@@ -93,17 +123,27 @@ const emit = defineEmits<{
|
|||||||
<button
|
<button
|
||||||
v-for="stock in stocks"
|
v-for="stock in stocks"
|
||||||
:key="stock.code"
|
:key="stock.code"
|
||||||
:class="[ui.searchResult, selectedStockCode === stock.code && ui.searchResultActive]"
|
:ref="setSelectedButtonRef(stock.code)"
|
||||||
|
:class="[
|
||||||
|
ui.searchResult,
|
||||||
|
selectedStockCode === stock.code && ui.searchResultActive,
|
||||||
|
selectedStockCode === stock.code && ui.searchResultFocus,
|
||||||
|
]"
|
||||||
type="button"
|
type="button"
|
||||||
:aria-current="selectedStockCode === stock.code ? 'true' : undefined"
|
:aria-current="selectedStockCode === stock.code ? 'true' : undefined"
|
||||||
|
:aria-selected="selectedStockCode === stock.code"
|
||||||
|
:tabindex="selectedStockCode === stock.code ? 0 : -1"
|
||||||
@click="emit('select', stock)"
|
@click="emit('select', stock)"
|
||||||
|
@keyup.enter="emit('select', stock)"
|
||||||
>
|
>
|
||||||
<span class="grid gap-1">
|
<span class="grid gap-1">
|
||||||
<strong>{{ stock.name }}</strong>
|
<strong>{{ stock.name }}</strong>
|
||||||
<small>{{ stock.code }} / {{ stock.country }} / {{ stock.market }}</small>
|
<small>{{ stock.code }} / {{ stock.country }} / {{ stock.market }}</small>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
<template v-if="stock.price > 0">{{ currency(stock.price) }}</template>
|
<template v-if="stock.price > 0">{{
|
||||||
|
currencyForMarket(stock.price, stock.market)
|
||||||
|
}}</template>
|
||||||
<Spinner v-else size="sm" />
|
<Spinner v-else size="sm" />
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -5,16 +5,19 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
Pencil,
|
Pencil,
|
||||||
Plug,
|
Plug,
|
||||||
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Save,
|
Save,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserRoundCog,
|
UserRoundCog,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
import { AnimatePresence } from 'motion-v'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import type { ApiKey, ApiKeySettings, SbiPasskey } from '../../api'
|
import type { ApiKey, ApiKeySettings, SbiPasskey } from '../../api'
|
||||||
import ApiKeyPolicyEditor from '../../components/ApiKeyPolicyEditor.vue'
|
import ApiKeyPolicyEditor from '../../components/ApiKeyPolicyEditor.vue'
|
||||||
|
import UiModal from '../../components/ui/UiModal.vue'
|
||||||
import { ui } from '../../styles/ui'
|
import { ui } from '../../styles/ui'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
@@ -51,6 +54,10 @@ const activeSection = computed<SettingsSection>(() => {
|
|||||||
return route.params.section === 'passkeys' ? 'passkeys' : 'api-keys'
|
return route.params.section === 'passkeys' ? 'passkeys' : 'api-keys'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isNewApiKey = computed(
|
||||||
|
() => activeSection.value === 'api-keys' && route.params.mode === 'new',
|
||||||
|
)
|
||||||
|
|
||||||
const settingsGroups = [
|
const settingsGroups = [
|
||||||
{
|
{
|
||||||
label: 'アクセス',
|
label: 'アクセス',
|
||||||
@@ -80,8 +87,16 @@ const openSection = (section: SettingsSection) => {
|
|||||||
router.push(`/settings/${section}`)
|
router.push(`/settings/${section}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openNewApiKey = () => {
|
||||||
|
router.push('/settings/api-keys/new')
|
||||||
|
}
|
||||||
|
|
||||||
const sectionTitle = computed(() =>
|
const sectionTitle = computed(() =>
|
||||||
activeSection.value === 'api-keys' ? 'API キー' : 'SBI パスキー',
|
isNewApiKey.value
|
||||||
|
? 'API Key 発行'
|
||||||
|
: activeSection.value === 'api-keys'
|
||||||
|
? 'API キー'
|
||||||
|
: 'SBI パスキー',
|
||||||
)
|
)
|
||||||
|
|
||||||
const openApiKeyEditor = (key: ApiKey) => {
|
const openApiKeyEditor = (key: ApiKey) => {
|
||||||
@@ -103,19 +118,12 @@ const saveEditingApiKey = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section
|
<section class="grid min-h-[calc(100dvh-9rem)] min-w-0 max-w-full gap-0 lg:block">
|
||||||
class="grid min-h-[calc(100dvh-9rem)] min-w-0 max-w-full gap-0 lg:grid-cols-[18rem_minmax(0,1fr)]"
|
|
||||||
>
|
|
||||||
<aside
|
<aside
|
||||||
class="min-w-0 border-b border-[#30343a] pb-4 lg:sticky lg:top-0 lg:h-fit lg:border-r lg:border-b-0 lg:pr-5 lg:pb-0"
|
class="min-w-0 border-b border-[#30343a] pb-4 lg:fixed lg:top-8 lg:left-[9rem] lg:z-10 lg:h-fit lg:max-h-[calc(100dvh-4rem)] lg:w-72 lg:overflow-y-auto lg:border-r lg:border-b-0 lg:pr-5 lg:pb-0"
|
||||||
aria-label="Settings"
|
aria-label="Settings"
|
||||||
>
|
>
|
||||||
<div class="grid gap-5">
|
<div class="grid gap-5">
|
||||||
<div class="grid gap-1">
|
|
||||||
<h2 class="text-xl font-black text-[#e3e3e9]">設定</h2>
|
|
||||||
<p class="text-sm font-semibold text-[#9aa0a9]">用途ごとに管理項目を分けています。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav class="grid gap-5">
|
<nav class="grid gap-5">
|
||||||
<section v-for="group in settingsGroups" :key="group.label" class="grid gap-2">
|
<section v-for="group in settingsGroups" :key="group.label" class="grid gap-2">
|
||||||
<p class="px-2 text-xs font-black text-[#747982]">{{ group.label }}</p>
|
<p class="px-2 text-xs font-black text-[#747982]">{{ group.label }}</p>
|
||||||
@@ -153,32 +161,43 @@ const saveEditingApiKey = () => {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="grid min-w-0 content-start gap-5 pt-5 lg:pl-7 lg:pt-0">
|
<div class="grid min-w-0 content-start gap-8 pt-5 lg:ml-72 lg:pl-7 lg:pt-0">
|
||||||
<header
|
<header
|
||||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-[#30343a] pb-5"
|
class="flex flex-wrap items-center justify-between gap-3 border-b border-[#30343a] pb-5"
|
||||||
>
|
>
|
||||||
<div class="grid gap-1">
|
<div class="grid gap-1">
|
||||||
<p :class="ui.eyebrow">Settings</p>
|
|
||||||
<h2 class="text-2xl font-black text-[#e3e3e9]">{{ sectionTitle }}</h2>
|
<h2 class="text-2xl font-black text-[#e3e3e9]">{{ sectionTitle }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button :class="ui.ghostButton" type="button" @click="emit('refresh')">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<RefreshCw class="h-4 w-4" aria-hidden="true" />
|
<button
|
||||||
更新
|
v-if="isNewApiKey"
|
||||||
</button>
|
:class="ui.primaryButton"
|
||||||
|
type="button"
|
||||||
|
@click="emit('addApiKey')"
|
||||||
|
>
|
||||||
|
<KeyRound class="h-4 w-4" aria-hidden="true" />
|
||||||
|
発行
|
||||||
|
</button>
|
||||||
|
<template v-else>
|
||||||
|
<button :class="ui.ghostButton" type="button" @click="emit('refresh')">
|
||||||
|
<RefreshCw class="h-4 w-4" aria-hidden="true" />
|
||||||
|
更新
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="activeSection === 'api-keys'"
|
||||||
|
class="inline-grid h-10 w-10 place-items-center rounded-full bg-[#a8c7fa] text-[#102033] shadow-sm shadow-black/20 transition hover:bg-[#d3e3fd] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]"
|
||||||
|
type="button"
|
||||||
|
title="API Key 発行"
|
||||||
|
@click="openNewApiKey"
|
||||||
|
>
|
||||||
|
<Plus class="h-4 w-4" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<template v-if="activeSection === 'api-keys'">
|
<template v-if="isNewApiKey">
|
||||||
<article :class="[ui.panel, 'min-w-0 overflow-hidden']">
|
<article class="grid min-w-0 content-start gap-4 overflow-hidden bg-transparent">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<p :class="ui.eyebrow">Create</p>
|
|
||||||
<h3 class="text-lg font-black">API Key 発行</h3>
|
|
||||||
</div>
|
|
||||||
<button :class="ui.primaryButton" type="button" @click="emit('addApiKey')">
|
|
||||||
<KeyRound class="h-4 w-4" aria-hidden="true" />
|
|
||||||
発行
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<label :class="ui.label">
|
<label :class="ui.label">
|
||||||
名前
|
名前
|
||||||
<input v-model="apiKeyLabel" :class="ui.input" placeholder="例: trade-bot-readonly" />
|
<input v-model="apiKeyLabel" :class="ui.input" placeholder="例: trade-bot-readonly" />
|
||||||
@@ -193,18 +212,14 @@ const saveEditingApiKey = () => {
|
|||||||
></textarea>
|
></textarea>
|
||||||
</label>
|
</label>
|
||||||
</article>
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
<article :class="[ui.panel, 'min-w-0 overflow-hidden']">
|
<template v-else-if="activeSection === 'api-keys'">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<article class="grid min-w-0 content-start gap-4 overflow-hidden bg-transparent">
|
||||||
<div>
|
|
||||||
<p :class="ui.eyebrow">Active keys</p>
|
|
||||||
<h3 class="text-lg font-black">発行済みキー</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="apiKeys.length" :class="ui.list">
|
<div v-if="apiKeys.length" :class="ui.list">
|
||||||
<div v-for="key in apiKeys" :key="key.id" :class="ui.keyRow">
|
<div v-for="key in apiKeys" :key="key.id" :class="ui.keyRow">
|
||||||
<div :class="ui.row">
|
<div :class="ui.row">
|
||||||
<span class="min-w-0 truncate font-black">{{ key.label }}</span>
|
<span class="min-w-0 truncate font-bold text-slate-100">{{ key.label }}</span>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<button
|
<button
|
||||||
:class="ui.ghostButton"
|
:class="ui.ghostButton"
|
||||||
@@ -238,10 +253,9 @@ const saveEditingApiKey = () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<article :class="[ui.panel, 'min-w-0 overflow-hidden']">
|
<article class="grid min-w-0 content-start gap-4 overflow-hidden bg-transparent">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p :class="ui.eyebrow">Profiles</p>
|
|
||||||
<h3 class="text-lg font-black">パスキー追加</h3>
|
<h3 class="text-lg font-black">パスキー追加</h3>
|
||||||
</div>
|
</div>
|
||||||
<button :class="ui.primaryButton" type="button" @click="emit('addSbiPasskey')">
|
<button :class="ui.primaryButton" type="button" @click="emit('addSbiPasskey')">
|
||||||
@@ -279,10 +293,9 @@ const saveEditingApiKey = () => {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article :class="[ui.panel, 'min-w-0 overflow-hidden']">
|
<article class="grid min-w-0 content-start gap-4 overflow-hidden bg-transparent">
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p :class="ui.eyebrow">Connection</p>
|
|
||||||
<h3 class="text-lg font-black">プロフィール切り替え</h3>
|
<h3 class="text-lg font-black">プロフィール切り替え</h3>
|
||||||
</div>
|
</div>
|
||||||
<button :class="ui.ghostButton" type="button" @click="emit('connect')">
|
<button :class="ui.ghostButton" type="button" @click="emit('connect')">
|
||||||
@@ -328,38 +341,24 @@ const saveEditingApiKey = () => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<AnimatePresence>
|
||||||
v-if="editingApiKey"
|
<UiModal
|
||||||
class="fixed inset-0 z-30 grid place-items-center overflow-y-auto bg-[#101418]/75 p-4 backdrop-blur-sm"
|
v-if="editingApiKey"
|
||||||
role="dialog"
|
:title="editingApiKey.label"
|
||||||
aria-modal="true"
|
size="lg"
|
||||||
@click.self="closeApiKeyEditor"
|
@close="closeApiKeyEditor"
|
||||||
>
|
|
||||||
<section
|
|
||||||
class="grid max-h-[calc(100dvh-2rem)] w-full max-w-5xl gap-5 overflow-y-auto rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-2xl shadow-black/35"
|
|
||||||
>
|
>
|
||||||
<header
|
<template #actions>
|
||||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-[#33383f] pb-4"
|
<button :class="ui.ghostButton" type="button" @click="closeApiKeyEditor">
|
||||||
>
|
キャンセル
|
||||||
<div class="grid min-w-0 gap-1">
|
</button>
|
||||||
<p :class="ui.eyebrow">Edit API key</p>
|
<button :class="ui.primaryButton" type="button" @click="saveEditingApiKey">
|
||||||
<h3 class="truncate text-xl font-black text-[#e3e3e9]">
|
<Save class="h-4 w-4" aria-hidden="true" />
|
||||||
{{ editingApiKey.label }}
|
保存
|
||||||
</h3>
|
</button>
|
||||||
</div>
|
</template>
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<button :class="ui.ghostButton" type="button" @click="closeApiKeyEditor">
|
|
||||||
キャンセル
|
|
||||||
</button>
|
|
||||||
<button :class="ui.primaryButton" type="button" @click="saveEditingApiKey">
|
|
||||||
<Save class="h-4 w-4" aria-hidden="true" />
|
|
||||||
保存
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<ApiKeyPolicyEditor v-model="editingApiKey" permissions-open />
|
<ApiKeyPolicyEditor v-model="editingApiKey" permissions-open />
|
||||||
</section>
|
</UiModal>
|
||||||
</div>
|
</AnimatePresence>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
import * as d3 from 'd3'
|
import * as d3 from 'd3'
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import Spinner from '../../components/ui/Spinner.vue'
|
import Spinner from '../../components/ui/Spinner.vue'
|
||||||
|
import { marketTimeZones } from '../../constants/market'
|
||||||
import type { ChartMode, ChartNotice, ChartRange, RealtimePricePoint } from '../../types/trading'
|
import type { ChartMode, ChartNotice, ChartRange, RealtimePricePoint } from '../../types/trading'
|
||||||
|
import { currencyForMarket } from '../../utils/format'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
points: RealtimePricePoint[]
|
points: RealtimePricePoint[]
|
||||||
@@ -10,6 +12,7 @@ const props = defineProps<{
|
|||||||
active: boolean
|
active: boolean
|
||||||
mode: ChartMode
|
mode: ChartMode
|
||||||
range: ChartRange
|
range: ChartRange
|
||||||
|
market?: string
|
||||||
previousClose?: number
|
previousClose?: number
|
||||||
notice?: ChartNotice | null
|
notice?: ChartNotice | null
|
||||||
}>()
|
}>()
|
||||||
@@ -40,7 +43,8 @@ const tooltip = ref({
|
|||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
price: '',
|
price: '',
|
||||||
date: '',
|
jstDate: '',
|
||||||
|
localDate: '',
|
||||||
detail: '',
|
detail: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -62,7 +66,6 @@ const dayMs = 24 * 60 * 60 * 1000
|
|||||||
const tolerancePx = 10
|
const tolerancePx = 10
|
||||||
const offscreenPointBuffer = 4
|
const offscreenPointBuffer = 4
|
||||||
const minXAxisTickSpacing = 72
|
const minXAxisTickSpacing = 72
|
||||||
|
|
||||||
const chartPoints = computed<ChartPoint[]>(() =>
|
const chartPoints = computed<ChartPoint[]>(() =>
|
||||||
props.points
|
props.points
|
||||||
.map((point, index) => ({ ...point, index, date: new Date(point.at) }))
|
.map((point, index) => ({ ...point, index, date: new Date(point.at) }))
|
||||||
@@ -80,6 +83,31 @@ const labelFor = (point: ChartPoint) => {
|
|||||||
}).format(point.date)
|
}).format(point.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (props.range === '3D') {
|
||||||
|
if (
|
||||||
|
point.date.getHours() === 0 &&
|
||||||
|
point.date.getMinutes() === 0 &&
|
||||||
|
point.date.getSeconds() === 0
|
||||||
|
) {
|
||||||
|
return new Intl.DateTimeFormat('ja-JP', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
}).format(point.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('ja-JP', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
}).format(point.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.range === 'ALL') {
|
||||||
|
return new Intl.DateTimeFormat('ja-JP', {
|
||||||
|
year: 'numeric',
|
||||||
|
}).format(point.date)
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
point.date.getHours() === 0 &&
|
point.date.getHours() === 0 &&
|
||||||
point.date.getMinutes() === 0 &&
|
point.date.getMinutes() === 0 &&
|
||||||
@@ -97,6 +125,9 @@ const labelFor = (point: ChartPoint) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const closePrice = (point: ChartPoint) => point.close ?? point.price
|
const closePrice = (point: ChartPoint) => point.close ?? point.price
|
||||||
|
const marketTimeZone = computed(
|
||||||
|
() => marketTimeZones[props.market?.toUpperCase() ?? ''] ?? 'Asia/Tokyo',
|
||||||
|
)
|
||||||
|
|
||||||
const previousCloseValue = computed(() =>
|
const previousCloseValue = computed(() =>
|
||||||
props.range === '1D' && Number.isFinite(props.previousClose) && props.previousClose
|
props.range === '1D' && Number.isFinite(props.previousClose) && props.previousClose
|
||||||
@@ -112,10 +143,31 @@ const ohlcFor = (point: ChartPoint) => {
|
|||||||
return { open, high, low, close }
|
return { open, high, low, close }
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipDateFor = (point: ChartPoint) => {
|
const zonedClockParts = (date: Date, timeZone: string) => {
|
||||||
const hasTime =
|
const parts = Object.fromEntries(
|
||||||
point.date.getHours() !== 0 || point.date.getMinutes() !== 0 || point.date.getSeconds() !== 0
|
new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
hourCycle: 'h23',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
})
|
||||||
|
.formatToParts(date)
|
||||||
|
.filter((part) => part.type !== 'literal')
|
||||||
|
.map((part) => [part.type, Number(part.value)]),
|
||||||
|
) as Partial<Record<'hour' | 'minute' | 'second', number>>
|
||||||
|
return {
|
||||||
|
hour: parts.hour ?? 0,
|
||||||
|
minute: parts.minute ?? 0,
|
||||||
|
second: parts.second ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipDateFor = (point: ChartPoint, timeZone: string) => {
|
||||||
|
const parts = zonedClockParts(point.date, timeZone)
|
||||||
|
const hasTime = parts.hour !== 0 || parts.minute !== 0 || parts.second !== 0
|
||||||
return new Intl.DateTimeFormat('ja-JP', {
|
return new Intl.DateTimeFormat('ja-JP', {
|
||||||
|
timeZone,
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -124,22 +176,24 @@ const tooltipDateFor = (point: ChartPoint) => {
|
|||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
second: '2-digit',
|
second: '2-digit',
|
||||||
|
timeZoneName: 'short',
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}).format(point.date)
|
}).format(point.date)
|
||||||
}
|
}
|
||||||
|
|
||||||
const tooltipStyle = computed(() => {
|
const tooltipStyle = computed(() => {
|
||||||
const width = 152
|
const width = 220
|
||||||
|
const height = 92
|
||||||
const pad = 8
|
const pad = 8
|
||||||
const left = Math.min(
|
const left = Math.min(
|
||||||
Math.max(pad, tooltip.value.x + 12),
|
Math.max(pad, tooltip.value.x + 12),
|
||||||
Math.max(pad, size.value.width - width - pad),
|
Math.max(pad, size.value.width - width - pad),
|
||||||
)
|
)
|
||||||
const top =
|
const top =
|
||||||
tooltip.value.y > 76
|
tooltip.value.y > height + pad
|
||||||
? Math.max(pad, tooltip.value.y - 70)
|
? Math.max(pad, tooltip.value.y - height)
|
||||||
: Math.min(size.value.height - 64, tooltip.value.y + 14)
|
: Math.min(size.value.height - height - pad, tooltip.value.y + 14)
|
||||||
return {
|
return {
|
||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
top: `${top}px`,
|
top: `${top}px`,
|
||||||
@@ -487,11 +541,12 @@ const showHover = (point: ChartPoint) => {
|
|||||||
visible: true,
|
visible: true,
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
price: `${closePrice(point).toLocaleString('ja-JP')}円`,
|
price: currencyForMarket(closePrice(point), props.market),
|
||||||
date: tooltipDateFor(point),
|
jstDate: tooltipDateFor(point, 'Asia/Tokyo'),
|
||||||
|
localDate: tooltipDateFor(point, marketTimeZone.value),
|
||||||
detail:
|
detail:
|
||||||
props.mode === 'box'
|
props.mode === 'box'
|
||||||
? `始 ${ohlc.open.toLocaleString('ja-JP')} / 高 ${ohlc.high.toLocaleString('ja-JP')} / 安 ${ohlc.low.toLocaleString('ja-JP')}`
|
? `始 ${currencyForMarket(ohlc.open, props.market)} / 高 ${currencyForMarket(ohlc.high, props.market)} / 安 ${currencyForMarket(ohlc.low, props.market)}`
|
||||||
: '',
|
: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,20 +752,15 @@ onBeforeUnmount(() => {
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="notice"
|
class="pointer-events-none absolute w-[220px] translate-y-1 rounded-md border border-[#2d3440] bg-[#111418]/95 px-3 py-2 text-xs text-[#d3e3fd] opacity-0 shadow-lg shadow-black/30 transition-all duration-150 ease-out"
|
||||||
class="pointer-events-none absolute left-3 top-3 max-w-[min(22rem,calc(100%-1.5rem))] rounded-md border border-[#3a424f] bg-[#111418]/90 px-3 py-2 text-xs text-[#d3e3fd] shadow-lg shadow-black/20"
|
|
||||||
>
|
|
||||||
<div class="font-black text-[#ffcf6e]">{{ notice.title }}</div>
|
|
||||||
<div v-if="notice.detail" class="mt-0.5 text-[#8f949d]">{{ notice.detail }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="pointer-events-none absolute w-[152px] translate-y-1 rounded-md border border-[#2d3440] bg-[#111418]/95 px-3 py-2 text-xs text-[#d3e3fd] opacity-0 shadow-lg shadow-black/30 transition-all duration-150 ease-out"
|
|
||||||
:class="tooltip.visible && 'translate-y-0 opacity-100'"
|
:class="tooltip.visible && 'translate-y-0 opacity-100'"
|
||||||
:style="tooltipStyle"
|
:style="tooltipStyle"
|
||||||
>
|
>
|
||||||
<div class="font-black text-[#40dba2]">{{ tooltip.price }}</div>
|
<div class="font-black text-[#40dba2]">{{ tooltip.price }}</div>
|
||||||
<div class="mt-1 text-[#8f949d]">{{ tooltip.date }}</div>
|
<div class="mt-1 grid gap-0.5 text-[#8f949d]">
|
||||||
|
<div>JST {{ tooltip.jstDate }}</div>
|
||||||
|
<div>現地 {{ tooltip.localDate }}</div>
|
||||||
|
</div>
|
||||||
<div v-if="tooltip.detail" class="mt-1 leading-relaxed text-[#8f949d]">
|
<div v-if="tooltip.detail" class="mt-1 leading-relaxed text-[#8f949d]">
|
||||||
{{ tooltip.detail }}
|
{{ tooltip.detail }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
FileCheck2,
|
FileCheck2,
|
||||||
Minus,
|
Minus,
|
||||||
|
TriangleAlert,
|
||||||
Plus,
|
Plus,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
Search,
|
Search,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
tradeSideOptions,
|
tradeSideOptions,
|
||||||
} from '../../constants/trade'
|
} from '../../constants/trade'
|
||||||
import { ui } from '../../styles/ui'
|
import { ui } from '../../styles/ui'
|
||||||
|
import { uiMotion } from '../../constants/motion'
|
||||||
import type {
|
import type {
|
||||||
ChartMode,
|
ChartMode,
|
||||||
ChartNotice,
|
ChartNotice,
|
||||||
@@ -39,7 +41,7 @@ import type {
|
|||||||
Stock,
|
Stock,
|
||||||
TradeSide,
|
TradeSide,
|
||||||
} from '../../types/trading'
|
} from '../../types/trading'
|
||||||
import { currency } from '../../utils/format'
|
import { currency, currencyForMarket } from '../../utils/format'
|
||||||
import RealtimePriceChart from './RealtimePriceChart.vue'
|
import RealtimePriceChart from './RealtimePriceChart.vue'
|
||||||
|
|
||||||
type StockInfoTab = 'detail' | 'holding'
|
type StockInfoTab = 'detail' | 'holding'
|
||||||
@@ -60,7 +62,7 @@ type CashOrderDateOption = {
|
|||||||
value: string
|
value: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartRangeOptions: ChartRange[] = ['1D', '7D', '1M', '3M', '1Y', 'ALL']
|
const chartRangeOptions: ChartRange[] = ['1D', '3D', '3M', '1Y', 'ALL']
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
viewedStocks: Stock[]
|
viewedStocks: Stock[]
|
||||||
@@ -128,9 +130,9 @@ const secondaryPriceConditionRequiresPrice = computed(() =>
|
|||||||
cashOrderSecondaryPriceCondition.value,
|
cashOrderSecondaryPriceCondition.value,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const tabTransition = { type: 'spring', damping: 28, stiffness: 360 } as const
|
const tabTransition = uiMotion.trade.tabIndicator
|
||||||
const tabContentTransition = { duration: 0.18, ease: 'easeOut' } as const
|
const tabContentTransition = uiMotion.trade.tabContentFade
|
||||||
const advancedTransition = { duration: 0.18, ease: 'easeOut' } as const
|
const advancedTransition = uiMotion.trade.advancedOptions
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
openSearch: []
|
openSearch: []
|
||||||
@@ -143,41 +145,8 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section :class="ui.tradeLayout">
|
<section :class="ui.tradeLayout">
|
||||||
<article :class="ui.watchlist">
|
|
||||||
<button :class="ui.watchSearch" type="button" @click="emit('openSearch')">
|
|
||||||
<Search class="h-4 w-4" aria-hidden="true" />
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-for="stock in viewedStocks"
|
|
||||||
:key="stock.code"
|
|
||||||
:class="[ui.watchRow, selectedStock.code === stock.code && ui.watchRowActive]"
|
|
||||||
type="button"
|
|
||||||
:aria-current="selectedStock.code === stock.code ? 'true' : undefined"
|
|
||||||
@click="emit('selectStock', stock)"
|
|
||||||
>
|
|
||||||
<span class="grid gap-1">
|
|
||||||
<strong>{{ stock.name }}</strong>
|
|
||||||
<small>{{ stock.symbol }}</small>
|
|
||||||
</span>
|
|
||||||
<span class="grid justify-items-end gap-1">
|
|
||||||
<strong>
|
|
||||||
<template v-if="hasQuote(stock)">{{ currency(stock.price) }}</template>
|
|
||||||
<Spinner v-else size="sm" />
|
|
||||||
</strong>
|
|
||||||
<small v-if="hasQuote(stock)" :class="stock.change >= 0 ? ui.positive : ui.negative">
|
|
||||||
{{ stock.change >= 0 ? '+' : '' }}{{ stock.change }}%
|
|
||||||
</small>
|
|
||||||
<Spinner v-else size="sm" />
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
<p v-if="!viewedStocks.length" :class="[ui.muted, 'p-5 text-sm']">
|
|
||||||
SBIに接続するか検索すると銘柄を表示します
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<div :class="ui.centerStack">
|
<div :class="ui.centerStack">
|
||||||
<article :class="ui.stockPanel">
|
<article :class="ui.stockMetadataPanel">
|
||||||
<div :class="ui.stockTitle">
|
<div :class="ui.stockTitle">
|
||||||
<div>
|
<div>
|
||||||
<p :class="ui.eyebrow">{{ selectedStock.symbol }}</p>
|
<p :class="ui.eyebrow">{{ selectedStock.symbol }}</p>
|
||||||
@@ -186,7 +155,7 @@ const emit = defineEmits<{
|
|||||||
<div :class="ui.quoteBox">
|
<div :class="ui.quoteBox">
|
||||||
<strong>
|
<strong>
|
||||||
<template v-if="hasQuote(selectedStock)">{{
|
<template v-if="hasQuote(selectedStock)">{{
|
||||||
currency(selectedStock.price)
|
currencyForMarket(selectedStock.price, selectedStock.market)
|
||||||
}}</template>
|
}}</template>
|
||||||
<Spinner v-else size="sm" />
|
<Spinner v-else size="sm" />
|
||||||
</strong>
|
</strong>
|
||||||
@@ -199,210 +168,226 @@ const emit = defineEmits<{
|
|||||||
<Spinner v-else size="sm" />
|
<Spinner v-else size="sm" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="ui.periodTabs">
|
|
||||||
<button
|
|
||||||
v-for="range in chartRangeOptions"
|
|
||||||
:key="range"
|
|
||||||
:class="[ui.periodButton, chartRange === range && ui.periodButtonActive]"
|
|
||||||
type="button"
|
|
||||||
:aria-pressed="chartRange === range"
|
|
||||||
@click="chartRange = range"
|
|
||||||
>
|
|
||||||
{{ range }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :class="ui.chartActions">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div :class="ui.smallTabs">
|
|
||||||
<button
|
|
||||||
:class="[ui.smallTab, chartMode === 'line' && ui.smallTabActive]"
|
|
||||||
type="button"
|
|
||||||
@click="chartMode = 'line'"
|
|
||||||
>
|
|
||||||
推移
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
:class="[ui.smallTab, chartMode === 'box' && ui.smallTabActive]"
|
|
||||||
type="button"
|
|
||||||
@click="chartMode = 'box'"
|
|
||||||
>
|
|
||||||
箱ひげ
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-1">
|
|
||||||
<button
|
|
||||||
class="grid h-8 w-8 place-items-center rounded-md border border-[#2d3440] text-[#d3e3fd] transition hover:bg-[#1d232b] disabled:opacity-40"
|
|
||||||
type="button"
|
|
||||||
aria-label="縮小"
|
|
||||||
:disabled="!hasQuote(selectedStock)"
|
|
||||||
@click="priceChart?.zoomOut()"
|
|
||||||
>
|
|
||||||
<Minus class="h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="grid h-8 w-8 place-items-center rounded-md border border-[#2d3440] text-[#d3e3fd] transition hover:bg-[#1d232b] disabled:opacity-40"
|
|
||||||
type="button"
|
|
||||||
aria-label="拡大"
|
|
||||||
:disabled="!hasQuote(selectedStock)"
|
|
||||||
@click="priceChart?.zoomIn()"
|
|
||||||
>
|
|
||||||
<Plus class="h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="grid h-8 w-8 place-items-center rounded-md border border-[#2d3440] text-[#8f949d] transition hover:bg-[#1d232b] hover:text-[#d3e3fd] disabled:opacity-40"
|
|
||||||
type="button"
|
|
||||||
aria-label="縮尺を戻す"
|
|
||||||
:disabled="!hasQuote(selectedStock)"
|
|
||||||
@click="priceChart?.resetZoom()"
|
|
||||||
>
|
|
||||||
<RotateCcw class="h-3.5 w-3.5" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button :class="ui.ghostButton" type="button" @click="emit('downloadCsv')">
|
|
||||||
<Download class="h-4 w-4" aria-hidden="true" />
|
|
||||||
CSV
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div :class="ui.chartBox">
|
|
||||||
<RealtimePriceChart
|
|
||||||
v-if="hasQuote(selectedStock)"
|
|
||||||
ref="priceChart"
|
|
||||||
:points="realtimePricePoints"
|
|
||||||
:stock-name="selectedStock.name"
|
|
||||||
:active="pricePolling"
|
|
||||||
:mode="chartMode"
|
|
||||||
:range="chartRange"
|
|
||||||
:previous-close="selectedStock.prevClose"
|
|
||||||
:notice="chartNotice"
|
|
||||||
/>
|
|
||||||
<span v-else class="grid h-full place-items-center text-[#8f949d]">
|
|
||||||
<Spinner />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<LayoutGroup id="stock-info-tabs">
|
<div :class="ui.stockBodyGrid">
|
||||||
<article :class="ui.infoTabPanel">
|
<article :class="[ui.stockPanel, ui.stockPanelSpan]">
|
||||||
<div :class="ui.infoTabList" role="tablist" aria-label="銘柄別情報">
|
<div :class="ui.periodTabs">
|
||||||
<button
|
<button
|
||||||
id="stock-detail-tab"
|
v-for="range in chartRangeOptions"
|
||||||
:class="[ui.infoTabButton, activeStockInfoTab === 'detail' && ui.infoTabButtonActive]"
|
:key="range"
|
||||||
|
:class="[ui.periodButton, chartRange === range && ui.periodButtonActive]"
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
:aria-pressed="chartRange === range"
|
||||||
:aria-selected="activeStockInfoTab === 'detail'"
|
@click="chartRange = range"
|
||||||
aria-controls="stock-detail-panel"
|
|
||||||
@click="activeStockInfoTab = 'detail'"
|
|
||||||
>
|
>
|
||||||
詳細
|
{{ range }}
|
||||||
<motion.span
|
|
||||||
v-if="activeStockInfoTab === 'detail'"
|
|
||||||
layout-id="stock-info-tab-indicator"
|
|
||||||
:class="ui.infoTabIndicator"
|
|
||||||
:transition="tabTransition"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
id="stock-holding-tab"
|
|
||||||
:class="[
|
|
||||||
ui.infoTabButton,
|
|
||||||
activeStockInfoTab === 'holding' && ui.infoTabButtonActive,
|
|
||||||
]"
|
|
||||||
type="button"
|
|
||||||
role="tab"
|
|
||||||
:aria-selected="activeStockInfoTab === 'holding'"
|
|
||||||
aria-controls="stock-holding-panel"
|
|
||||||
@click="activeStockInfoTab = 'holding'"
|
|
||||||
>
|
|
||||||
保有
|
|
||||||
<motion.span
|
|
||||||
v-if="activeStockInfoTab === 'holding'"
|
|
||||||
layout-id="stock-info-tab-indicator"
|
|
||||||
:class="ui.infoTabIndicator"
|
|
||||||
:transition="tabTransition"
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="ui.infoTabBody">
|
<div :class="ui.chartActions">
|
||||||
<AnimatePresence mode="wait" :initial="false">
|
<div class="flex items-center gap-2">
|
||||||
<motion.dl
|
<div :class="ui.smallTabs">
|
||||||
v-if="activeStockInfoTab === 'detail'"
|
<button
|
||||||
id="stock-detail-panel"
|
:class="[ui.smallTab, chartMode === 'line' && ui.smallTabActive]"
|
||||||
key="detail"
|
type="button"
|
||||||
:class="ui.detailGrid"
|
@click="chartMode = 'line'"
|
||||||
role="tabpanel"
|
>
|
||||||
aria-labelledby="stock-detail-tab"
|
推移
|
||||||
:initial="{ opacity: 0, y: 8 }"
|
</button>
|
||||||
:animate="{ opacity: 1, y: 0 }"
|
<button
|
||||||
:exit="{ opacity: 0, y: -8 }"
|
:class="[ui.smallTab, chartMode === 'box' && ui.smallTabActive]"
|
||||||
:transition="tabContentTransition"
|
type="button"
|
||||||
>
|
@click="chartMode = 'box'"
|
||||||
<div :class="ui.detailItem">
|
>
|
||||||
<dt class="text-xs text-[#9aa0a9]">国</dt>
|
箱ひげ
|
||||||
<dd class="font-bold">{{ selectedStock.country }}</dd>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div :class="ui.detailItem">
|
<div class="flex items-center gap-1">
|
||||||
<dt class="text-xs text-[#9aa0a9]">市場</dt>
|
<button
|
||||||
<dd class="font-bold">{{ selectedStock.market }}</dd>
|
class="grid h-8 w-8 place-items-center rounded-md border border-[#2d3440] text-[#d3e3fd] transition hover:bg-[#1d232b] disabled:opacity-40"
|
||||||
</div>
|
type="button"
|
||||||
<div :class="ui.detailItem">
|
aria-label="縮小"
|
||||||
<dt class="text-xs text-[#9aa0a9]">業種</dt>
|
:disabled="!hasQuote(selectedStock)"
|
||||||
<dd class="font-bold">{{ selectedStock.sector }}</dd>
|
@click="priceChart?.zoomOut()"
|
||||||
</div>
|
>
|
||||||
<div :class="ui.detailItem">
|
<Minus class="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
<dt class="text-xs text-[#9aa0a9]">始値</dt>
|
</button>
|
||||||
<dd class="font-bold">
|
<button
|
||||||
<template v-if="hasQuote(selectedStock)">{{
|
class="grid h-8 w-8 place-items-center rounded-md border border-[#2d3440] text-[#d3e3fd] transition hover:bg-[#1d232b] disabled:opacity-40"
|
||||||
currency(selectedStock.open)
|
type="button"
|
||||||
}}</template>
|
aria-label="拡大"
|
||||||
<Spinner v-else size="sm" />
|
:disabled="!hasQuote(selectedStock)"
|
||||||
</dd>
|
@click="priceChart?.zoomIn()"
|
||||||
</div>
|
>
|
||||||
<div :class="ui.detailItem">
|
<Plus class="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
<dt class="text-xs text-[#9aa0a9]">高値</dt>
|
</button>
|
||||||
<dd class="font-bold">
|
<button
|
||||||
<template v-if="hasQuote(selectedStock)">{{
|
class="grid h-8 w-8 place-items-center rounded-md border border-[#2d3440] text-[#8f949d] transition hover:bg-[#1d232b] hover:text-[#d3e3fd] disabled:opacity-40"
|
||||||
currency(selectedStock.high)
|
type="button"
|
||||||
}}</template>
|
aria-label="縮尺を戻す"
|
||||||
<Spinner v-else size="sm" />
|
:disabled="!hasQuote(selectedStock)"
|
||||||
</dd>
|
@click="priceChart?.resetZoom()"
|
||||||
</div>
|
>
|
||||||
<div :class="ui.detailItem">
|
<RotateCcw class="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
<dt class="text-xs text-[#9aa0a9]">安値</dt>
|
</button>
|
||||||
<dd class="font-bold">
|
</div>
|
||||||
<template v-if="hasQuote(selectedStock)">{{
|
</div>
|
||||||
currency(selectedStock.low)
|
<button :class="ui.ghostButton" type="button" @click="emit('downloadCsv')">
|
||||||
}}</template>
|
<Download class="h-4 w-4" aria-hidden="true" />
|
||||||
<Spinner v-else size="sm" />
|
CSV
|
||||||
</dd>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</motion.dl>
|
|
||||||
|
|
||||||
<motion.div
|
<div :class="[ui.chartBox, 'overflow-visible']">
|
||||||
v-else
|
<RealtimePriceChart
|
||||||
id="stock-holding-panel"
|
v-if="hasQuote(selectedStock)"
|
||||||
key="holding"
|
ref="priceChart"
|
||||||
role="tabpanel"
|
:points="realtimePricePoints"
|
||||||
aria-labelledby="stock-holding-tab"
|
:stock-name="selectedStock.name"
|
||||||
:initial="{ opacity: 0, y: 8 }"
|
:active="pricePolling"
|
||||||
:animate="{ opacity: 1, y: 0 }"
|
:mode="chartMode"
|
||||||
:exit="{ opacity: 0, y: -8 }"
|
:range="chartRange"
|
||||||
:transition="tabContentTransition"
|
:market="selectedStock.market"
|
||||||
>
|
:previous-close="selectedStock.prevClose"
|
||||||
<div v-if="selectedPosition" :class="ui.holdingNote">
|
:notice="chartNotice"
|
||||||
保有 {{ selectedPosition.quantity }}株 / 平均
|
/>
|
||||||
{{ currency(selectedPosition.avgPrice) }}
|
<span v-else class="grid h-full place-items-center text-[#8f949d]">
|
||||||
</div>
|
<Spinner />
|
||||||
<p v-else :class="ui.holdingEmpty">保有なし</p>
|
</span>
|
||||||
</motion.div>
|
|
||||||
</AnimatePresence>
|
<div
|
||||||
|
v-if="chartNotice"
|
||||||
|
class="mt-2 flex items-start gap-1.5 px-3 pb-2 text-xs text-[#8f949d]"
|
||||||
|
>
|
||||||
|
<TriangleAlert class="mt-0.5 h-4 w-4 shrink-0 text-[#6f7783]" />
|
||||||
|
<span>
|
||||||
|
<span class="font-medium text-[#9aa0a9]">{{ chartNotice.title }}</span>
|
||||||
|
<span v-if="chartNotice.detail" class="ml-1">{{ chartNotice.detail }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</LayoutGroup>
|
|
||||||
|
<LayoutGroup id="stock-info-tabs">
|
||||||
|
<article :class="ui.infoTabPanel">
|
||||||
|
<div :class="ui.infoTabList" role="tablist" aria-label="銘柄別情報">
|
||||||
|
<button
|
||||||
|
id="stock-detail-tab"
|
||||||
|
:class="[
|
||||||
|
ui.infoTabButton,
|
||||||
|
activeStockInfoTab === 'detail' && ui.infoTabButtonActive,
|
||||||
|
]"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeStockInfoTab === 'detail'"
|
||||||
|
aria-controls="stock-detail-panel"
|
||||||
|
@click="activeStockInfoTab = 'detail'"
|
||||||
|
>
|
||||||
|
詳細
|
||||||
|
<motion.span
|
||||||
|
v-if="activeStockInfoTab === 'detail'"
|
||||||
|
layout-id="stock-info-tab-indicator"
|
||||||
|
:class="ui.infoTabIndicator"
|
||||||
|
:transition="tabTransition"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
id="stock-holding-tab"
|
||||||
|
:class="[
|
||||||
|
ui.infoTabButton,
|
||||||
|
activeStockInfoTab === 'holding' && ui.infoTabButtonActive,
|
||||||
|
]"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
:aria-selected="activeStockInfoTab === 'holding'"
|
||||||
|
aria-controls="stock-holding-panel"
|
||||||
|
@click="activeStockInfoTab = 'holding'"
|
||||||
|
>
|
||||||
|
保有
|
||||||
|
<motion.span
|
||||||
|
v-if="activeStockInfoTab === 'holding'"
|
||||||
|
layout-id="stock-info-tab-indicator"
|
||||||
|
:class="ui.infoTabIndicator"
|
||||||
|
:transition="tabTransition"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="ui.infoTabBody">
|
||||||
|
<div :class="ui.infoTabBodyInner">
|
||||||
|
<motion.dl
|
||||||
|
v-show="activeStockInfoTab === 'detail'"
|
||||||
|
id="stock-detail-panel"
|
||||||
|
key="detail"
|
||||||
|
:class="ui.detailGrid"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="stock-detail-tab"
|
||||||
|
:initial="{ opacity: 0 }"
|
||||||
|
:animate="{ opacity: activeStockInfoTab === 'detail' ? 1 : 0 }"
|
||||||
|
:transition="tabContentTransition"
|
||||||
|
>
|
||||||
|
<div :class="ui.detailItem">
|
||||||
|
<dt class="text-xs text-[#9aa0a9]">国</dt>
|
||||||
|
<dd class="font-bold">{{ selectedStock.country }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.detailItem">
|
||||||
|
<dt class="text-xs text-[#9aa0a9]">市場</dt>
|
||||||
|
<dd class="font-bold">{{ selectedStock.market }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.detailItem">
|
||||||
|
<dt class="text-xs text-[#9aa0a9]">業種</dt>
|
||||||
|
<dd class="font-bold">{{ selectedStock.sector }}</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.detailItem">
|
||||||
|
<dt class="text-xs text-[#9aa0a9]">始値</dt>
|
||||||
|
<dd class="font-bold">
|
||||||
|
<template v-if="hasQuote(selectedStock)">{{
|
||||||
|
currencyForMarket(selectedStock.open, selectedStock.market)
|
||||||
|
}}</template>
|
||||||
|
<Spinner v-else size="sm" />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.detailItem">
|
||||||
|
<dt class="text-xs text-[#9aa0a9]">高値</dt>
|
||||||
|
<dd class="font-bold">
|
||||||
|
<template v-if="hasQuote(selectedStock)">{{
|
||||||
|
currencyForMarket(selectedStock.high, selectedStock.market)
|
||||||
|
}}</template>
|
||||||
|
<Spinner v-else size="sm" />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div :class="ui.detailItem">
|
||||||
|
<dt class="text-xs text-[#9aa0a9]">安値</dt>
|
||||||
|
<dd class="font-bold">
|
||||||
|
<template v-if="hasQuote(selectedStock)">{{
|
||||||
|
currencyForMarket(selectedStock.low, selectedStock.market)
|
||||||
|
}}</template>
|
||||||
|
<Spinner v-else size="sm" />
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</motion.dl>
|
||||||
|
<motion.div
|
||||||
|
v-show="activeStockInfoTab === 'holding'"
|
||||||
|
id="stock-holding-panel"
|
||||||
|
key="holding"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby="stock-holding-tab"
|
||||||
|
:initial="{ opacity: 0 }"
|
||||||
|
:animate="{ opacity: activeStockInfoTab === 'holding' ? 1 : 0 }"
|
||||||
|
:transition="tabContentTransition"
|
||||||
|
>
|
||||||
|
<div v-if="selectedPosition" :class="ui.holdingNote">
|
||||||
|
保有 {{ selectedPosition.quantity }}株 / 平均
|
||||||
|
{{ currencyForMarket(selectedPosition.avgPrice, selectedPosition.market) }}
|
||||||
|
</div>
|
||||||
|
<p v-else :class="ui.holdingEmpty">保有なし</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</LayoutGroup>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div :class="ui.tradeSeparator" aria-hidden="true"></div>
|
<div :class="ui.tradeSeparator" aria-hidden="true"></div>
|
||||||
@@ -422,11 +407,13 @@ const emit = defineEmits<{
|
|||||||
min="1"
|
min="1"
|
||||||
placeholder="株数を入力"
|
placeholder="株数を入力"
|
||||||
/>
|
/>
|
||||||
<details
|
<section :class="ui.advancedOptions">
|
||||||
:class="ui.advancedOptions"
|
<button
|
||||||
@toggle="advancedOptionsOpen = ($event.target as HTMLDetailsElement).open"
|
type="button"
|
||||||
>
|
:class="ui.advancedSummary"
|
||||||
<summary :class="ui.advancedSummary">
|
:aria-expanded="advancedOptionsOpen"
|
||||||
|
@click="advancedOptionsOpen = !advancedOptionsOpen"
|
||||||
|
>
|
||||||
<span>Advanced Options</span>
|
<span>Advanced Options</span>
|
||||||
<motion.span
|
<motion.span
|
||||||
:class="ui.advancedSummaryIconWrap"
|
:class="ui.advancedSummaryIconWrap"
|
||||||
@@ -435,134 +422,53 @@ const emit = defineEmits<{
|
|||||||
>
|
>
|
||||||
<ChevronDown :class="ui.advancedSummaryIcon" aria-hidden="true" />
|
<ChevronDown :class="ui.advancedSummaryIcon" aria-hidden="true" />
|
||||||
</motion.span>
|
</motion.span>
|
||||||
</summary>
|
</button>
|
||||||
<motion.div
|
<AnimatePresence>
|
||||||
:class="ui.advancedBody"
|
<motion.div
|
||||||
:initial="{ opacity: 0, y: -4 }"
|
v-if="advancedOptionsOpen"
|
||||||
:animate="{ opacity: 1, y: 0 }"
|
key="advanced-options"
|
||||||
:transition="advancedTransition"
|
:class="ui.advancedBody"
|
||||||
>
|
:initial="{ opacity: 0, height: 0, y: -4 }"
|
||||||
<span :class="ui.advancedLabel">預り区分</span>
|
:animate="{ opacity: 1, height: 'auto', y: 0 }"
|
||||||
<LayoutGroup id="cash-order-account-type">
|
:exit="{ opacity: 0, height: 0, y: -4 }"
|
||||||
<div :class="ui.accountTypeGrid" role="radiogroup" aria-label="預り区分">
|
:transition="advancedTransition"
|
||||||
<button
|
|
||||||
v-for="option in cashOrderAccountTypeOptions"
|
|
||||||
:key="option.value"
|
|
||||||
type="button"
|
|
||||||
role="radio"
|
|
||||||
:aria-checked="cashOrderAccountType === option.value"
|
|
||||||
:class="[
|
|
||||||
ui.accountTypeButton,
|
|
||||||
cashOrderAccountType === option.value && ui.accountTypeButtonActive,
|
|
||||||
]"
|
|
||||||
@click="cashOrderAccountType = option.value"
|
|
||||||
>
|
|
||||||
<motion.span
|
|
||||||
v-if="cashOrderAccountType === option.value"
|
|
||||||
layout-id="cash-order-account-type-indicator"
|
|
||||||
:class="ui.accountTypeIndicator"
|
|
||||||
:transition="tabTransition"
|
|
||||||
/>
|
|
||||||
<span :class="ui.accountTypeText">{{ option.label }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</LayoutGroup>
|
|
||||||
<UiField
|
|
||||||
v-model="cashOrderMarket"
|
|
||||||
as="select"
|
|
||||||
label="注文市場"
|
|
||||||
:disabled="orderKind === 's'"
|
|
||||||
>
|
>
|
||||||
<option
|
<span :class="ui.advancedLabel">預り区分</span>
|
||||||
v-for="option in cashOrderMarketOptions"
|
<LayoutGroup id="cash-order-account-type">
|
||||||
:key="option.value"
|
<div :class="ui.accountTypeGrid" role="radiogroup" aria-label="預り区分">
|
||||||
:value="option.value"
|
<button
|
||||||
>
|
v-for="option in cashOrderAccountTypeOptions"
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</UiField>
|
|
||||||
<template v-if="orderKind !== 's'">
|
|
||||||
<UiField v-model="cashOrderPriceCondition" as="select" label="執行条件">
|
|
||||||
<option
|
|
||||||
v-for="option in cashOrderPriceConditionOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</UiField>
|
|
||||||
<UiField
|
|
||||||
v-if="primaryPriceConditionRequiresPrice"
|
|
||||||
v-model="priceInput"
|
|
||||||
label="注文価格"
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
:step="cashOrderPriceStep"
|
|
||||||
placeholder="価格を入力"
|
|
||||||
/>
|
|
||||||
<UiField v-model="cashOrderTerm" as="select" label="有効期限">
|
|
||||||
<option
|
|
||||||
v-for="option in cashOrderTermOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</UiField>
|
|
||||||
<UiField
|
|
||||||
v-if="cashOrderTerm === 'date' && cashOrderDateOptions.length"
|
|
||||||
v-model="cashOrderDateInput"
|
|
||||||
as="select"
|
|
||||||
label="指定日"
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
v-for="option in cashOrderDateOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</UiField>
|
|
||||||
<UiField
|
|
||||||
v-else-if="cashOrderTerm === 'date'"
|
|
||||||
v-model="cashOrderDateInput"
|
|
||||||
label="指定日"
|
|
||||||
type="date"
|
|
||||||
/>
|
|
||||||
<UiField v-model="cashOrderMethod" as="select" label="特殊注文">
|
|
||||||
<option
|
|
||||||
v-for="option in cashOrderMethodOptions"
|
|
||||||
:key="option.value"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
</UiField>
|
|
||||||
<template v-if="cashOrderMethod !== 'normal'">
|
|
||||||
<UiField v-model="cashOrderTriggerZone" as="select" label="逆指値条件">
|
|
||||||
<option
|
|
||||||
v-for="option in cashOrderTriggerZoneOptions"
|
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
:value="option.value"
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
:aria-checked="cashOrderAccountType === option.value"
|
||||||
|
:class="[
|
||||||
|
ui.accountTypeButton,
|
||||||
|
cashOrderAccountType === option.value && ui.accountTypeButtonActive,
|
||||||
|
]"
|
||||||
|
@click="cashOrderAccountType = option.value"
|
||||||
>
|
>
|
||||||
{{ option.label }}
|
<motion.span
|
||||||
</option>
|
v-if="cashOrderAccountType === option.value"
|
||||||
</UiField>
|
layout-id="cash-order-account-type-indicator"
|
||||||
<UiField
|
:class="ui.accountTypeIndicator"
|
||||||
v-model="cashOrderTriggerPriceInput"
|
:transition="tabTransition"
|
||||||
label="逆指値価格"
|
/>
|
||||||
type="number"
|
<span :class="ui.accountTypeText">{{ option.label }}</span>
|
||||||
min="1"
|
</button>
|
||||||
:step="cashOrderPriceStep"
|
</div>
|
||||||
placeholder="価格を入力"
|
</LayoutGroup>
|
||||||
/>
|
<UiField v-model="cashOrderMarket" as="select" label="注文市場">
|
||||||
</template>
|
<option
|
||||||
<template v-if="cashOrderMethod === 'oco'">
|
v-for="option in cashOrderMarketOptions"
|
||||||
<UiField
|
:key="option.value"
|
||||||
v-model="cashOrderSecondaryPriceCondition"
|
:value="option.value"
|
||||||
as="select"
|
|
||||||
label="OCO執行条件"
|
|
||||||
>
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</UiField>
|
||||||
|
<template>
|
||||||
|
<UiField v-model="cashOrderPriceCondition" as="select" label="執行条件">
|
||||||
<option
|
<option
|
||||||
v-for="option in cashOrderPriceConditionOptions"
|
v-for="option in cashOrderPriceConditionOptions"
|
||||||
:key="option.value"
|
:key="option.value"
|
||||||
@@ -572,25 +478,106 @@ const emit = defineEmits<{
|
|||||||
</option>
|
</option>
|
||||||
</UiField>
|
</UiField>
|
||||||
<UiField
|
<UiField
|
||||||
v-if="secondaryPriceConditionRequiresPrice"
|
v-if="primaryPriceConditionRequiresPrice"
|
||||||
v-model="cashOrderSecondaryPriceInput"
|
v-model="priceInput"
|
||||||
label="OCO価格"
|
label="注文価格"
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
:step="cashOrderPriceStep"
|
:step="cashOrderPriceStep"
|
||||||
placeholder="価格を入力"
|
placeholder="価格を入力"
|
||||||
/>
|
/>
|
||||||
|
<UiField v-model="cashOrderTerm" as="select" label="有効期限">
|
||||||
|
<option
|
||||||
|
v-for="option in cashOrderTermOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</UiField>
|
||||||
|
<UiField
|
||||||
|
v-if="cashOrderTerm === 'date' && cashOrderDateOptions.length"
|
||||||
|
v-model="cashOrderDateInput"
|
||||||
|
as="select"
|
||||||
|
label="指定日"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in cashOrderDateOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</UiField>
|
||||||
|
<UiField
|
||||||
|
v-else-if="cashOrderTerm === 'date'"
|
||||||
|
v-model="cashOrderDateInput"
|
||||||
|
label="指定日"
|
||||||
|
type="date"
|
||||||
|
/>
|
||||||
|
<UiField v-model="cashOrderMethod" as="select" label="特殊注文">
|
||||||
|
<option
|
||||||
|
v-for="option in cashOrderMethodOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</UiField>
|
||||||
|
<template v-if="cashOrderMethod !== 'normal'">
|
||||||
|
<UiField v-model="cashOrderTriggerZone" as="select" label="逆指値条件">
|
||||||
|
<option
|
||||||
|
v-for="option in cashOrderTriggerZoneOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</UiField>
|
||||||
|
<UiField
|
||||||
|
v-model="cashOrderTriggerPriceInput"
|
||||||
|
label="逆指値価格"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:step="cashOrderPriceStep"
|
||||||
|
placeholder="価格を入力"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-if="cashOrderMethod === 'oco'">
|
||||||
|
<UiField
|
||||||
|
v-model="cashOrderSecondaryPriceCondition"
|
||||||
|
as="select"
|
||||||
|
label="OCO執行条件"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="option in cashOrderPriceConditionOptions"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</UiField>
|
||||||
|
<UiField
|
||||||
|
v-if="secondaryPriceConditionRequiresPrice"
|
||||||
|
v-model="cashOrderSecondaryPriceInput"
|
||||||
|
label="OCO価格"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:step="cashOrderPriceStep"
|
||||||
|
placeholder="価格を入力"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</motion.div>
|
||||||
</motion.div>
|
</AnimatePresence>
|
||||||
</details>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div :class="ui.ticketBottom">
|
<div :class="ui.ticketBottom">
|
||||||
<div :class="ui.estimateSummary">
|
<div :class="ui.estimateSummary">
|
||||||
<span>概算金額</span>
|
<span>概算金額</span>
|
||||||
<strong>{{ currency(estimatedAmount) }}</strong>
|
<strong>{{ currencyForMarket(estimatedAmount, selectedStock.market) }}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div :class="ui.actions">
|
<div :class="ui.actions">
|
||||||
<UiButton type="button" :disabled="!canRequestCashEstimate" @click="emit('estimate')">
|
<UiButton type="button" :disabled="!canRequestCashEstimate" @click="emit('estimate')">
|
||||||
@@ -609,5 +596,40 @@ const emit = defineEmits<{
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article :class="ui.watchlist">
|
||||||
|
<button :class="ui.watchSearch" type="button" @click="emit('openSearch')">
|
||||||
|
<Search class="h-4 w-4" aria-hidden="true" />
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-for="stock in viewedStocks"
|
||||||
|
:key="stock.code"
|
||||||
|
:class="[ui.watchRow, selectedStock.code === stock.code && ui.watchRowActive]"
|
||||||
|
type="button"
|
||||||
|
:aria-current="selectedStock.code === stock.code ? 'true' : undefined"
|
||||||
|
@click="emit('selectStock', stock)"
|
||||||
|
>
|
||||||
|
<span class="grid gap-1">
|
||||||
|
<strong>{{ stock.name }}</strong>
|
||||||
|
<small>{{ stock.symbol }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="grid justify-items-end gap-1">
|
||||||
|
<strong>
|
||||||
|
<template v-if="hasQuote(stock)">{{
|
||||||
|
currencyForMarket(stock.price, stock.market)
|
||||||
|
}}</template>
|
||||||
|
<Spinner v-else size="sm" />
|
||||||
|
</strong>
|
||||||
|
<small v-if="hasQuote(stock)" :class="stock.change >= 0 ? ui.positive : ui.negative">
|
||||||
|
{{ stock.change >= 0 ? '+' : '' }}{{ stock.change }}%
|
||||||
|
</small>
|
||||||
|
<Spinner v-else size="sm" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="!viewedStocks.length" :class="[ui.muted, 'p-5 text-sm']">
|
||||||
|
SBIに接続するか検索すると銘柄を表示します
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -68,6 +68,11 @@ const nullableNumberValue = (value: unknown): number | null => {
|
|||||||
export const textValue = (value: unknown, fallback = ''): string =>
|
export const textValue = (value: unknown, fallback = ''): string =>
|
||||||
typeof value === 'string' && value ? value : fallback
|
typeof value === 'string' && value ? value : fallback
|
||||||
|
|
||||||
|
const issueSymbol = (code: string, market: string) => (market ? `${market}:${code}` : code)
|
||||||
|
|
||||||
|
const countryFromMarket = (market: string) =>
|
||||||
|
market === 'XNAS' || market === 'XNYS' || market === 'ARCX' ? '米国' : '日本'
|
||||||
|
|
||||||
export const issueFrom = (value: unknown) => {
|
export const issueFrom = (value: unknown) => {
|
||||||
const issue = asRecord(value)
|
const issue = asRecord(value)
|
||||||
return {
|
return {
|
||||||
@@ -110,8 +115,8 @@ const boxFromHistory = (history: number[]) => {
|
|||||||
export const stockFromIssue = (issue: ReturnType<typeof issueFrom>): Stock => ({
|
export const stockFromIssue = (issue: ReturnType<typeof issueFrom>): Stock => ({
|
||||||
code: issue.code,
|
code: issue.code,
|
||||||
name: issue.name || issue.code,
|
name: issue.name || issue.code,
|
||||||
symbol: issue.code ? `${issue.code}${issue.market ? `.${issue.market}` : ''}` : '',
|
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
|
||||||
country: '日本',
|
country: countryFromMarket(issue.market),
|
||||||
market: issue.market,
|
market: issue.market,
|
||||||
sector: '',
|
sector: '',
|
||||||
price: 0,
|
price: 0,
|
||||||
@@ -163,8 +168,8 @@ export const stockFromBoard = (
|
|||||||
return {
|
return {
|
||||||
code: issue.code,
|
code: issue.code,
|
||||||
name: issue.name,
|
name: issue.name,
|
||||||
symbol: issue.code ? `${issue.code}${issue.market ? `.${issue.market}` : ''}` : '',
|
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
|
||||||
country: '日本',
|
country: countryFromMarket(issue.market),
|
||||||
market: issue.market,
|
market: issue.market,
|
||||||
sector: '',
|
sector: '',
|
||||||
price,
|
price,
|
||||||
@@ -257,6 +262,15 @@ export const startOfMarketDateUtcMs = (timeZone: string, now = new Date()) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const marketDateKey = (timeZone: string, now = new Date()) => {
|
||||||
|
const parts = zonedParts(now, timeZone)
|
||||||
|
return [
|
||||||
|
parts.year,
|
||||||
|
String(parts.month).padStart(2, '0'),
|
||||||
|
String(parts.day).padStart(2, '0'),
|
||||||
|
].join('-')
|
||||||
|
}
|
||||||
|
|
||||||
const chartDateTimeToIso = (value: string, timeZone = 'Asia/Tokyo') => {
|
const chartDateTimeToIso = (value: string, timeZone = 'Asia/Tokyo') => {
|
||||||
const digits = value.replace(/\D/g, '')
|
const digits = value.replace(/\D/g, '')
|
||||||
if (digits.length < 8) return ''
|
if (digits.length < 8) return ''
|
||||||
@@ -319,6 +333,8 @@ export const chartNoticeFromIssueChart = (value: unknown): ChartNotice | null =>
|
|||||||
const today = japanToday()
|
const today = japanToday()
|
||||||
if (!latestDate || latestDate >= today) return null
|
if (!latestDate || latestDate >= today) return null
|
||||||
|
|
||||||
|
if (!isWeekendInJapan()) return null
|
||||||
|
|
||||||
const formattedLatestDate = new Intl.DateTimeFormat('ja-JP', {
|
const formattedLatestDate = new Intl.DateTimeFormat('ja-JP', {
|
||||||
timeZone: 'Asia/Tokyo',
|
timeZone: 'Asia/Tokyo',
|
||||||
month: 'numeric',
|
month: 'numeric',
|
||||||
@@ -326,7 +342,7 @@ export const chartNoticeFromIssueChart = (value: unknown): ChartNotice | null =>
|
|||||||
}).format(new Date(`${latestDate}T00:00:00+09:00`))
|
}).format(new Date(`${latestDate}T00:00:00+09:00`))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: isWeekendInJapan() ? '本日は休場日です' : '本日の取引データはまだありません',
|
title: '本日は休場日です',
|
||||||
detail: `最新のチャートは${formattedLatestDate}です`,
|
detail: `最新のチャートは${formattedLatestDate}です`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,7 +381,7 @@ export const orderFromApi = (value: unknown): OrderRow | null => {
|
|||||||
const issue = issueFrom(item.issue)
|
const issue = issueFrom(item.issue)
|
||||||
if (!issue.code) return null
|
if (!issue.code) return null
|
||||||
const side = item.side === 'sell' ? 'sell' : 'buy'
|
const side = item.side === 'sell' ? 'sell' : 'buy'
|
||||||
const kind = item.kind === 's' ? 's' : 'standard'
|
const kind = 'standard'
|
||||||
const status = orderStatusText(textValue(item.status, 'executed'))
|
const status = orderStatusText(textValue(item.status, 'executed'))
|
||||||
const executedQuantity = nullableNumberValue(item.executedQuantity)
|
const executedQuantity = nullableNumberValue(item.executedQuantity)
|
||||||
const orderedQuantity = nullableNumberValue(item.quantity)
|
const orderedQuantity = nullableNumberValue(item.quantity)
|
||||||
@@ -382,6 +398,7 @@ export const orderFromApi = (value: unknown): OrderRow | null => {
|
|||||||
id: textValue(item.id, textValue(item.orderNumber, `${issue.code}-${item.orderedAt ?? ''}`)),
|
id: textValue(item.id, textValue(item.orderNumber, `${issue.code}-${item.orderedAt ?? ''}`)),
|
||||||
date: textValue(item.orderedAt, textValue(item.expiresAt)),
|
date: textValue(item.orderedAt, textValue(item.expiresAt)),
|
||||||
stock: issue.name || issue.code,
|
stock: issue.name || issue.code,
|
||||||
|
market: issue.market,
|
||||||
side,
|
side,
|
||||||
kind,
|
kind,
|
||||||
quantity,
|
quantity,
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { createRpcSocket } from '../../api'
|
|||||||
import {
|
import {
|
||||||
cashOrderAccountTypeOptions as defaultCashOrderAccountTypeOptions,
|
cashOrderAccountTypeOptions as defaultCashOrderAccountTypeOptions,
|
||||||
cashOrderMarketOptions as defaultCashOrderMarketOptions,
|
cashOrderMarketOptions as defaultCashOrderMarketOptions,
|
||||||
sKabuOrderMarketOptions,
|
searchableMarkets,
|
||||||
} from '../../constants/trade'
|
} from '../../constants/trade'
|
||||||
|
import { countryTimeZones, marketSessions, marketTimeZones } from '../../constants/market'
|
||||||
import type {
|
import type {
|
||||||
ChartMode,
|
ChartMode,
|
||||||
ChartRange,
|
ChartRange,
|
||||||
@@ -32,6 +33,7 @@ import {
|
|||||||
fulfilledValues,
|
fulfilledValues,
|
||||||
isOrderPreview,
|
isOrderPreview,
|
||||||
issueFrom,
|
issueFrom,
|
||||||
|
marketDateKey,
|
||||||
numberValue,
|
numberValue,
|
||||||
orderFromApi,
|
orderFromApi,
|
||||||
orderHistoryKey,
|
orderHistoryKey,
|
||||||
@@ -39,7 +41,6 @@ import {
|
|||||||
chartNoticeFromIssueChart,
|
chartNoticeFromIssueChart,
|
||||||
pricePointsFromIssueChart,
|
pricePointsFromIssueChart,
|
||||||
positionFromApi,
|
positionFromApi,
|
||||||
startOfMarketDateUtcMs,
|
|
||||||
stockFromBoard,
|
stockFromBoard,
|
||||||
stockFromIssue,
|
stockFromIssue,
|
||||||
stockFromPosition,
|
stockFromPosition,
|
||||||
@@ -85,8 +86,7 @@ const cashOrderPriceConditionRequiresPrice = (condition: CashOrderPriceCondition
|
|||||||
|
|
||||||
const chartRangeOptions = {
|
const chartRangeOptions = {
|
||||||
'1D': { period: 'minute', unit: 5, count: 120 },
|
'1D': { period: 'minute', unit: 5, count: 120 },
|
||||||
'7D': { period: 'day', unit: 1, count: 7 },
|
'3D': { period: 'minute', unit: 15, count: 9999 },
|
||||||
'1M': { period: 'day', unit: 1, count: 31 },
|
|
||||||
'3M': { period: 'day', unit: 1, count: 93 },
|
'3M': { period: 'day', unit: 1, count: 93 },
|
||||||
'1Y': { period: 'day', unit: 1, count: 365 },
|
'1Y': { period: 'day', unit: 1, count: 365 },
|
||||||
ALL: { period: 'month', unit: 1, count: 9999 },
|
ALL: { period: 'month', unit: 1, count: 9999 },
|
||||||
@@ -95,36 +95,54 @@ const chartRangeOptions = {
|
|||||||
{ period: 'minute' | 'day' | 'week' | 'month'; unit: number; count: number }
|
{ period: 'minute' | 'day' | 'week' | 'month'; unit: number; count: number }
|
||||||
>
|
>
|
||||||
|
|
||||||
const marketTimeZones: Record<string, string> = {
|
|
||||||
FKO: 'Asia/Tokyo',
|
|
||||||
NGY: 'Asia/Tokyo',
|
|
||||||
PTS: 'Asia/Tokyo',
|
|
||||||
PTX: 'Asia/Tokyo',
|
|
||||||
SOR: 'Asia/Tokyo',
|
|
||||||
SPR: 'Asia/Tokyo',
|
|
||||||
STK: 'Asia/Tokyo',
|
|
||||||
TKY: 'Asia/Tokyo',
|
|
||||||
AMEX: 'America/New_York',
|
|
||||||
ARCX: 'America/New_York',
|
|
||||||
NAS: 'America/New_York',
|
|
||||||
NASDAQ: 'America/New_York',
|
|
||||||
NYS: 'America/New_York',
|
|
||||||
NYSE: 'America/New_York',
|
|
||||||
XNAS: 'America/New_York',
|
|
||||||
XNYS: 'America/New_York',
|
|
||||||
}
|
|
||||||
|
|
||||||
const countryTimeZones: Record<string, string> = {
|
|
||||||
アメリカ: 'America/New_York',
|
|
||||||
日本: 'Asia/Tokyo',
|
|
||||||
米国: 'America/New_York',
|
|
||||||
US: 'America/New_York',
|
|
||||||
USA: 'America/New_York',
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeZoneForStock = (stock: Stock) =>
|
const timeZoneForStock = (stock: Stock) =>
|
||||||
marketTimeZones[stock.market.toUpperCase()] ?? countryTimeZones[stock.country] ?? null
|
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 normalizeApkOrderTermDate = (value: string) => {
|
||||||
const digits = value.replace(/\D/g, '')
|
const digits = value.replace(/\D/g, '')
|
||||||
if (digits.length === 6) return `20${digits}`
|
if (digits.length === 6) return `20${digits}`
|
||||||
@@ -138,13 +156,13 @@ const displayApkOrderTermDate = (value: string) => {
|
|||||||
return `${normalized.slice(0, 4)}/${normalized.slice(4, 6)}/${normalized.slice(6, 8)}`
|
return `${normalized.slice(0, 4)}/${normalized.slice(4, 6)}/${normalized.slice(6, 8)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const apkExchangeMarketCodes: CashOrderMarket[] = ['SOR', 'TKY', 'NGY', 'FKO', 'SPR', 'PTS', 'PTX']
|
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
|
||||||
|
|
||||||
const parseApkExchangeMarkets = (value: string) => {
|
const parseApkExchangeMarkets = (value: string) => {
|
||||||
const markets: CashOrderMarket[] = []
|
const markets: CashOrderMarket[] = []
|
||||||
for (let index = 0; index < value.length; index += 3) {
|
for (let index = 0; index < value.length; index += 3) {
|
||||||
const code = value.slice(index, index + 3) as CashOrderMarket
|
const code = value.slice(index, index + 3) as CashOrderMarket
|
||||||
if (apkExchangeMarketCodes.includes(code) && !markets.includes(code)) markets.push(code)
|
if (searchableMarkets.includes(code) && !markets.includes(code)) markets.push(code)
|
||||||
}
|
}
|
||||||
return markets
|
return markets
|
||||||
}
|
}
|
||||||
@@ -156,11 +174,12 @@ const priceMatchesStep = (price: number, step: number) => {
|
|||||||
|
|
||||||
export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
||||||
const selectedStockCode = ref('')
|
const selectedStockCode = ref('')
|
||||||
|
const selectedStockId = ref('')
|
||||||
const viewedStockCodes = ref<string[]>([])
|
const viewedStockCodes = ref<string[]>([])
|
||||||
const tradeSide = ref<TradeSide>('buy')
|
const tradeSide = ref<TradeSide>('buy')
|
||||||
const orderKind = ref<OrderKind>('s')
|
const orderKind = ref<OrderKind>('standard')
|
||||||
const cashOrderAccountType = ref<CashOrderAccountType>('specific')
|
const cashOrderAccountType = ref<CashOrderAccountType>('specific')
|
||||||
const cashOrderMarket = ref<CashOrderMarket>('STK')
|
const cashOrderMarket = ref<CashOrderMarket>('auto')
|
||||||
const cashOrderPriceCondition = ref<CashOrderPriceCondition>('market')
|
const cashOrderPriceCondition = ref<CashOrderPriceCondition>('market')
|
||||||
const cashOrderTerm = ref<CashOrderTerm>('day')
|
const cashOrderTerm = ref<CashOrderTerm>('day')
|
||||||
const cashOrderDateInput = ref('')
|
const cashOrderDateInput = ref('')
|
||||||
@@ -188,11 +207,13 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
const sbiConnected = ref(false)
|
const sbiConnected = ref(false)
|
||||||
const dataLoading = ref(false)
|
const dataLoading = ref(false)
|
||||||
const searchLoading = ref(false)
|
const searchLoading = ref(false)
|
||||||
|
const totalAssetValueFromAssets = ref<number | null>(null)
|
||||||
const buyingPower = ref(0)
|
const buyingPower = ref(0)
|
||||||
const holdingsMarketValue = ref(0)
|
const holdingsMarketValue = ref(0)
|
||||||
const totalProfitLoss = ref(0)
|
const totalProfitLoss = ref(0)
|
||||||
const totalProfitLossRate = ref(0)
|
const totalProfitLossRate = ref(0)
|
||||||
const orders = ref<OrderRow[]>([])
|
const orders = ref<OrderRow[]>([])
|
||||||
|
const cancelingOrderKey = ref('')
|
||||||
const orderHistoryLoaded = ref(false)
|
const orderHistoryLoaded = ref(false)
|
||||||
const orderHistoryNotice = ref('')
|
const orderHistoryNotice = ref('')
|
||||||
const positions = ref<Position[]>([])
|
const positions = ref<Position[]>([])
|
||||||
@@ -211,6 +232,24 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
const errorMessage = (cause: unknown, fallback: string) =>
|
const errorMessage = (cause: unknown, fallback: string) =>
|
||||||
cause instanceof Error ? cause.message : fallback
|
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) => {
|
const reportDataError = (message: string, cause?: unknown) => {
|
||||||
if (cause) {
|
if (cause) {
|
||||||
console.error(`[csbie-ui] データ取得エラー: ${message}`, cause)
|
console.error(`[csbie-ui] データ取得エラー: ${message}`, cause)
|
||||||
@@ -219,12 +258,28 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
console.error(`[csbie-ui] データ取得エラー: ${message}`)
|
console.error(`[csbie-ui] データ取得エラー: ${message}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedStock = computed(
|
const selectedStock = computed(() => {
|
||||||
() =>
|
const selectedRef = stockRefFromId(selectedStockId.value)
|
||||||
stocks.value.find((stock) => stock.code === selectedStockCode.value) ??
|
const exact =
|
||||||
stocks.value[0] ??
|
stockById.value.get(selectedStockId.value) ??
|
||||||
emptyStock,
|
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 socketReady = computed(() => ws.value?.readyState === WebSocket.OPEN)
|
||||||
const connected = computed(() => sbiConnected.value && socketReady.value)
|
const connected = computed(() => sbiConnected.value && socketReady.value)
|
||||||
const orderQuantity = computed(() => Number(quantityInput.value || 0))
|
const orderQuantity = computed(() => Number(quantityInput.value || 0))
|
||||||
@@ -237,14 +292,21 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
)
|
)
|
||||||
const cashOrderTriggerPrice = computed(() => Number(cashOrderTriggerPriceInput.value || 0))
|
const cashOrderTriggerPrice = computed(() => Number(cashOrderTriggerPriceInput.value || 0))
|
||||||
const cashOrderSecondaryPrice = computed(() => Number(cashOrderSecondaryPriceInput.value || 0))
|
const cashOrderSecondaryPrice = computed(() => Number(cashOrderSecondaryPriceInput.value || 0))
|
||||||
|
const selectedStockIsUs = computed(() => usMarkets.has(selectedStock.value.market))
|
||||||
const resolvedCashOrderMarket = computed(() => {
|
const resolvedCashOrderMarket = computed(() => {
|
||||||
if (orderKind.value === 's') return 'STK'
|
|
||||||
return cashOrderMarket.value === 'auto' ? selectedStock.value.market : cashOrderMarket.value
|
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(() => {
|
const sKabuAvailable = computed(() => {
|
||||||
if (orderKind.value !== 's') return true
|
return true
|
||||||
const sKabu = cashPreOrder.value ? asRecord(cashPreOrder.value.sKabu) : null
|
|
||||||
return sKabu?.available !== false
|
|
||||||
})
|
})
|
||||||
const cashOrderIppanMarginPaymentLimit = computed(() => {
|
const cashOrderIppanMarginPaymentLimit = computed(() => {
|
||||||
const margin = cashPreOrder.value ? asRecord(cashPreOrder.value.margin) : {}
|
const margin = cashPreOrder.value ? asRecord(cashPreOrder.value.margin) : {}
|
||||||
@@ -270,9 +332,17 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
return options
|
return options
|
||||||
})
|
})
|
||||||
const cashOrderMarketOptions = computed<CashOrderMarketOption[]>(() => {
|
const cashOrderMarketOptions = computed<CashOrderMarketOption[]>(() => {
|
||||||
if (orderKind.value === 's') return sKabuOrderMarketOptions
|
if (selectedStockIsUs.value) {
|
||||||
|
return defaultCashOrderMarketOptions.filter(
|
||||||
|
(option) => option.value === 'auto' || option.value === selectedStock.value.market,
|
||||||
|
)
|
||||||
|
}
|
||||||
const exchangeList = textValue(cashPreOrder.value?.exchangeList)
|
const exchangeList = textValue(cashPreOrder.value?.exchangeList)
|
||||||
if (!exchangeList) return defaultCashOrderMarketOptions
|
if (!exchangeList) {
|
||||||
|
return defaultCashOrderMarketOptions.filter(
|
||||||
|
(option) => option.value === 'auto' || option.value === 'XTKS',
|
||||||
|
)
|
||||||
|
}
|
||||||
const markets = parseApkExchangeMarkets(exchangeList)
|
const markets = parseApkExchangeMarkets(exchangeList)
|
||||||
const options = markets
|
const options = markets
|
||||||
.map((market) => defaultCashOrderMarketOptions.find((option) => option.value === market))
|
.map((market) => defaultCashOrderMarketOptions.find((option) => option.value === market))
|
||||||
@@ -280,7 +350,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
return options.length ? options : defaultCashOrderMarketOptions
|
return options.length ? options : defaultCashOrderMarketOptions
|
||||||
})
|
})
|
||||||
const preferredCashOrderMarket = computed(() => {
|
const preferredCashOrderMarket = computed(() => {
|
||||||
if (orderKind.value === 's') return 'STK'
|
if (selectedStockIsUs.value) return 'auto'
|
||||||
const index = Number(textValue(cashPreOrder.value?.exchangeListIndexFlag))
|
const index = Number(textValue(cashPreOrder.value?.exchangeListIndexFlag))
|
||||||
const indexedOption =
|
const indexedOption =
|
||||||
Number.isInteger(index) && index > 0 ? cashOrderMarketOptions.value[index - 1] : undefined
|
Number.isInteger(index) && index > 0 ? cashOrderMarketOptions.value[index - 1] : undefined
|
||||||
@@ -303,7 +373,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
)
|
)
|
||||||
const cashOrderTermOptions = computed<CashOrderTermOption[]>(() => {
|
const cashOrderTermOptions = computed<CashOrderTermOption[]>(() => {
|
||||||
if (orderKind.value === 's') return [{ label: '当日中', value: 'day' }]
|
if (selectedStockIsUs.value) return [{ label: '当日中', value: 'day' }]
|
||||||
const terms = apkOrderTerms.value
|
const terms = apkOrderTerms.value
|
||||||
if (!terms.length) {
|
if (!terms.length) {
|
||||||
return [
|
return [
|
||||||
@@ -360,52 +430,36 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
const cashOrderKey = computed(() =>
|
const cashOrderKey = computed(() =>
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
issueCode: selectedStock.value.code,
|
issueCode: selectedStock.value.code,
|
||||||
market: resolvedCashOrderMarket.value,
|
market: cashOrderRequestMarket.value,
|
||||||
preOrderMarket: orderKind.value === 's' ? selectedStock.value.market : undefined,
|
|
||||||
side: tradeSide.value,
|
side: tradeSide.value,
|
||||||
quantity: orderQuantity.value,
|
quantity: orderQuantity.value,
|
||||||
kind: orderKind.value,
|
kind: cashOrderKind.value,
|
||||||
|
preOrderMarket: cashOrderPreOrderMarket.value,
|
||||||
accountType: cashOrderAccountType.value,
|
accountType: cashOrderAccountType.value,
|
||||||
depositType: cashOrderAccountType.value,
|
depositType: cashOrderAccountType.value,
|
||||||
priceCondition: orderKind.value === 's' ? undefined : cashOrderPriceCondition.value,
|
priceCondition: cashOrderPriceCondition.value,
|
||||||
price:
|
price: cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined,
|
||||||
orderKind.value !== 's' && cashOrderPrimaryRequiresPrice.value
|
orderTerm: cashOrderTerm.value,
|
||||||
? orderPrice.value
|
orderDate: cashOrderTerm.value === 'date' ? cashOrderDateInput.value : undefined,
|
||||||
: undefined,
|
orderMethod: cashOrderMethod.value,
|
||||||
orderTerm: orderKind.value === 's' ? undefined : cashOrderTerm.value,
|
triggerZone: cashOrderMethod.value !== 'normal' ? cashOrderTriggerZone.value : undefined,
|
||||||
orderDate:
|
triggerPrice: cashOrderMethod.value !== 'normal' ? cashOrderTriggerPrice.value : undefined,
|
||||||
orderKind.value !== 's' && cashOrderTerm.value === 'date'
|
|
||||||
? cashOrderDateInput.value
|
|
||||||
: undefined,
|
|
||||||
orderMethod: orderKind.value === 's' ? undefined : cashOrderMethod.value,
|
|
||||||
triggerZone:
|
|
||||||
orderKind.value !== 's' && cashOrderMethod.value !== 'normal'
|
|
||||||
? cashOrderTriggerZone.value
|
|
||||||
: undefined,
|
|
||||||
triggerPrice:
|
|
||||||
orderKind.value !== 's' && cashOrderMethod.value !== 'normal'
|
|
||||||
? cashOrderTriggerPrice.value
|
|
||||||
: undefined,
|
|
||||||
secondaryPriceCondition:
|
secondaryPriceCondition:
|
||||||
orderKind.value !== 's' && cashOrderMethod.value === 'oco'
|
cashOrderMethod.value === 'oco' ? cashOrderSecondaryPriceCondition.value : undefined,
|
||||||
? cashOrderSecondaryPriceCondition.value
|
|
||||||
: undefined,
|
|
||||||
secondaryPrice:
|
secondaryPrice:
|
||||||
orderKind.value !== 's' &&
|
cashOrderMethod.value === 'oco' && cashOrderSecondaryRequiresPrice.value
|
||||||
cashOrderMethod.value === 'oco' &&
|
|
||||||
cashOrderSecondaryRequiresPrice.value
|
|
||||||
? cashOrderSecondaryPrice.value
|
? cashOrderSecondaryPrice.value
|
||||||
: undefined,
|
: undefined,
|
||||||
ippanMarginPaymentLimit:
|
ippanMarginPaymentLimit: cashOrderIppanMarginPaymentLimit.value,
|
||||||
orderKind.value !== 's' ? cashOrderIppanMarginPaymentLimit.value : undefined,
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const canRequestCashEstimate = computed(() => {
|
const canRequestCashEstimate = computed(() => {
|
||||||
if (!connected.value || !selectedStock.value.code || orderQuantity.value <= 0) return false
|
if (!connected.value || !selectedStock.value.code || orderQuantity.value <= 0) return false
|
||||||
if (orderKind.value === 's' && !selectedStock.value.market) return false
|
if (!resolvedCashOrderMarket.value) return false
|
||||||
|
if (cashOrderKind.value === 's' && selectedStockIsUs.value) return false
|
||||||
if (!sKabuAvailable.value) return false
|
if (!sKabuAvailable.value) return false
|
||||||
if (orderKind.value === 's') return true
|
|
||||||
if (cashOrderPrimaryRequiresPrice.value && orderPrice.value <= 0) return false
|
if (cashOrderPrimaryRequiresPrice.value && orderPrice.value <= 0) return false
|
||||||
|
if (selectedStockIsUs.value && cashOrderMethod.value !== 'normal') return false
|
||||||
if (
|
if (
|
||||||
cashOrderPrimaryRequiresPrice.value &&
|
cashOrderPrimaryRequiresPrice.value &&
|
||||||
!priceMatchesStep(orderPrice.value, cashOrderPriceStep.value)
|
!priceMatchesStep(orderPrice.value, cashOrderPriceStep.value)
|
||||||
@@ -444,12 +498,16 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
)
|
)
|
||||||
const countries = computed(() => [...new Set(stocks.value.map((stock) => stock.country))])
|
const countries = computed(() => [...new Set(stocks.value.map((stock) => stock.country))])
|
||||||
const markets = computed(() => [
|
const markets = computed(() => [
|
||||||
...new Set(stocks.value.map((stock) => stock.market).filter(Boolean)),
|
...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 stockByCode = computed(() => new Map(stocks.value.map((stock) => [stock.code, stock])))
|
||||||
const viewedStocks = computed(() =>
|
const viewedStocks = computed(() =>
|
||||||
viewedStockCodes.value
|
viewedStockCodes.value
|
||||||
.map((code) => stockByCode.value.get(code))
|
.map((code) => stockById.value.get(code) ?? stockByCode.value.get(code))
|
||||||
.filter((stock): stock is Stock => Boolean(stock)),
|
.filter((stock): stock is Stock => Boolean(stock)),
|
||||||
)
|
)
|
||||||
const filteredStocks = computed(() => {
|
const filteredStocks = computed(() => {
|
||||||
@@ -459,22 +517,37 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
const matchesMarket = marketFilter.value === 'all' || stock.market === marketFilter.value
|
const matchesMarket = marketFilter.value === 'all' || stock.market === marketFilter.value
|
||||||
return matchesCountry && matchesMarket
|
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)
|
||||||
|
return matchesQuery && matchesFilters(stock)
|
||||||
|
})
|
||||||
|
: viewedStocks.value.filter(matchesFilters)
|
||||||
|
|
||||||
if (!query) return viewedStocks.value.filter(matchesFilters)
|
if (!selectedStockCode.value) return baseStocks
|
||||||
|
|
||||||
return stocks.value.filter((stock) => {
|
const exists = baseStocks.some((stock) => stockId(stock) === selectedStockId.value)
|
||||||
const matchesQuery =
|
const selected =
|
||||||
stock.name.toLowerCase().includes(query) ||
|
stockById.value.get(selectedStockId.value) ?? stockByCode.value.get(selectedStockCode.value)
|
||||||
stock.code.includes(query) ||
|
if (!exists && selected && matchesFilters(selected)) {
|
||||||
stock.symbol.toLowerCase().includes(query)
|
return [selected, ...baseStocks]
|
||||||
return matchesQuery && matchesFilters(stock)
|
}
|
||||||
})
|
return baseStocks
|
||||||
})
|
})
|
||||||
const selectedPosition = computed(() =>
|
const selectedPosition = computed(() =>
|
||||||
positions.value.find((position) => position.code === selectedStock.value.code),
|
positions.value.find(
|
||||||
|
(position) =>
|
||||||
|
position.code === selectedStock.value.code &&
|
||||||
|
position.market === selectedStock.value.market,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
const recentOrders = computed(() => orders.value.slice(0, 2))
|
const recentOrders = computed(() => orders.value.slice(0, 2))
|
||||||
const totalAssetValue = computed(() => holdingsMarketValue.value + buyingPower.value)
|
const totalAssetValue = computed(
|
||||||
|
() => totalAssetValueFromAssets.value ?? holdingsMarketValue.value + buyingPower.value,
|
||||||
|
)
|
||||||
const stockAssetRatio = computed(() => {
|
const stockAssetRatio = computed(() => {
|
||||||
if (!totalAssetValue.value) return 0
|
if (!totalAssetValue.value) return 0
|
||||||
return (holdingsMarketValue.value / totalAssetValue.value) * 100
|
return (holdingsMarketValue.value / totalAssetValue.value) * 100
|
||||||
@@ -486,15 +559,41 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
const selectedStockTimeZone = computed(() => timeZoneForStock(selectedStock.value))
|
const selectedStockTimeZone = computed(() => timeZoneForStock(selectedStock.value))
|
||||||
const chartPricePoints = computed(() => {
|
const chartPricePoints = computed(() => {
|
||||||
const points = [...historicalPricePoints.value, ...realtimePricePoints.value]
|
const points = [...historicalPricePoints.value, ...realtimePricePoints.value]
|
||||||
if (chartRange.value !== '1D') return points
|
|
||||||
|
|
||||||
const timeZone = selectedStockTimeZone.value
|
const timeZone = selectedStockTimeZone.value
|
||||||
|
|
||||||
|
if (chartRange.value !== '1D') {
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
if (!timeZone) return points
|
if (!timeZone) return points
|
||||||
|
|
||||||
const start = startOfMarketDateUtcMs(timeZone)
|
const keyForPoint = (point: RealtimePricePoint) => {
|
||||||
return points.filter((point) => {
|
|
||||||
const time = Date.parse(point.at)
|
const time = Date.parse(point.at)
|
||||||
return Number.isFinite(time) && time >= start
|
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) => {
|
const recordViewedStock = (code: string) => {
|
||||||
@@ -504,7 +603,19 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
|
|
||||||
const selectStock = (stock: Stock) => {
|
const selectStock = (stock: Stock) => {
|
||||||
selectedStockCode.value = stock.code
|
selectedStockCode.value = stock.code
|
||||||
recordViewedStock(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
|
showSearch.value = false
|
||||||
lastCashEstimate.value = null
|
lastCashEstimate.value = null
|
||||||
lastCashEstimateKey.value = ''
|
lastCashEstimateKey.value = ''
|
||||||
@@ -546,6 +657,11 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
if (stock.code && stock.code !== selectedStock.value.code) return
|
if (stock.code && stock.code !== selectedStock.value.code) return
|
||||||
|
|
||||||
mergeStocks([stock])
|
mergeStocks([stock])
|
||||||
|
const timeZone = selectedStockTimeZone.value
|
||||||
|
if (!timeZone || !isMarketSessionOpen(stock.market, timeZone)) {
|
||||||
|
stopBoardPolling()
|
||||||
|
return
|
||||||
|
}
|
||||||
appendRealtimePricePoint(stock.price)
|
appendRealtimePricePoint(stock.price)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,18 +733,20 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
},
|
},
|
||||||
reject: (reason) => {
|
reject: (reason) => {
|
||||||
window.clearTimeout(timeout)
|
window.clearTimeout(timeout)
|
||||||
reject(reason)
|
const message = reason instanceof Error ? reason.message : 'RPC request failed'
|
||||||
|
reject(new Error(`${method}: ${message}`))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const mergeStocks = (nextStocks: Stock[]) => {
|
const mergeStocks = (nextStocks: Stock[]) => {
|
||||||
const merged = new Map(stocks.value.map((stock) => [stock.code, stock]))
|
const merged = new Map(stocks.value.map((stock) => [stockId(stock), stock]))
|
||||||
for (const stock of nextStocks) {
|
for (const stock of nextStocks) {
|
||||||
if (!stock.code) continue
|
if (!stock.code) continue
|
||||||
const current = merged.get(stock.code)
|
const id = stockId(stock)
|
||||||
merged.set(stock.code, {
|
const current = merged.get(id)
|
||||||
|
merged.set(id, {
|
||||||
...current,
|
...current,
|
||||||
...stock,
|
...stock,
|
||||||
name: stock.name || current?.name || stock.code,
|
name: stock.name || current?.name || stock.code,
|
||||||
@@ -644,7 +762,8 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
const nextCode = stocks.value[0]?.code ?? ''
|
const nextCode = stocks.value[0]?.code ?? ''
|
||||||
if (nextCode) {
|
if (nextCode) {
|
||||||
selectedStockCode.value = nextCode
|
selectedStockCode.value = nextCode
|
||||||
recordViewedStock(nextCode)
|
selectedStockId.value = stockId(stocks.value[0] ?? { code: nextCode, market: '' })
|
||||||
|
recordViewedStock(selectedStockId.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -655,6 +774,8 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
const [openOrdersResult, executionsTodayResult] = await Promise.allSettled([
|
const [openOrdersResult, executionsTodayResult] = await Promise.allSettled([
|
||||||
rpcCallOptional<RecordLike>('orders.inquiry.open'),
|
rpcCallOptional<RecordLike>('orders.inquiry.open'),
|
||||||
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday'),
|
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday'),
|
||||||
|
rpcCallOptional<RecordLike>('orders.inquiry.open', { market: 'XNAS' }),
|
||||||
|
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday', { market: 'XNAS' }),
|
||||||
])
|
])
|
||||||
if (openOrdersResult.status === 'rejected' && executionsTodayResult.status === 'rejected') {
|
if (openOrdersResult.status === 'rejected' && executionsTodayResult.status === 'rejected') {
|
||||||
throw openOrdersResult.reason
|
throw openOrdersResult.reason
|
||||||
@@ -676,15 +797,40 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 () => {
|
const loadTradingData = async () => {
|
||||||
dataLoading.value = true
|
dataLoading.value = true
|
||||||
try {
|
try {
|
||||||
const cashPositions = await rpcCallOptional<RecordLike>(
|
const [assetsResult, ...positionResults] = await Promise.allSettled([
|
||||||
'account.positions.cash',
|
rpcCallOptional<RecordLike>('account.assets.current', undefined, 20_000),
|
||||||
undefined,
|
rpcCallOptional<RecordLike>('account.positions.cash', undefined, 15_000),
|
||||||
15_000,
|
rpcCallOptional<RecordLike>('account.positions.cash', { market: 'XNAS' }, 15_000),
|
||||||
)
|
])
|
||||||
const nextPositions = asArray(cashPositions.positions)
|
const cashPositionLists = fulfilledValues(positionResults)
|
||||||
|
const cashPositions = cashPositionLists[0] ?? {}
|
||||||
|
const nextPositions = cashPositionLists
|
||||||
|
.flatMap((list) => asArray(list.positions))
|
||||||
.map(positionFromApi)
|
.map(positionFromApi)
|
||||||
.filter((position): position is Position => Boolean(position))
|
.filter((position): position is Position => Boolean(position))
|
||||||
positions.value = nextPositions
|
positions.value = nextPositions
|
||||||
@@ -697,12 +843,23 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
totalProfitLoss.value = numberValue(cashPositions.totalProfitLoss)
|
totalProfitLoss.value = numberValue(cashPositions.totalProfitLoss)
|
||||||
totalProfitLossRate.value = numberValue(cashPositions.totalProfitLossRate)
|
totalProfitLossRate.value = 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([
|
const [orderHistoryResult, powerResult] = await Promise.allSettled([
|
||||||
loadOrderHistoryFromSdk(),
|
loadOrderHistoryFromSdk(),
|
||||||
rpcCallOptional<RecordLike>('account.power.buyingPower'),
|
rpcCallOptional<RecordLike>('account.power.buyingPower'),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (powerResult.status === 'fulfilled') {
|
if (!hasAccountAssets && powerResult.status === 'fulfilled') {
|
||||||
buyingPower.value = numberValue(
|
buyingPower.value = numberValue(
|
||||||
powerResult.value.cashBuyingPower ?? powerResult.value.withdrawableAmount,
|
powerResult.value.cashBuyingPower ?? powerResult.value.withdrawableAmount,
|
||||||
)
|
)
|
||||||
@@ -720,6 +877,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
'market.issue.board',
|
'market.issue.board',
|
||||||
{
|
{
|
||||||
issueCode: position.code,
|
issueCode: position.code,
|
||||||
|
market: position.market,
|
||||||
},
|
},
|
||||||
8_000,
|
8_000,
|
||||||
),
|
),
|
||||||
@@ -795,13 +953,16 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
|
|
||||||
const stock = selectedStock.value
|
const stock = selectedStock.value
|
||||||
if (!connected.value || !stock.code) return
|
if (!connected.value || !stock.code) return
|
||||||
|
const timeZone = selectedStockTimeZone.value
|
||||||
|
if (!timeZone || !isMarketSessionOpen(stock.market, timeZone)) return
|
||||||
|
|
||||||
appendRealtimePricePoint(stock.price)
|
appendRealtimePricePoint(stock.price)
|
||||||
|
|
||||||
const requestId = ++boardPollingRequestId
|
const requestId = ++boardPollingRequestId
|
||||||
try {
|
try {
|
||||||
const subscribed = await rpcCall<RecordLike>('market.issue.pollBoard.subscribe', {
|
const subscribed = await rpcCall<RecordLike>('market.issue.pollBoard.subscribe', {
|
||||||
issueCode: stock.code,
|
issueCode: stock.code,
|
||||||
market: stock.market || undefined,
|
market: stock.market,
|
||||||
})
|
})
|
||||||
if (requestId !== boardPollingRequestId) {
|
if (requestId !== boardPollingRequestId) {
|
||||||
const staleSubscriptionId = textValue(subscribed.subscriptionId)
|
const staleSubscriptionId = textValue(subscribed.subscriptionId)
|
||||||
@@ -835,7 +996,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
}
|
}
|
||||||
const chart = await rpcCall<RecordLike>('market.issue.chart', {
|
const chart = await rpcCall<RecordLike>('market.issue.chart', {
|
||||||
issueCode: stock.code,
|
issueCode: stock.code,
|
||||||
market: stock.market || undefined,
|
market: stock.market,
|
||||||
period: chartOptions.period,
|
period: chartOptions.period,
|
||||||
unit: chartOptions.unit,
|
unit: chartOptions.unit,
|
||||||
count: chartOptions.count,
|
count: chartOptions.count,
|
||||||
@@ -852,8 +1013,17 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
|
|
||||||
const searchIssues = async (query: string) => {
|
const searchIssues = async (query: string) => {
|
||||||
if (!connected.value || query.trim().length < 2) return
|
if (!connected.value || query.trim().length < 2) return
|
||||||
const result = await rpcCall<RecordLike>('market.issue.search', { query, limit: 12 })
|
const marketsToSearch =
|
||||||
const issues = asArray(result.issues)
|
marketFilter.value !== 'all'
|
||||||
|
? [marketFilter.value as CashOrderMarket]
|
||||||
|
: searchableMarkets.filter((market) => market !== 'auto')
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
marketsToSearch.map((market) =>
|
||||||
|
rpcCall<RecordLike>('market.issue.search', { query, market, limit: 12 }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
const issues = fulfilledValues(results)
|
||||||
|
.flatMap((result) => asArray(result.issues))
|
||||||
.map(issueFrom)
|
.map(issueFrom)
|
||||||
.filter((issue) => issue.code)
|
.filter((issue) => issue.code)
|
||||||
mergeStocks(issues.map(stockFromIssue))
|
mergeStocks(issues.map(stockFromIssue))
|
||||||
@@ -861,7 +1031,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
issues.map((issue) =>
|
issues.map((issue) =>
|
||||||
rpcCall<RecordLike>('market.issue.board', {
|
rpcCall<RecordLike>('market.issue.board', {
|
||||||
issueCode: issue.code,
|
issueCode: issue.code,
|
||||||
market: issue.market || undefined,
|
market: issue.market,
|
||||||
}).then((board) => stockFromBoard(board, issue)),
|
}).then((board) => stockFromBoard(board, issue)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -887,42 +1057,27 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
|
|
||||||
const cashOrderParams = () => ({
|
const cashOrderParams = () => ({
|
||||||
issueCode: selectedStock.value.code,
|
issueCode: selectedStock.value.code,
|
||||||
market: resolvedCashOrderMarket.value || undefined,
|
market: cashOrderRequestMarket.value,
|
||||||
preOrderMarket: orderKind.value === 's' ? selectedStock.value.market || undefined : undefined,
|
|
||||||
side: tradeSide.value,
|
side: tradeSide.value,
|
||||||
quantity: orderQuantity.value,
|
quantity: orderQuantity.value,
|
||||||
kind: orderKind.value === 's' ? 's' : undefined,
|
kind: cashOrderKind.value,
|
||||||
|
preOrderMarket: cashOrderPreOrderMarket.value,
|
||||||
accountType: cashOrderAccountType.value,
|
accountType: cashOrderAccountType.value,
|
||||||
depositType: cashOrderAccountType.value,
|
depositType: cashOrderAccountType.value,
|
||||||
price:
|
price: cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined,
|
||||||
orderKind.value !== 's' && cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined,
|
priceCondition: cashOrderPriceCondition.value,
|
||||||
priceCondition: orderKind.value !== 's' ? cashOrderPriceCondition.value : undefined,
|
orderTerm: cashOrderTerm.value,
|
||||||
orderTerm: orderKind.value !== 's' ? cashOrderTerm.value : undefined,
|
orderDate: cashOrderTerm.value === 'date' ? cashOrderDateInput.value : undefined,
|
||||||
orderDate:
|
orderMethod: cashOrderMethod.value,
|
||||||
orderKind.value !== 's' && cashOrderTerm.value === 'date'
|
triggerZone: cashOrderMethod.value !== 'normal' ? cashOrderTriggerZone.value : undefined,
|
||||||
? cashOrderDateInput.value
|
triggerPrice: cashOrderMethod.value !== 'normal' ? cashOrderTriggerPrice.value : undefined,
|
||||||
: undefined,
|
|
||||||
orderMethod: orderKind.value !== 's' ? cashOrderMethod.value : undefined,
|
|
||||||
triggerZone:
|
|
||||||
orderKind.value !== 's' && cashOrderMethod.value !== 'normal'
|
|
||||||
? cashOrderTriggerZone.value
|
|
||||||
: undefined,
|
|
||||||
triggerPrice:
|
|
||||||
orderKind.value !== 's' && cashOrderMethod.value !== 'normal'
|
|
||||||
? cashOrderTriggerPrice.value
|
|
||||||
: undefined,
|
|
||||||
secondaryPriceCondition:
|
secondaryPriceCondition:
|
||||||
orderKind.value !== 's' && cashOrderMethod.value === 'oco'
|
cashOrderMethod.value === 'oco' ? cashOrderSecondaryPriceCondition.value : undefined,
|
||||||
? cashOrderSecondaryPriceCondition.value
|
|
||||||
: undefined,
|
|
||||||
secondaryPrice:
|
secondaryPrice:
|
||||||
orderKind.value !== 's' &&
|
cashOrderMethod.value === 'oco' && cashOrderSecondaryRequiresPrice.value
|
||||||
cashOrderMethod.value === 'oco' &&
|
|
||||||
cashOrderSecondaryRequiresPrice.value
|
|
||||||
? cashOrderSecondaryPrice.value
|
? cashOrderSecondaryPrice.value
|
||||||
: undefined,
|
: undefined,
|
||||||
ippanMarginPaymentLimit:
|
ippanMarginPaymentLimit: cashOrderIppanMarginPaymentLimit.value,
|
||||||
orderKind.value !== 's' ? cashOrderIppanMarginPaymentLimit.value : undefined,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const refreshCashPreOrder = async () => {
|
const refreshCashPreOrder = async () => {
|
||||||
@@ -934,11 +1089,10 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
try {
|
try {
|
||||||
const preOrder = await rpcCall<RecordLike>('orders.cash.preOrder', {
|
const preOrder = await rpcCall<RecordLike>('orders.cash.preOrder', {
|
||||||
issueCode: selectedStock.value.code,
|
issueCode: selectedStock.value.code,
|
||||||
market: resolvedCashOrderMarket.value,
|
market: cashOrderRequestMarket.value,
|
||||||
preOrderMarket:
|
|
||||||
orderKind.value === 's' ? selectedStock.value.market || undefined : undefined,
|
|
||||||
side: tradeSide.value,
|
side: tradeSide.value,
|
||||||
kind: orderKind.value === 's' ? 's' : undefined,
|
kind: cashOrderKind.value,
|
||||||
|
preOrderMarket: cashOrderPreOrderMarket.value,
|
||||||
accountType: cashOrderAccountType.value,
|
accountType: cashOrderAccountType.value,
|
||||||
depositType: cashOrderAccountType.value,
|
depositType: cashOrderAccountType.value,
|
||||||
})
|
})
|
||||||
@@ -969,6 +1123,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
id: textValue(receipt.orderId, `ord-${Date.now()}`),
|
id: textValue(receipt.orderId, `ord-${Date.now()}`),
|
||||||
date: textValue(receipt.acceptedAt, new Date().toLocaleString('ja-JP')),
|
date: textValue(receipt.acceptedAt, new Date().toLocaleString('ja-JP')),
|
||||||
stock: selectedStock.value.name,
|
stock: selectedStock.value.name,
|
||||||
|
market: selectedStock.value.market,
|
||||||
side: tradeSide.value,
|
side: tradeSide.value,
|
||||||
kind: orderKind.value,
|
kind: orderKind.value,
|
||||||
quantity: orderQuantity.value,
|
quantity: orderQuantity.value,
|
||||||
@@ -982,14 +1137,30 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cancelOrder = async (order: OrderRow) => {
|
const cancelOrder = async (order: OrderRow) => {
|
||||||
await rpcCall('orders.cash.placeCancel', {
|
if (!order.orderNumber) {
|
||||||
orderNumber: order.orderNumber || order.id,
|
reportDataError('注文番号を取得できないため取消できません')
|
||||||
orderId: order.id,
|
return
|
||||||
tradeId: order.tradeId || undefined,
|
}
|
||||||
allowTrading: true,
|
const key = orderHistoryKey(order)
|
||||||
})
|
if (cancelingOrderKey.value) return
|
||||||
order.status = '取消済'
|
cancelingOrderKey.value = key
|
||||||
await loadTradingData()
|
try {
|
||||||
|
const params = {
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
orderId: order.id,
|
||||||
|
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 downloadCsv = () => {
|
const downloadCsv = () => {
|
||||||
@@ -1026,21 +1197,15 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
|
|
||||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
let searchRequestId = 0
|
let searchRequestId = 0
|
||||||
watch(orderKind, (kind) => {
|
watch(orderKind, () => {
|
||||||
if (kind === 's') {
|
cashOrderMarket.value = 'auto'
|
||||||
cashOrderMarket.value = 'STK'
|
cashOrderPriceCondition.value = 'market'
|
||||||
cashOrderPriceCondition.value = 'market'
|
cashOrderTerm.value = 'day'
|
||||||
cashOrderTerm.value = 'day'
|
cashOrderDateInput.value = ''
|
||||||
cashOrderDateInput.value = ''
|
cashOrderMethod.value = 'normal'
|
||||||
cashOrderMethod.value = 'normal'
|
cashOrderTriggerPriceInput.value = ''
|
||||||
cashOrderTriggerPriceInput.value = ''
|
cashOrderSecondaryPriceInput.value = ''
|
||||||
cashOrderSecondaryPriceInput.value = ''
|
priceInput.value = ''
|
||||||
priceInput.value = ''
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (cashOrderMarket.value === 'STK') {
|
|
||||||
cashOrderMarket.value = 'auto'
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(cashOrderPriceCondition, (condition) => {
|
watch(cashOrderPriceCondition, (condition) => {
|
||||||
@@ -1141,6 +1306,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
selectedStockCode,
|
selectedStockCode,
|
||||||
|
selectedStockId,
|
||||||
tradeSide,
|
tradeSide,
|
||||||
orderKind,
|
orderKind,
|
||||||
cashOrderAccountType,
|
cashOrderAccountType,
|
||||||
@@ -1172,6 +1338,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
totalProfitLoss,
|
totalProfitLoss,
|
||||||
totalProfitLossRate,
|
totalProfitLossRate,
|
||||||
orders,
|
orders,
|
||||||
|
cancelingOrderKey,
|
||||||
orderHistoryLoaded,
|
orderHistoryLoaded,
|
||||||
orderHistoryNotice,
|
orderHistoryNotice,
|
||||||
positions,
|
positions,
|
||||||
@@ -1205,6 +1372,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
|
|||||||
cashAssetRatio,
|
cashAssetRatio,
|
||||||
hasQuote,
|
hasQuote,
|
||||||
selectStock,
|
selectStock,
|
||||||
|
selectStockByCode,
|
||||||
connect,
|
connect,
|
||||||
loadTradingData,
|
loadTradingData,
|
||||||
estimateCashOrder,
|
estimateCashOrder,
|
||||||
|
|||||||
@@ -10,11 +10,15 @@ export const router = createRouter({
|
|||||||
routes: [
|
routes: [
|
||||||
{ path: '/', redirect: '/portfolio' },
|
{ path: '/', redirect: '/portfolio' },
|
||||||
{ path: '/portfolio', name: 'portfolio', component: RouteStub },
|
{ path: '/portfolio', name: 'portfolio', component: RouteStub },
|
||||||
{ path: '/trade', name: 'trade', component: RouteStub },
|
{ path: '/trade/:id', name: 'trade', component: RouteStub },
|
||||||
{ path: '/history', name: 'history', component: RouteStub },
|
{ path: '/history', name: 'history', component: RouteStub },
|
||||||
{ path: '/api-keys', redirect: '/settings/api-keys' },
|
{ path: '/api-keys', redirect: '/settings/api-keys' },
|
||||||
{ path: '/settings', redirect: '/settings/api-keys' },
|
{ path: '/settings', redirect: '/settings/api-keys' },
|
||||||
{ path: '/settings/:section(api-keys|passkeys)', name: 'settings', component: RouteStub },
|
{
|
||||||
|
path: '/settings/:section(api-keys|passkeys)/:mode(new)?',
|
||||||
|
name: 'settings',
|
||||||
|
component: RouteStub,
|
||||||
|
},
|
||||||
{ path: '/oauth/authorize', name: 'oauthAuthorize', component: RouteStub },
|
{ path: '/oauth/authorize', name: 'oauthAuthorize', component: RouteStub },
|
||||||
{ path: '/:pathMatch(.*)*', redirect: '/portfolio' },
|
{ path: '/:pathMatch(.*)*', redirect: '/portfolio' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -69,12 +69,15 @@ export const ui = {
|
|||||||
watchSearch:
|
watchSearch:
|
||||||
'm-3 inline-flex min-h-11 items-center gap-2 rounded-full bg-[#111418] px-4 text-left font-medium text-[#9aa0a9] outline outline-1 outline-[#33383f] transition hover:bg-[#22272e] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]',
|
'm-3 inline-flex min-h-11 items-center gap-2 rounded-full bg-[#111418] px-4 text-left font-medium text-[#9aa0a9] outline outline-1 outline-[#33383f] transition hover:bg-[#22272e] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]',
|
||||||
watchRow:
|
watchRow:
|
||||||
'grid min-h-18 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-l-2 border-transparent bg-transparent px-4 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#d3e3fd]',
|
'grid min-h-18 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-lg border-l-2 border-transparent px-4 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#d3e3fd]',
|
||||||
watchRowActive: 'border-[#c3c7cf] bg-[#242930]',
|
watchRowActive: 'bg-gray-900',
|
||||||
centerStack: 'order-1 col-span-3 grid content-start gap-5',
|
centerStack: 'order-1 col-span-3 flex min-h-0 h-full flex-col gap-5',
|
||||||
|
stockMetadataPanel: 'grid min-h-0 gap-4 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none',
|
||||||
|
stockBodyGrid: 'flex-1 min-h-0 grid grid-rows-3 gap-5',
|
||||||
tradeSeparator: 'order-3 hidden',
|
tradeSeparator: 'order-3 hidden',
|
||||||
stockPanel:
|
stockPanel:
|
||||||
'grid min-h-72 content-start gap-4 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none',
|
'grid min-h-0 h-full grid-rows-[auto_auto_1fr] gap-4 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none',
|
||||||
|
stockPanelSpan: 'row-span-2',
|
||||||
stockTitle: 'flex items-start justify-between gap-3',
|
stockTitle: 'flex items-start justify-between gap-3',
|
||||||
stockHoldingNote:
|
stockHoldingNote:
|
||||||
'inline-flex w-fit items-center rounded-full border border-[#2f343b] bg-[#111418] px-4 py-2 text-sm font-semibold text-[#c3c7cf]',
|
'inline-flex w-fit items-center rounded-full border border-[#2f343b] bg-[#111418] px-4 py-2 text-sm font-semibold text-[#c3c7cf]',
|
||||||
@@ -86,7 +89,7 @@ export const ui = {
|
|||||||
smallTabs: 'grid grid-cols-2 gap-1 rounded-full bg-[#111418] p-1',
|
smallTabs: 'grid grid-cols-2 gap-1 rounded-full bg-[#111418] p-1',
|
||||||
smallTab: 'min-h-8 rounded-full bg-transparent px-3 text-xs font-bold text-[#9aa0a9]',
|
smallTab: 'min-h-8 rounded-full bg-transparent px-3 text-xs font-bold text-[#9aa0a9]',
|
||||||
smallTabActive: 'bg-[#263141] text-[#d3e3fd]',
|
smallTabActive: 'bg-[#263141] text-[#d3e3fd]',
|
||||||
chartBox: 'mt-1 h-40 overflow-hidden rounded-[20px] bg-[#111418]',
|
chartBox: 'mt-1 min-h-0 h-full min-h-[14rem] overflow-hidden rounded-[20px] bg-[#111418]',
|
||||||
chartLine: 'fill-none stroke-[#40dba2] stroke-[3]',
|
chartLine: 'fill-none stroke-[#40dba2] stroke-[3]',
|
||||||
boxplot: 'grid min-h-36 content-center gap-5 rounded-[24px] bg-[#111418] p-7',
|
boxplot: 'grid min-h-36 content-center gap-5 rounded-[24px] bg-[#111418] p-7',
|
||||||
boxplotScale: 'flex justify-between text-xs text-[#8f949d]',
|
boxplotScale: 'flex justify-between text-xs text-[#8f949d]',
|
||||||
@@ -102,6 +105,7 @@ export const ui = {
|
|||||||
infoTabButtonActive: 'text-[#d3e3fd]',
|
infoTabButtonActive: 'text-[#d3e3fd]',
|
||||||
infoTabIndicator: 'absolute inset-x-0 bottom-[-1px] block h-0.5 rounded-full bg-[#d3e3fd]',
|
infoTabIndicator: 'absolute inset-x-0 bottom-[-1px] block h-0.5 rounded-full bg-[#d3e3fd]',
|
||||||
infoTabBody: 'overflow-hidden pt-4',
|
infoTabBody: 'overflow-hidden pt-4',
|
||||||
|
infoTabBodyInner: 'relative min-h-[9.5rem]',
|
||||||
detailGrid: 'grid grid-cols-3 gap-3',
|
detailGrid: 'grid grid-cols-3 gap-3',
|
||||||
detailItem: 'grid min-h-16 gap-1 rounded-[14px] bg-[#111418] p-3',
|
detailItem: 'grid min-h-16 gap-1 rounded-[14px] bg-[#111418] p-3',
|
||||||
holdingEmpty:
|
holdingEmpty:
|
||||||
@@ -161,7 +165,9 @@ export const ui = {
|
|||||||
searchLoading: 'flex min-h-32 flex-col items-center justify-center gap-3 py-8 text-[#9aa0a8]',
|
searchLoading: 'flex min-h-32 flex-col items-center justify-center gap-3 py-8 text-[#9aa0a8]',
|
||||||
searchResult:
|
searchResult:
|
||||||
'flex min-h-16 items-center justify-between gap-4 rounded-[20px] border border-transparent bg-[#111418] p-4 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]',
|
'flex min-h-16 items-center justify-between gap-4 rounded-[20px] border border-transparent bg-[#111418] p-4 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]',
|
||||||
searchResultActive: 'border-[#5f666f] bg-[#242930]',
|
searchResultActive: 'border-l-4 border-[#d3e3fd] bg-[#242930]',
|
||||||
|
searchResultFocus:
|
||||||
|
'bg-[#2a323a] outline outline-2 outline-[#d3e3fd] outline-offset-[-2px] shadow-[0_0_0_1px_rgba(211,227,253,0.35)]',
|
||||||
confirmList: 'grid gap-2',
|
confirmList: 'grid gap-2',
|
||||||
confirmRow: 'flex justify-between gap-3 border-b border-[#33383f] pb-2',
|
confirmRow: 'flex justify-between gap-3 border-b border-[#33383f] pb-2',
|
||||||
dialogNote: 'leading-7 text-[#c3c7cf]',
|
dialogNote: 'leading-7 text-[#c3c7cf]',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export type TradeSide = 'buy' | 'sell'
|
export type TradeSide = 'buy' | 'sell'
|
||||||
export type OrderKind = 'standard' | 's'
|
export type OrderKind = 'standard' | 's'
|
||||||
export type CashOrderAccountType = 'specific' | 'general' | 'growthInvestment' | 'nisa'
|
export type CashOrderAccountType = 'specific' | 'general' | 'growthInvestment' | 'nisa'
|
||||||
export type CashOrderMarket = 'auto' | 'TKY' | 'NGY' | 'FKO' | 'SPR' | 'SOR' | 'PTS' | 'PTX' | 'STK'
|
export type CashOrderMarket = 'auto' | 'XTKS' | 'XNAS' | 'XNYS' | 'ARCX' | 'STK'
|
||||||
export type CashOrderPriceCondition =
|
export type CashOrderPriceCondition =
|
||||||
| 'limit'
|
| 'limit'
|
||||||
| 'limitAtOpen'
|
| 'limitAtOpen'
|
||||||
@@ -16,7 +16,7 @@ export type CashOrderTerm = 'day' | 'week' | 'date'
|
|||||||
export type CashOrderMethod = 'normal' | 'stop' | 'oco'
|
export type CashOrderMethod = 'normal' | 'stop' | 'oco'
|
||||||
export type CashOrderTriggerZone = 'above' | 'below'
|
export type CashOrderTriggerZone = 'above' | 'below'
|
||||||
export type ChartMode = 'line' | 'box'
|
export type ChartMode = 'line' | 'box'
|
||||||
export type ChartRange = '1D' | '7D' | '1M' | '3M' | '1Y' | 'ALL'
|
export type ChartRange = '1D' | '3D' | '3M' | '1Y' | 'ALL'
|
||||||
|
|
||||||
export type RpcMessage = {
|
export type RpcMessage = {
|
||||||
id: number
|
id: number
|
||||||
@@ -91,6 +91,7 @@ export type OrderRow = {
|
|||||||
id: string
|
id: string
|
||||||
date: string
|
date: string
|
||||||
stock: string
|
stock: string
|
||||||
|
market: string
|
||||||
side: TradeSide
|
side: TradeSide
|
||||||
kind: OrderKind
|
kind: OrderKind
|
||||||
quantity: number | null
|
quantity: number | null
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
export const currency = (value: number) =>
|
export type CurrencyCode = 'JPY' | 'USD'
|
||||||
|
|
||||||
|
export const currencyCodeForMarket = (market?: string): CurrencyCode =>
|
||||||
|
market === 'XNAS' || market === 'XNYS' || market === 'ARCX' ? 'USD' : 'JPY'
|
||||||
|
|
||||||
|
export const currency = (value: number, currencyCode: CurrencyCode = 'JPY') =>
|
||||||
new Intl.NumberFormat('ja-JP', {
|
new Intl.NumberFormat('ja-JP', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'JPY',
|
currency: currencyCode,
|
||||||
maximumFractionDigits: 0,
|
maximumFractionDigits: currencyCode === 'JPY' ? 0 : 3,
|
||||||
}).format(value)
|
}).format(value)
|
||||||
|
|
||||||
|
export const currencyForMarket = (value: number, market?: string) =>
|
||||||
|
currency(value, currencyCodeForMarket(market))
|
||||||
|
|
||||||
export const number = (value: number) => new Intl.NumberFormat('ja-JP').format(value)
|
export const number = (value: number) => new Intl.NumberFormat('ja-JP').format(value)
|
||||||
|
|
||||||
export const signedCurrency = (value: number) =>
|
export const signedCurrency = (value: number, currencyCode: CurrencyCode = 'JPY') =>
|
||||||
`${value >= 0 ? '+' : '-'}${currency(Math.abs(value))}`
|
`${value >= 0 ? '+' : '-'}${currency(Math.abs(value), currencyCode)}`
|
||||||
|
|
||||||
|
export const signedCurrencyForMarket = (value: number, market?: string) =>
|
||||||
|
signedCurrency(value, currencyCodeForMarket(market))
|
||||||
|
|
||||||
export const signedPercent = (value: number, fractionDigits = 2) =>
|
export const signedPercent = (value: number, fractionDigits = 2) =>
|
||||||
`${value >= 0 ? '+' : ''}${value.toFixed(fractionDigits)}%`
|
`${value >= 0 ? '+' : ''}${value.toFixed(fractionDigits)}%`
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ export type {
|
|||||||
SbiClientOptions,
|
SbiClientOptions,
|
||||||
StoredWebAuthnCredential,
|
StoredWebAuthnCredential,
|
||||||
StoredWebAuthnCredentialSecret,
|
StoredWebAuthnCredentialSecret,
|
||||||
|
AccountAssetsValuationDetail,
|
||||||
|
AccountAssetsValuationSummary,
|
||||||
|
AccountAssetsValuations,
|
||||||
|
ExchangeAccountKind,
|
||||||
|
ExchangeOrderPreview,
|
||||||
|
ExchangeOrderReceipt,
|
||||||
|
ExchangeOrderSide,
|
||||||
|
ExchangeRateInfo,
|
||||||
|
ExchangeSellMethod,
|
||||||
|
ExchangeSpecificMethod,
|
||||||
WebAuthnAlgorithm,
|
WebAuthnAlgorithm,
|
||||||
WebAuthnJwk,
|
WebAuthnJwk,
|
||||||
WebAuthnTransport,
|
WebAuthnTransport,
|
||||||
@@ -23,6 +33,7 @@ export type {
|
|||||||
IssueSearchItem,
|
IssueSearchItem,
|
||||||
IssueSearchResult,
|
IssueSearchResult,
|
||||||
IssueSearchStatus,
|
IssueSearchStatus,
|
||||||
|
MarketCode,
|
||||||
OrderCorrectionPreOrder,
|
OrderCorrectionPreOrder,
|
||||||
OrderCorrectionPreOrderDetail,
|
OrderCorrectionPreOrderDetail,
|
||||||
OrderPreview,
|
OrderPreview,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { MarketCode } from './types'
|
||||||
|
|
||||||
|
export type MarketRegion = 'domestic' | 'us'
|
||||||
|
|
||||||
|
const DOMESTIC_TO_MTS = {
|
||||||
|
XTKS: 'TKY',
|
||||||
|
} as const satisfies Record<string, string>
|
||||||
|
|
||||||
|
const MTS_TO_DOMESTIC = Object.fromEntries(
|
||||||
|
Object.entries(DOMESTIC_TO_MTS).map(([mic, mts]) => [mts, mic]),
|
||||||
|
) as Record<string, MarketCode>
|
||||||
|
|
||||||
|
const US_MARKETS = new Set<MarketCode>(['XNAS', 'XNYS', 'ARCX'])
|
||||||
|
|
||||||
|
export const marketRegion = (market: MarketCode | undefined, methodName: string): MarketRegion => {
|
||||||
|
if (!market) throw new Error(`${methodName} requires market`)
|
||||||
|
if (market in DOMESTIC_TO_MTS) return 'domestic'
|
||||||
|
if (market === 'STK') return 'domestic'
|
||||||
|
if (US_MARKETS.has(market)) return 'us'
|
||||||
|
throw new Error(`${methodName} does not support market: ${market}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isUsMarket = (market: MarketCode | undefined) =>
|
||||||
|
market != null && US_MARKETS.has(market)
|
||||||
|
|
||||||
|
export const domesticMarketToMts = (market: MarketCode | undefined, methodName: string) => {
|
||||||
|
if (!market) throw new Error(`${methodName} requires market`)
|
||||||
|
const mts = DOMESTIC_TO_MTS[market as keyof typeof DOMESTIC_TO_MTS]
|
||||||
|
if (!mts) throw new Error(`${methodName} does not support domestic market: ${market}`)
|
||||||
|
return mts
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mtsMarketToDomestic = (market: string | undefined): MarketCode | undefined => {
|
||||||
|
if (!market) return undefined
|
||||||
|
return MTS_TO_DOMESTIC[market]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireDomesticMarket = (market: MarketCode | undefined, methodName: string) => {
|
||||||
|
const region = marketRegion(market, methodName)
|
||||||
|
if (region !== 'domestic') {
|
||||||
|
throw new Error(`${methodName} supports only domestic markets`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requireUsMarket = (market: MarketCode | undefined, methodName: string) => {
|
||||||
|
const region = marketRegion(market, methodName)
|
||||||
|
if (region !== 'us') {
|
||||||
|
throw new Error(`${methodName} supports only US stock markets`)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,19 @@
|
|||||||
import type {
|
import type {
|
||||||
AccountProfile,
|
AccountProfile,
|
||||||
|
AccountAssetsValuations,
|
||||||
AccountType,
|
AccountType,
|
||||||
Board,
|
Board,
|
||||||
BuyingPower,
|
BuyingPower,
|
||||||
CashPositionList,
|
CashPositionList,
|
||||||
ChartPeriod,
|
ChartPeriod,
|
||||||
DepositType,
|
DepositType,
|
||||||
|
ExchangeAccountKind,
|
||||||
|
ExchangeOrderPreview,
|
||||||
|
ExchangeOrderReceipt,
|
||||||
|
ExchangeOrderSide,
|
||||||
|
ExchangeRateInfo,
|
||||||
|
ExchangeSellMethod,
|
||||||
|
ExchangeSpecificMethod,
|
||||||
DomesticMarket,
|
DomesticMarket,
|
||||||
IssueCode,
|
IssueCode,
|
||||||
IssueChart,
|
IssueChart,
|
||||||
@@ -50,7 +58,7 @@ export type IssueOptions = {
|
|||||||
/** Issue code to request. */
|
/** Issue code to request. */
|
||||||
issueCode: IssueCode
|
issueCode: IssueCode
|
||||||
/** Market code to request. */
|
/** Market code to request. */
|
||||||
market?: MarketCode
|
market: MarketCode
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MarketIssueBoardPollingOptions = IssueOptions & {
|
export type MarketIssueBoardPollingOptions = IssueOptions & {
|
||||||
@@ -72,8 +80,8 @@ export type IssueChartOptions = IssueOptions & {
|
|||||||
export type IssueSearchOptions = {
|
export type IssueSearchOptions = {
|
||||||
/** Search text, such as an issue code, name, or keyword. */
|
/** Search text, such as an issue code, name, or keyword. */
|
||||||
query: string
|
query: string
|
||||||
/** Filters returned issues by market code on the client side. */
|
/** Market code to search. */
|
||||||
market?: MarketCode
|
market: MarketCode
|
||||||
/** Maximum number of returned issues after client-side filtering. */
|
/** Maximum number of returned issues after client-side filtering. */
|
||||||
limit?: number
|
limit?: number
|
||||||
}
|
}
|
||||||
@@ -138,6 +146,8 @@ export type StockOrderBaseOptions = {
|
|||||||
quantity: number
|
quantity: number
|
||||||
/** Deposit type used for the order. */
|
/** Deposit type used for the order. */
|
||||||
depositType?: DepositType
|
depositType?: DepositType
|
||||||
|
/** US stock settlement method. Defaults to yen settlement for foreign stock orders. */
|
||||||
|
foreignStockSettlementMethod?: 'yen' | 'foreign'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CashOrderPriceCondition =
|
export type CashOrderPriceCondition =
|
||||||
@@ -387,6 +397,37 @@ export type PlaceOrderCancelOptions = OrderCancelOptions & {
|
|||||||
allowTrading?: true
|
allowTrading?: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExchangeOrderOptions = {
|
||||||
|
/** Currency code, such as USD. */
|
||||||
|
currencyCode: string
|
||||||
|
/** Buy or sell the foreign currency. */
|
||||||
|
side: ExchangeOrderSide
|
||||||
|
/** Quantity entered on the SBI exchange order screen. */
|
||||||
|
tradeQuantity: number | string
|
||||||
|
/** `foreign` means foreign-currency quantity; `domestic` means yen amount. */
|
||||||
|
specificMethod?: ExchangeSpecificMethod
|
||||||
|
/** SBI account kind. Defaults to GENERAL. */
|
||||||
|
accountKind?: ExchangeAccountKind
|
||||||
|
/** Required for sell orders when using the exchange web flow. */
|
||||||
|
sellMethod?: ExchangeSellMethod
|
||||||
|
/** Hidden order amount posted to SBI. Defaults to tradeQuantity for foreign quantity orders. */
|
||||||
|
orderAmount?: number | string
|
||||||
|
/** Trading password used by SBI. Defaults to session tradePassword. */
|
||||||
|
tradePassword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExchangeRateOptions = {
|
||||||
|
/** Currency code, such as USD. */
|
||||||
|
currencyCode: string
|
||||||
|
/** Buy or sell the foreign currency. */
|
||||||
|
side: ExchangeOrderSide
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlaceExchangeOrderOptions = ExchangeOrderOptions & {
|
||||||
|
/** Explicitly allows sending a live exchange order. */
|
||||||
|
allowTrading?: true
|
||||||
|
}
|
||||||
|
|
||||||
export type MarginCloseOrderOptions = StandardCashOrderOptions & {
|
export type MarginCloseOrderOptions = StandardCashOrderOptions & {
|
||||||
/** Position ID to close. */
|
/** Position ID to close. */
|
||||||
positionId?: PositionId
|
positionId?: PositionId
|
||||||
@@ -504,6 +545,11 @@ export type AccountPowerOptions = {
|
|||||||
includeMarginAccount?: boolean
|
includeMarginAccount?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProfitLossOptions = {
|
||||||
|
/** Market to fetch profit/loss for. Omit for domestic cash/margin summary. */
|
||||||
|
market?: MarketCode
|
||||||
|
}
|
||||||
|
|
||||||
export interface SbiClientMethodSession {
|
export interface SbiClientMethodSession {
|
||||||
/** Returns the current authenticated session profile. */
|
/** Returns the current authenticated session profile. */
|
||||||
profile(): Promise<AccountProfile>
|
profile(): Promise<AccountProfile>
|
||||||
@@ -541,12 +587,19 @@ export interface SbiClientMethodAccountPositions {
|
|||||||
|
|
||||||
export interface SbiClientMethodAccountProfitLoss {
|
export interface SbiClientMethodAccountProfitLoss {
|
||||||
/** Fetches the unrealized profit and loss summary for cash and margin positions. */
|
/** Fetches the unrealized profit and loss summary for cash and margin positions. */
|
||||||
unrealized(): Promise<ProfitLossSummary>
|
unrealized(options?: ProfitLossOptions): Promise<ProfitLossSummary>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SbiClientMethodAccountAssets {
|
||||||
|
/** Fetches current My Assets valuations from the SBI main site. */
|
||||||
|
current(): Promise<AccountAssetsValuations>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SbiClientMethodAccount {
|
export interface SbiClientMethodAccount {
|
||||||
/** Returns the current account profile. */
|
/** Returns the current account profile. */
|
||||||
profile(): Promise<AccountProfile>
|
profile(): Promise<AccountProfile>
|
||||||
|
/** Methods for fetching My Assets values from the SBI main site. */
|
||||||
|
assets: SbiClientMethodAccountAssets
|
||||||
/** Methods for fetching buying power and collateral information. */
|
/** Methods for fetching buying power and collateral information. */
|
||||||
power: SbiClientMethodAccountPower
|
power: SbiClientMethodAccountPower
|
||||||
/** Methods for fetching cash and margin positions. */
|
/** Methods for fetching cash and margin positions. */
|
||||||
@@ -688,6 +741,15 @@ export interface SbiClientMethodThemeInvestmentOrder {
|
|||||||
place(options: PlaceThemeInvestmentOrderOptions): Promise<OrderReceipt>
|
place(options: PlaceThemeInvestmentOrderOptions): Promise<OrderReceipt>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SbiClientMethodExchangeOrder {
|
||||||
|
/** Fetches the current exchange-order input rate and limits. */
|
||||||
|
rate(options: ExchangeRateOptions): Promise<ExchangeRateInfo>
|
||||||
|
/** Estimates an exchange order without submitting a live order. */
|
||||||
|
estimate(options: ExchangeOrderOptions): Promise<ExchangeOrderPreview>
|
||||||
|
/** Places a live exchange order. Requires `allowTrading: true`. */
|
||||||
|
place(options: PlaceExchangeOrderOptions): Promise<ExchangeOrderReceipt>
|
||||||
|
}
|
||||||
|
|
||||||
export interface SbiClientMethodOrders {
|
export interface SbiClientMethodOrders {
|
||||||
/** Methods for order inquiries. */
|
/** Methods for order inquiries. */
|
||||||
inquiry: SbiClientMethodOrderInquiry
|
inquiry: SbiClientMethodOrderInquiry
|
||||||
@@ -699,6 +761,8 @@ export interface SbiClientMethodOrders {
|
|||||||
ifd: SbiClientMethodIfdOrder
|
ifd: SbiClientMethodIfdOrder
|
||||||
/** Methods for estimating and placing theme investment orders. */
|
/** Methods for estimating and placing theme investment orders. */
|
||||||
themeInvestment: SbiClientMethodThemeInvestmentOrder
|
themeInvestment: SbiClientMethodThemeInvestmentOrder
|
||||||
|
/** Methods for estimating and placing exchange orders. */
|
||||||
|
exchange: SbiClientMethodExchangeOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SbiClientMethods {
|
export interface SbiClientMethods {
|
||||||
|
|||||||
@@ -0,0 +1,810 @@
|
|||||||
|
import { randomUUID } from 'node:crypto'
|
||||||
|
import type {
|
||||||
|
Board,
|
||||||
|
CashPosition,
|
||||||
|
CashPositionList,
|
||||||
|
ChartPeriod,
|
||||||
|
ChartPrice,
|
||||||
|
CurrencyAmount,
|
||||||
|
IssueChart,
|
||||||
|
IssueSearchResult,
|
||||||
|
MarketCode,
|
||||||
|
Order,
|
||||||
|
OrderList,
|
||||||
|
OrderPreview,
|
||||||
|
OrderReceipt,
|
||||||
|
SbiSession,
|
||||||
|
SignedTextValue,
|
||||||
|
StockOrderPreOrder,
|
||||||
|
TradeSide,
|
||||||
|
} from '../types'
|
||||||
|
import type {
|
||||||
|
BoardOptions,
|
||||||
|
CashOrderOptions,
|
||||||
|
CashOrderPreOrderOptions,
|
||||||
|
IssueChartOptions,
|
||||||
|
IssueOptions,
|
||||||
|
IssueSearchOptions,
|
||||||
|
OrderInquiryOptions,
|
||||||
|
PlaceCashOrderOptions,
|
||||||
|
PlaceOrderCancelOptions,
|
||||||
|
} from './types'
|
||||||
|
import { requireUsMarket } from '../markets'
|
||||||
|
|
||||||
|
const COUNTRY_US = 'US'
|
||||||
|
const DEFAULT_CHART_COUNT = 120
|
||||||
|
const US_CHART_INTERVALS: Record<ChartPeriod, Record<number, string>> = {
|
||||||
|
minute: { 1: '1', 5: '2', 10: '3', 15: '4' },
|
||||||
|
day: { 1: '7' },
|
||||||
|
week: { 1: '8' },
|
||||||
|
month: { 1: '9' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUsStockAdapter = (session: SbiSession) => ({
|
||||||
|
search: async (options: IssueSearchOptions): Promise<IssueSearchResult> => {
|
||||||
|
requireUsMarket(options.market, 'market.issue.search')
|
||||||
|
const data = await callUsGraphql(session, 'SearchStocks', SEARCH_STOCKS, {
|
||||||
|
input: {
|
||||||
|
countryCode: COUNTRY_US,
|
||||||
|
searchKeyword: options.query,
|
||||||
|
matchType: 'CONTAINS',
|
||||||
|
marketCode: usGraphqlMarketCode(options.market),
|
||||||
|
page: { pageNum: 1, pageSize: options.limit ?? 20 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const stocks = arrayAt(data, ['listForeignStockSecurities', 'foreignStocks'])
|
||||||
|
return {
|
||||||
|
statusText: 'success',
|
||||||
|
issues: stocks.map((stock) => {
|
||||||
|
const securities = objectAt(stock, ['securities'])
|
||||||
|
const market = usMarketFromGraphql(objectAt(stock, ['market']), options.market)
|
||||||
|
return {
|
||||||
|
code: stringAt(securities, ['securitiesCode']) ?? '',
|
||||||
|
market,
|
||||||
|
name:
|
||||||
|
stringAt(securities, ['securitiesName']) ??
|
||||||
|
stringAt(securities, ['securitiesShortName']),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
board: async (options: IssueOptions): Promise<Board> => {
|
||||||
|
const detail = await fetchStockDetail(session, options, 'market.issue.board')
|
||||||
|
const quote = quoteFromDetail(detail, options)
|
||||||
|
return {
|
||||||
|
issue: quote.issue,
|
||||||
|
bids: priceLevels(detail.marketPrice, 'bid', 'bidSize'),
|
||||||
|
asks: priceLevels(detail.marketPrice, 'ask', 'askSize'),
|
||||||
|
quote,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
chart: async (options: IssueChartOptions): Promise<IssueChart> => {
|
||||||
|
requireUsMarket(options.market, 'market.issue.chart')
|
||||||
|
const normalized = normalizedUsChartOptions(options)
|
||||||
|
const data = await callUsRest(
|
||||||
|
session,
|
||||||
|
`information/chart/rics/${encodeURIComponent(usRic(options.issueCode, options.market))}/candles:listLatestCandles`,
|
||||||
|
{
|
||||||
|
count: String(normalized.count),
|
||||||
|
interval: normalized.interval,
|
||||||
|
countryCode: COUNTRY_US,
|
||||||
|
},
|
||||||
|
{ hash: 'candle' },
|
||||||
|
)
|
||||||
|
const prices = arrayAt(data, ['candles'])
|
||||||
|
.map(usChartPrice)
|
||||||
|
.filter((price): price is ChartPrice => price != null)
|
||||||
|
.reverse()
|
||||||
|
const detail = await fetchStockDetail(session, options, 'market.issue.chart')
|
||||||
|
const quote = quoteFromDetail(detail, options)
|
||||||
|
return {
|
||||||
|
issue: quote.issue,
|
||||||
|
period: normalized.period,
|
||||||
|
unit: normalized.unit,
|
||||||
|
prices,
|
||||||
|
previousClose: quote.previousClose,
|
||||||
|
currentPrice: quote.price,
|
||||||
|
highPrice: quote.high,
|
||||||
|
lowPrice: quote.low,
|
||||||
|
latestDateTime: prices.at(-1)?.dateTime,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openOrders: async (options: IssueOptions): Promise<OrderList> => {
|
||||||
|
requireUsMarket(options.market, 'market.issue.openOrders')
|
||||||
|
const list = await fetchUsOrders(session, {
|
||||||
|
issueCode: options.issueCode,
|
||||||
|
market: options.market,
|
||||||
|
})
|
||||||
|
return { orders: list.orders.filter((order) => order.issue.code === options.issueCode) }
|
||||||
|
},
|
||||||
|
tradingInfo: async (options: BoardOptions): Promise<Board> => {
|
||||||
|
const detail = await fetchStockDetail(session, options, 'market.issue.tradingInfo')
|
||||||
|
const quote = quoteFromDetail(detail, options)
|
||||||
|
return {
|
||||||
|
issue: quote.issue,
|
||||||
|
bids: priceLevels(detail.marketPrice, 'bid', 'bidSize'),
|
||||||
|
asks: priceLevels(detail.marketPrice, 'ask', 'askSize'),
|
||||||
|
quote,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
positions: async (): Promise<CashPositionList> => fetchUsCashPositions(session),
|
||||||
|
unrealized: async () => {
|
||||||
|
const positions = await fetchUsCashPositions(session)
|
||||||
|
return {
|
||||||
|
cash: positions.totalProfitLoss,
|
||||||
|
total: positions.totalProfitLoss,
|
||||||
|
totalRate: positions.totalProfitLossRate,
|
||||||
|
error: positions.error,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
orders: async (options?: OrderInquiryOptions): Promise<OrderList> =>
|
||||||
|
fetchUsOrders(session, options),
|
||||||
|
preOrder: async (options: CashOrderPreOrderOptions): Promise<StockOrderPreOrder> => {
|
||||||
|
requireUsMarket(options.market, 'orders.cash.preOrder')
|
||||||
|
const data = await callUsGraphql(session, 'GetOrderCreatingInitialData', ORDER_INITIAL_DATA, {
|
||||||
|
buySellCode: buySellCode(options.side),
|
||||||
|
countryCode: COUNTRY_US,
|
||||||
|
securitiesCode: options.issueCode,
|
||||||
|
rics: [usRic(options.issueCode, options.market)],
|
||||||
|
})
|
||||||
|
const init = objectAt(data, ['getForeignStockCreatedOrderInitialization'])
|
||||||
|
const securities =
|
||||||
|
objectAt(init, ['securities']) ?? objectAt(data, ['getForeignStockSecurities', 'securities'])
|
||||||
|
return {
|
||||||
|
issue: {
|
||||||
|
code: stringAt(securities, ['securitiesCode']) ?? options.issueCode,
|
||||||
|
market: options.market,
|
||||||
|
name:
|
||||||
|
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
|
||||||
|
},
|
||||||
|
market: options.market,
|
||||||
|
currentPrice: usd(
|
||||||
|
stringAt(arrayAt(data, ['listMarketPrices', 'marketPrices'])[0], ['price', 'last']),
|
||||||
|
),
|
||||||
|
priceSteps: arrayAt(init, ['tickSizes']).map((tick) => ({
|
||||||
|
from: usd(stringAt(tick, ['tickSize'])),
|
||||||
|
})),
|
||||||
|
orderTerms: stringArrayAt(init, ['orderTerms']),
|
||||||
|
orderTermDates: [],
|
||||||
|
paymentLimits: stringArrayAt(init, ['settlementMethodCodes']).map((code) => ({ code })),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
estimate: async (options: CashOrderOptions): Promise<OrderPreview> => {
|
||||||
|
requireUsMarket(options.market, 'orders.cash.estimate')
|
||||||
|
assertUsCashOrderOptions(options, 'orders.cash.estimate')
|
||||||
|
const orderInput = await resolveUsOrderInput(session, options, 'orders.cash.estimate')
|
||||||
|
const data = await callUsGraphql(
|
||||||
|
session,
|
||||||
|
'ConfirmOrderCreating',
|
||||||
|
CONFIRM_ORDER,
|
||||||
|
{ input: { order: orderInput } },
|
||||||
|
{ tradePassword: requireUsTradePassword(session, 'orders.cash.estimate') },
|
||||||
|
)
|
||||||
|
return orderPreviewFromConfirmation(data, options)
|
||||||
|
},
|
||||||
|
place: async (options: PlaceCashOrderOptions): Promise<OrderReceipt> => {
|
||||||
|
requireUsMarket(options.market, 'orders.cash.place')
|
||||||
|
if (options.allowTrading !== true) {
|
||||||
|
throw new Error('orders.cash.place requires allowTrading: true')
|
||||||
|
}
|
||||||
|
assertUsCashOrderOptions(options, 'orders.cash.place')
|
||||||
|
const orderInput = await resolveUsOrderInput(session, options, 'orders.cash.place')
|
||||||
|
const data = await callUsGraphql(
|
||||||
|
session,
|
||||||
|
'SubmitOrderCreating',
|
||||||
|
SUBMIT_ORDER,
|
||||||
|
{ input: { order: orderInput } },
|
||||||
|
{ tradePassword: requireUsTradePassword(session, 'orders.cash.place') },
|
||||||
|
)
|
||||||
|
const order = objectAt(data, ['createForeignStockOrder', 'order'])
|
||||||
|
return {
|
||||||
|
accepted: true,
|
||||||
|
orderId: stringAt(order, ['orderNo']) ?? stringAt(order, ['orderSubNo']),
|
||||||
|
acceptedAt: stringAt(order, ['orderInputDatetime']),
|
||||||
|
message: stringAt(data, ['createForeignStockOrder', 'message']),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
placeCancel: async (options: PlaceOrderCancelOptions): Promise<OrderReceipt> => {
|
||||||
|
if (options.allowTrading !== true) {
|
||||||
|
throw new Error('orders.cash.placeCancel requires allowTrading: true')
|
||||||
|
}
|
||||||
|
throw new Error('orders.cash.placeCancel is not implemented for US stock markets')
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchStockDetail = async (session: SbiSession, options: IssueOptions, methodName: string) => {
|
||||||
|
requireUsMarket(options.market, methodName)
|
||||||
|
const data = await callUsGraphql(session, 'GetStockDetail', STOCK_DETAIL, {
|
||||||
|
countryCode: COUNTRY_US,
|
||||||
|
securitiesCode: options.issueCode,
|
||||||
|
rics: [usRic(options.issueCode, options.market)],
|
||||||
|
})
|
||||||
|
const stock = objectAt(data, ['getForeignStockSecurities'])
|
||||||
|
const marketPrice = arrayAt(data, ['listMarketPrices', 'marketPrices'])[0]
|
||||||
|
return { stock, marketPrice }
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUsCashPositions = async (session: SbiSession): Promise<CashPositionList> => {
|
||||||
|
const data = await callUsGraphql(session, 'GetSecuritiesBalanceList', SECURITIES_BALANCES, {
|
||||||
|
input: { countryCode: COUNTRY_US, page: { pageNum: 1, pageSize: 999 } },
|
||||||
|
})
|
||||||
|
const balances = arrayAt(data, ['listSecuritiesBalances', 'securitiesBalances'])
|
||||||
|
const positions = balances.map((balance): CashPosition => {
|
||||||
|
const securities = objectAt(balance, ['securities'])
|
||||||
|
const evaluation = objectAt(balance, ['evaluationProfitLoss'])
|
||||||
|
const market = usMarketFromGraphql(objectAt(balance, ['market']))
|
||||||
|
return {
|
||||||
|
issue: {
|
||||||
|
code: stringAt(securities, ['securitiesCode']) ?? '',
|
||||||
|
market,
|
||||||
|
name:
|
||||||
|
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
|
||||||
|
},
|
||||||
|
accountType: mapUsSpecificAccount(stringAt(balance, ['specificAccountCode'])),
|
||||||
|
depositType: mapUsSpecificAccount(stringAt(balance, ['specificAccountCode'])),
|
||||||
|
quantity: numberAt(balance, ['securitiesQuantity']),
|
||||||
|
currentPrice: usd(stringAt(balance, ['stockPrice', 'last'])),
|
||||||
|
averagePrice: usd(stringAt(balance, ['frnAcquisitionPrice'])),
|
||||||
|
purchasePrice: usd(stringAt(balance, ['frnAcquisitionPrice'])),
|
||||||
|
marketValue: usd(stringAt(evaluation, ['frnEvaluationAmount'])),
|
||||||
|
valuationPrice: usd(stringAt(evaluation, ['frnEvaluationAmount'])),
|
||||||
|
profitLoss: signed(stringAt(evaluation, ['frnEvaluationProfitLoss'])),
|
||||||
|
profitLossRate: percent(stringAt(evaluation, ['frnEvaluationProfitLossPercent'])),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
positions,
|
||||||
|
totalCount: positions.length,
|
||||||
|
totalMarketValue: sumAmounts(positions.map((position) => position.marketValue)),
|
||||||
|
totalProfitLoss: sumSigned(positions.map((position) => position.profitLoss)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUsOrders = async (
|
||||||
|
session: SbiSession,
|
||||||
|
options?: OrderInquiryOptions,
|
||||||
|
): Promise<OrderList> => {
|
||||||
|
const data = await callUsGraphql(session, 'GetOrderList', ORDER_LIST, {
|
||||||
|
input: { countryCode: COUNTRY_US, page: { pageNum: 1, pageSize: options?.limit ?? 999 } },
|
||||||
|
})
|
||||||
|
const orders = arrayAt(data, ['listForeignStockOrders', 'orderDecodes']).map(orderFromGraphql)
|
||||||
|
return {
|
||||||
|
orders: orders.filter((order) => {
|
||||||
|
if (options?.issueCode && order.issue.code !== options.issueCode) return false
|
||||||
|
if (options?.market && order.issue.market !== options.market) return false
|
||||||
|
if (options?.status && order.status !== options.status) return false
|
||||||
|
return true
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const callUsGraphql = async (
|
||||||
|
session: SbiSession,
|
||||||
|
operationName: string,
|
||||||
|
query: string,
|
||||||
|
variables: Record<string, unknown>,
|
||||||
|
options: { tradePassword?: string } = {},
|
||||||
|
) => {
|
||||||
|
const us = session.foreignStock
|
||||||
|
if (!us) {
|
||||||
|
throw new Error(
|
||||||
|
'foreign stock session is not configured; pass foreignStockBaseUrl/usStockBaseUrl to loginWithPasskey',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!us.sessionId || !us.accountId || us.loginAuthenticated !== true) {
|
||||||
|
throw new Error('foreign stock session is not authenticated')
|
||||||
|
}
|
||||||
|
const response = await fetch(us.endpoints.graphqlIntUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${us.sessionId}`,
|
||||||
|
'account-id': us.accountId,
|
||||||
|
'content-type': 'application/json',
|
||||||
|
...(us.endpoints.userAgent ? { 'user-agent': us.endpoints.userAgent } : {}),
|
||||||
|
...(us.marketPriceHash ? { hash_token: us.marketPriceHash } : {}),
|
||||||
|
...(options.tradePassword
|
||||||
|
? {
|
||||||
|
request_id: randomUUID(),
|
||||||
|
trade_password: Buffer.from(options.tradePassword, 'utf8').toString('base64'),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ operationName, query, variables }),
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`foreign stock GraphQL ${operationName} failed with HTTP ${response.status}: ${text}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const json = JSON.parse(text) as { data?: unknown; errors?: unknown }
|
||||||
|
if (json.errors) {
|
||||||
|
throw new Error(
|
||||||
|
`foreign stock GraphQL ${operationName} returned errors: ${JSON.stringify(json.errors)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!json.data || typeof json.data !== 'object') {
|
||||||
|
throw new Error(`foreign stock GraphQL ${operationName} returned no data`)
|
||||||
|
}
|
||||||
|
return json.data as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
const callUsRest = async (
|
||||||
|
session: SbiSession,
|
||||||
|
path: string,
|
||||||
|
params: Record<string, string | undefined>,
|
||||||
|
options: { hash?: 'marketPrice' | 'candle' } = {},
|
||||||
|
) => {
|
||||||
|
const us = session.foreignStock
|
||||||
|
if (!us) {
|
||||||
|
throw new Error(
|
||||||
|
'foreign stock session is not configured; pass foreignStockBaseUrl/usStockBaseUrl to loginWithPasskey',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!us.sessionId || !us.accountId || us.loginAuthenticated !== true) {
|
||||||
|
throw new Error('foreign stock session is not authenticated')
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(path, us.endpoints.restUrl)
|
||||||
|
for (const [key, value] of Object.entries(params)) {
|
||||||
|
if (value != null) url.searchParams.set(key, value)
|
||||||
|
}
|
||||||
|
const hashToken = options.hash === 'candle' ? us.candleHash : us.marketPriceHash
|
||||||
|
if (options.hash && !hashToken) {
|
||||||
|
throw new Error(`foreign stock ${options.hash} hash token is not available`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${us.sessionId}`,
|
||||||
|
'account-id': us.accountId,
|
||||||
|
...(us.endpoints.userAgent ? { 'user-agent': us.endpoints.userAgent } : {}),
|
||||||
|
...(hashToken ? { hash_token: hashToken } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`foreign stock REST ${path} failed with HTTP ${response.status}: ${text}`)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const json = text ? JSON.parse(text) : undefined
|
||||||
|
if (!json || typeof json !== 'object') {
|
||||||
|
throw new Error(`foreign stock REST ${path} returned no data`)
|
||||||
|
}
|
||||||
|
return json as Record<string, unknown>
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) {
|
||||||
|
throw new Error(`foreign stock REST ${path} returned non-JSON response`)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteFromDetail = (
|
||||||
|
detail: { stock: unknown; marketPrice: unknown },
|
||||||
|
options: IssueOptions,
|
||||||
|
) => {
|
||||||
|
const securities = objectAt(detail.stock, ['securities'])
|
||||||
|
const price = objectAt(detail.marketPrice, ['price'])
|
||||||
|
return {
|
||||||
|
issue: {
|
||||||
|
code: stringAt(securities, ['securitiesCode']) ?? options.issueCode,
|
||||||
|
market: options.market,
|
||||||
|
name:
|
||||||
|
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
|
||||||
|
},
|
||||||
|
price: usd(stringAt(price, ['last'])),
|
||||||
|
change: signed(stringAt(price, ['change'])),
|
||||||
|
changeRate: percent(stringAt(price, ['changePercent'])),
|
||||||
|
open: usd(stringAt(price, ['open'])),
|
||||||
|
high: usd(stringAt(price, ['high'])),
|
||||||
|
low: usd(stringAt(price, ['low'])),
|
||||||
|
previousClose: usd(stringAt(price, ['prevClose'])),
|
||||||
|
volume: numberAt(price, ['volume']),
|
||||||
|
timestamp: stringAt(price, ['lastDatetime']),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usChartPrice = (value: unknown): ChartPrice | undefined => {
|
||||||
|
const candle = value && typeof value === 'object' ? (value as Record<string, unknown>) : undefined
|
||||||
|
if (!candle) return undefined
|
||||||
|
const dateTime = stringAt(candle, ['startDatetime'])
|
||||||
|
const close = usd(stringAt(candle, ['close']))
|
||||||
|
if (!dateTime || close.value == null) return undefined
|
||||||
|
return {
|
||||||
|
dateTime,
|
||||||
|
open: usd(stringAt(candle, ['open'])),
|
||||||
|
high: usd(stringAt(candle, ['high'])),
|
||||||
|
low: usd(stringAt(candle, ['low'])),
|
||||||
|
close,
|
||||||
|
volume: numberAt(candle, ['volume']),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const priceLevels = (source: unknown, priceKey: string, quantityKey: string) => {
|
||||||
|
const price = stringAt(source, [priceKey])
|
||||||
|
if (price == null) return []
|
||||||
|
return [{ price: usd(price), quantity: numberAt(source, [quantityKey]) }]
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderPreviewFromConfirmation = (
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
options: CashOrderOptions,
|
||||||
|
): OrderPreview => {
|
||||||
|
const confirmation = objectAt(data, ['confirmForeignStockCreatedOrder'])
|
||||||
|
const order = objectAt(confirmation, ['order'])
|
||||||
|
return {
|
||||||
|
issue: { code: options.issueCode, market: options.market },
|
||||||
|
side: options.side,
|
||||||
|
quantity: options.quantity,
|
||||||
|
price: usd(String(options.price ?? '')),
|
||||||
|
estimatedAmount: usd(
|
||||||
|
stringAt(confirmation, ['estimatePrice']) ?? stringAt(order, ['frnNetAmount']),
|
||||||
|
),
|
||||||
|
commission: usd(stringAt(order, ['frnCommissionAmount'])),
|
||||||
|
tax: usd(stringAt(order, ['frnCommissionCtax'])),
|
||||||
|
warnings: stringArrayAt(confirmation, ['warningStatuses']),
|
||||||
|
confirmationId: stringAt(order, ['orderNo']) ?? stringAt(order, ['orderSubNo']),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderFromGraphql = (value: unknown): Order => {
|
||||||
|
const market = usMarketFromGraphql(objectAt(value, ['market']))
|
||||||
|
const securities = objectAt(value, ['securities'])
|
||||||
|
return {
|
||||||
|
id: stringAt(value, ['orderNo']) ?? stringAt(value, ['orderSubNo']) ?? '',
|
||||||
|
issue: {
|
||||||
|
code: stringAt(securities, ['securitiesCode']) ?? '',
|
||||||
|
market,
|
||||||
|
name:
|
||||||
|
stringAt(securities, ['securitiesName']) ?? stringAt(securities, ['securitiesShortName']),
|
||||||
|
},
|
||||||
|
side: stringAt(value, ['buySellCode']) === 'SELL' ? 'sell' : 'buy',
|
||||||
|
status: mapOrderStatus(stringAt(value, ['orderStatus'])),
|
||||||
|
statusText: stringAt(value, ['orderStatus']),
|
||||||
|
quantity: numberAt(value, ['orderQuantity']),
|
||||||
|
unexecutedQuantity: numberAt(value, ['unexecutedQuantity']),
|
||||||
|
executedQuantity: numberAt(value, ['executionQuantity']),
|
||||||
|
price: usd(stringAt(value, ['orderPrice'])),
|
||||||
|
executedPrice: usd(stringAt(value, ['executionAveragePrice'])),
|
||||||
|
orderedAt: stringAt(value, ['orderInputDatetime']),
|
||||||
|
orderNumber: stringAt(value, ['orderNo']),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUsChartOptions = (options: IssueChartOptions) => {
|
||||||
|
const period = options.period ?? 'day'
|
||||||
|
const unit = options.unit ?? 1
|
||||||
|
const count = options.count ?? DEFAULT_CHART_COUNT
|
||||||
|
if (!(period in US_CHART_INTERVALS)) {
|
||||||
|
throw new Error('period must be minute, day, week, or month')
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(unit) || unit <= 0) {
|
||||||
|
throw new Error('unit must be a positive integer')
|
||||||
|
}
|
||||||
|
const interval = US_CHART_INTERVALS[period][unit]
|
||||||
|
if (!interval) {
|
||||||
|
if (period === 'minute') throw new Error('minute chart unit must be 1, 5, 10, or 15')
|
||||||
|
throw new Error('day, week, and month chart unit must be 1')
|
||||||
|
}
|
||||||
|
if (!Number.isInteger(count) || count <= 0 || count > 9999) {
|
||||||
|
throw new Error('count must be an integer between 1 and 9999')
|
||||||
|
}
|
||||||
|
return { period, unit, count, interval }
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveUsOrderInput = async (
|
||||||
|
session: SbiSession,
|
||||||
|
options: CashOrderOptions | PlaceCashOrderOptions,
|
||||||
|
methodName: string,
|
||||||
|
) => {
|
||||||
|
const orderDate = usOrderDate(options)
|
||||||
|
if (orderDate) return usOrderInput(options, orderDate)
|
||||||
|
if ('orderTerm' in options && options.orderTerm === 'day') return usOrderInput(options)
|
||||||
|
|
||||||
|
const preOrder = await createUsStockAdapter(session).preOrder({
|
||||||
|
issueCode: options.issueCode,
|
||||||
|
market: options.market,
|
||||||
|
side: options.side,
|
||||||
|
})
|
||||||
|
const firstOrderDate = preOrder.orderTerms.find((term) => /^\d{4}-\d{2}-\d{2}$/.test(term))
|
||||||
|
if (!firstOrderDate) {
|
||||||
|
throw new Error(`${methodName} could not determine a valid US stock order term`)
|
||||||
|
}
|
||||||
|
return usOrderInput(options, firstOrderDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const usOrderInput = (options: CashOrderOptions | PlaceCashOrderOptions, orderDate?: string) => ({
|
||||||
|
...('allowTrading' in options ? { orderSubNo: options.confirmationId ?? '' } : {}),
|
||||||
|
countryCode: COUNTRY_US,
|
||||||
|
marketCode: usGraphqlMarketCode(options.market),
|
||||||
|
securitiesCode: options.issueCode,
|
||||||
|
buySellCode: buySellCode(options.side),
|
||||||
|
orderQuantity: String(options.quantity),
|
||||||
|
orderPrice: usOrderIsMarket(options) ? undefined : String(options.price),
|
||||||
|
orderPriceKindCode: usOrderIsMarket(options) ? 'MARKET' : 'LIMIT',
|
||||||
|
orderLimitCode: orderDate ? 'CARRY_OVER_ORDER' : 'TODAY_ORDER',
|
||||||
|
orderTerm: orderDate,
|
||||||
|
specificAccountCode: usSpecificAccountCode(options.accountType),
|
||||||
|
settlementMethodCode: usSettlementMethodCode(options.foreignStockSettlementMethod),
|
||||||
|
})
|
||||||
|
|
||||||
|
const usOrderDate = (options: CashOrderOptions | PlaceCashOrderOptions) => {
|
||||||
|
if ('orderTerm' in options && options.orderTerm === 'date') {
|
||||||
|
if (!('orderDate' in options) || !options.orderDate) {
|
||||||
|
throw new Error('US stock orderTerm: "date" requires orderDate')
|
||||||
|
}
|
||||||
|
return normalizeUsOrderDate(options.orderDate)
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeUsOrderDate = (value: string) => {
|
||||||
|
const normalized = value.replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3')
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||||
|
throw new Error('US stock orderDate must be yyyy-MM-dd or yyyyMMdd')
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
const usGraphqlMarketCode = (market: MarketCode) => {
|
||||||
|
switch (market) {
|
||||||
|
case 'XNAS':
|
||||||
|
return 'US_NASDAQ'
|
||||||
|
case 'XNYS':
|
||||||
|
return 'US_NYSE'
|
||||||
|
case 'ARCX':
|
||||||
|
return 'US_ARCA'
|
||||||
|
default:
|
||||||
|
throw new Error(`unsupported US stock market: ${market}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usRic = (issueCode: string, market: MarketCode) => {
|
||||||
|
switch (market) {
|
||||||
|
case 'XNAS':
|
||||||
|
return `${issueCode}.NB`
|
||||||
|
case 'XNYS':
|
||||||
|
return `${issueCode}.N`
|
||||||
|
case 'ARCX':
|
||||||
|
return `${issueCode}.P`
|
||||||
|
default:
|
||||||
|
throw new Error(`unsupported US stock market: ${market}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertUsCashOrderOptions = (options: CashOrderOptions, methodName: string) => {
|
||||||
|
if (options.kind === 's')
|
||||||
|
throw new Error(`${methodName} does not support S-kabu orders for US stocks`)
|
||||||
|
if (options.orderMethod && options.orderMethod !== 'normal') {
|
||||||
|
throw new Error(`${methodName} does not support stop/OCO orders for US stocks`)
|
||||||
|
}
|
||||||
|
if (!usOrderIsMarket(options) && options.price == null) {
|
||||||
|
throw new Error(`${methodName} requires price for non-market US stock orders`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requireUsTradePassword = (session: SbiSession, methodName: string) => {
|
||||||
|
if (!session.tradePassword) {
|
||||||
|
throw new Error(`${methodName} requires tradePassword in loginWithPasskey options`)
|
||||||
|
}
|
||||||
|
return session.tradePassword
|
||||||
|
}
|
||||||
|
|
||||||
|
const usOrderIsMarket = (options: CashOrderOptions | PlaceCashOrderOptions) =>
|
||||||
|
options.kind === 'market' || ('priceCondition' in options && options.priceCondition === 'market')
|
||||||
|
|
||||||
|
const buySellCode = (side: TradeSide) => (side === 'sell' ? 'SELL' : 'BUY')
|
||||||
|
|
||||||
|
const mapOrderStatus = (value: string | undefined): Order['status'] => {
|
||||||
|
if (!value) return 'unknown'
|
||||||
|
if (/EXECUT|約定/i.test(value)) return 'executed'
|
||||||
|
if (/CANCEL|取消/i.test(value)) return 'cancelled'
|
||||||
|
if (/REJECT|失効/i.test(value)) return 'rejected'
|
||||||
|
return 'open'
|
||||||
|
}
|
||||||
|
|
||||||
|
const usMarketFromGraphql = (market: unknown, fallback: MarketCode = 'XNAS'): MarketCode => {
|
||||||
|
const code = stringAt(market, ['marketCode'])
|
||||||
|
if (code === 'NASDAQ' || code === 'XNAS') return 'XNAS'
|
||||||
|
if (code === 'NYSE' || code === 'XNYS') return 'XNYS'
|
||||||
|
if (code === 'NYSE_ARCA' || code === 'ARCX') return 'ARCX'
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapUsSpecificAccount = (value: string | undefined) => {
|
||||||
|
if (value === 'SPECIFIC' || value === 'TOKUTEI') return 'specific'
|
||||||
|
if (value === 'GENERAL' || value === 'IPPAN') return 'general'
|
||||||
|
if (value?.includes('NISA')) return 'nisa'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const usSpecificAccountCode = (value: CashOrderOptions['accountType']) => {
|
||||||
|
if (value === 'specific') return 'SPECIFIC'
|
||||||
|
if (value === 'general') return 'GENERAL'
|
||||||
|
if (value === 'nisa' || value === 'growthInvestment') return 'NISA'
|
||||||
|
if (!value) return undefined
|
||||||
|
throw new Error(`US stock orders do not support accountType: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const usSettlementMethodCode = (value: CashOrderOptions['foreignStockSettlementMethod']) => {
|
||||||
|
if (value === 'foreign') return 'FOREIGN_SETTLEMENT'
|
||||||
|
if (value === 'yen' || !value) return 'YEN_SETTLEMENT'
|
||||||
|
throw new Error(`US stock orders do not support foreignStockSettlementMethod: ${value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const usd = (text: string | undefined): CurrencyAmount => ({
|
||||||
|
value: parseNumber(text),
|
||||||
|
text: text?.trim() ?? '',
|
||||||
|
currency: 'USD',
|
||||||
|
})
|
||||||
|
|
||||||
|
const percent = (text: string | undefined) => ({
|
||||||
|
value: parseNumber(text),
|
||||||
|
text: text?.trim() ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const signed = (text: string | undefined): SignedTextValue => {
|
||||||
|
const value = parseNumber(text)
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
text: text?.trim() ?? '',
|
||||||
|
sign: value == null ? undefined : value > 0 ? 'positive' : value < 0 ? 'negative' : 'zero',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumAmounts = (amounts: Array<CurrencyAmount | undefined>): CurrencyAmount | undefined => {
|
||||||
|
const values = amounts.map((amount) => amount?.value).filter((value) => value != null)
|
||||||
|
if (values.length === 0) return undefined
|
||||||
|
const value = values.reduce((sum, current) => sum + current, 0)
|
||||||
|
return { value, text: String(value), currency: 'USD' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumSigned = (values: Array<SignedTextValue | undefined>): SignedTextValue | undefined => {
|
||||||
|
const numbers = values.map((value) => value?.value).filter((value) => value != null)
|
||||||
|
if (numbers.length === 0) return undefined
|
||||||
|
const value = numbers.reduce((sum, current) => sum + current, 0)
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
text: String(value),
|
||||||
|
sign: value > 0 ? 'positive' : value < 0 ? 'negative' : 'zero',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseNumber = (text: string | undefined | null) => {
|
||||||
|
if (text == null) return null
|
||||||
|
const normalized = text.replace(/[,\s$%]/g, '').replace(/[()]/g, '')
|
||||||
|
if (!normalized || normalized === '-' || normalized === '--') return null
|
||||||
|
const value = Number(normalized)
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectAt = (source: unknown, path: string[]): Record<string, unknown> | undefined => {
|
||||||
|
const value = valueAt(source, path)
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? (value as Record<string, unknown>)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayAt = (source: unknown, path: string[]): unknown[] => {
|
||||||
|
const value = valueAt(source, path)
|
||||||
|
return Array.isArray(value) ? value : []
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringAt = (source: unknown, path: string[]): string | undefined => {
|
||||||
|
const value = valueAt(source, path)
|
||||||
|
if (typeof value === 'string') return value
|
||||||
|
if (typeof value === 'number') return String(value)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringArrayAt = (source: unknown, path: string[]) =>
|
||||||
|
arrayAt(source, path).flatMap((value) => (typeof value === 'string' ? [value] : []))
|
||||||
|
|
||||||
|
const numberAt = (source: unknown, path: string[]) => parseNumber(stringAt(source, path))
|
||||||
|
|
||||||
|
const valueAt = (source: unknown, path: string[]): unknown =>
|
||||||
|
path.reduce<unknown>((current, key) => {
|
||||||
|
if (!current || typeof current !== 'object') return undefined
|
||||||
|
return (current as Record<string, unknown>)[key]
|
||||||
|
}, source)
|
||||||
|
|
||||||
|
const SEARCH_STOCKS = `
|
||||||
|
query SearchStocks($input: Input_fstock_securities_ListForeignStockSecuritiesRequest) {
|
||||||
|
listForeignStockSecurities(input: $input) {
|
||||||
|
foreignStocks {
|
||||||
|
market { marketCode marketName marketShortName timeZone }
|
||||||
|
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
|
||||||
|
securitiesType tradeUnit openBuyRestrict openSellRestrict
|
||||||
|
}
|
||||||
|
page { hasNextPage }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const STOCK_DETAIL = `
|
||||||
|
query GetStockDetail($countryCode: common_enums_CountryEnum_Country, $securitiesCode: String, $rics: [String]) {
|
||||||
|
getForeignStockSecurities(input: { countryCode: $countryCode securitiesCode: $securitiesCode }) {
|
||||||
|
market { marketCode marketName marketShortName timeZone }
|
||||||
|
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
|
||||||
|
securitiesType tradeUnit openBuyRestrict openSellRestrict
|
||||||
|
}
|
||||||
|
listMarketPrices(input: { countryCode: $countryCode rics: $rics }) {
|
||||||
|
marketPrices {
|
||||||
|
ric ask askSize bid bidSize
|
||||||
|
price { last lastDatetime change changePercent open high low prevClose volume }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const SECURITIES_BALANCES = `
|
||||||
|
query GetSecuritiesBalanceList($input: Input_account_balance_ListSecuritiesBalancesRequest) {
|
||||||
|
listSecuritiesBalances(input: $input) {
|
||||||
|
securitiesBalances {
|
||||||
|
specificAccountCode securitiesQuantity frnAcquisitionPrice acquisitionPrice currencyCode countryCode
|
||||||
|
securities { countryCode securitiesCode securitiesName securitiesShortName ric }
|
||||||
|
market { marketCode marketName marketShortName timeZone }
|
||||||
|
evaluationProfitLoss {
|
||||||
|
frnEvaluationAmount frnEvaluationProfitLoss evaluationAmount evaluationProfitLoss evaluationProfitLossPercent frnEvaluationProfitLossPercent
|
||||||
|
}
|
||||||
|
stockPrice { last tickArrow }
|
||||||
|
}
|
||||||
|
page { hasNextPage pageNum pageSize }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const ORDER_INITIAL_DATA = `
|
||||||
|
query GetOrderCreatingInitialData($buySellCode: common_enums_BuySellEnum_BuySell, $countryCode: common_enums_CountryEnum_Country, $securitiesCode: String, $rics: [String]) {
|
||||||
|
getForeignStockCreatedOrderInitialization(input: { buySellCode: $buySellCode countryCode: $countryCode securitiesCode: $securitiesCode }) {
|
||||||
|
securities { countryCode productCode ric securitiesCode securitiesName securitiesShortName }
|
||||||
|
market { countryCode marketCode marketName marketShortName timeZone }
|
||||||
|
priceRangeNoLimit priceRangeLimitMin priceRangeLimitMax tickSizes { tickSize }
|
||||||
|
specificAccountCodes settlementMethodCodes orderPriceKindCodes orderLimitCodes orderTerms
|
||||||
|
}
|
||||||
|
getForeignStockSecurities(input: { countryCode: $countryCode securitiesCode: $securitiesCode }) {
|
||||||
|
currencyCode listedSecuritiesStatus tradeUnit
|
||||||
|
market { marketCode marketName marketShortName timeZone }
|
||||||
|
securities { countryCode productCode ric securitiesCode securitiesName securitiesShortName }
|
||||||
|
}
|
||||||
|
listMarketPrices(input: { countryCode: $countryCode rics: $rics }) {
|
||||||
|
marketPrices { ask askSize bid bidSize price { last lastDatetime change changePercent } }
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const CONFIRM_ORDER = `
|
||||||
|
query ConfirmOrderCreating($input: Input_fstock_order_ConfirmForeignStockCreatedOrderRequest) {
|
||||||
|
confirmForeignStockCreatedOrder(input: $input) {
|
||||||
|
buyPossibleAmount estimatePrice nisaBuyLimitAmount sellPossibleQuantity warningStatuses
|
||||||
|
order {
|
||||||
|
orderNo orderSubNo orderInputDatetime orderQuantity orderPrice frnNetAmount frnCommissionAmount frnCommissionCtax
|
||||||
|
securities { securitiesCode securitiesName securitiesShortName }
|
||||||
|
market { marketCode marketName marketShortName }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const SUBMIT_ORDER = `
|
||||||
|
mutation SubmitOrderCreating($input: Input_fstock_order_CreateForeignStockOrderRequest) {
|
||||||
|
createForeignStockOrder(input: $input) {
|
||||||
|
order {
|
||||||
|
orderNo orderSubNo orderInputDatetime orderQuantity orderPrice
|
||||||
|
securities { securitiesCode securitiesName securitiesShortName }
|
||||||
|
market { marketCode marketName marketShortName }
|
||||||
|
}
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
const ORDER_LIST = `
|
||||||
|
query GetOrderList($input: Input_fstock_order_ListForeignStockOrdersRequest) {
|
||||||
|
listForeignStockOrders(input: $input) {
|
||||||
|
orderDecodes {
|
||||||
|
orderNo orderSubNo buySellCode orderStatus orderQuantity unexecutedQuantity executionQuantity
|
||||||
|
orderPrice executionAveragePrice orderInputDatetime
|
||||||
|
securities { securitiesCode securitiesName securitiesShortName }
|
||||||
|
market { marketCode marketName marketShortName }
|
||||||
|
}
|
||||||
|
page { hasNextPage pageNum pageSize }
|
||||||
|
}
|
||||||
|
}`
|
||||||
@@ -11,8 +11,11 @@ import { tmpdir } from 'node:os'
|
|||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { spawnSync } from 'node:child_process'
|
import { spawnSync } from 'node:child_process'
|
||||||
import { createMethodsFromSession, registerDeviceId } from '../methods'
|
import { createMethodsFromSession, registerDeviceId } from '../methods'
|
||||||
|
import { mtsMarketToDomestic } from '../markets'
|
||||||
import type {
|
import type {
|
||||||
AccountProfile,
|
AccountProfile,
|
||||||
|
ForeignStockEndpointConfig,
|
||||||
|
ForeignStockSession,
|
||||||
LoginWithPasskeyOptions,
|
LoginWithPasskeyOptions,
|
||||||
PasskeyLoginResponse,
|
PasskeyLoginResponse,
|
||||||
PlaintextStoredWebAuthnCredential,
|
PlaintextStoredWebAuthnCredential,
|
||||||
@@ -27,6 +30,11 @@ type PasskeyLoginStart = {
|
|||||||
publicKey: string
|
publicKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PasskeyAccessToken = {
|
||||||
|
callbackUrl: string
|
||||||
|
accessToken: string
|
||||||
|
}
|
||||||
|
|
||||||
type CredentialRequest = {
|
type CredentialRequest = {
|
||||||
challenge: string
|
challenge: string
|
||||||
rpId: string
|
rpId: string
|
||||||
@@ -37,8 +45,25 @@ type SbiEndpointConfig = {
|
|||||||
authBaseUrl: string
|
authBaseUrl: string
|
||||||
mtsBaseUrl: string
|
mtsBaseUrl: string
|
||||||
izanagiBaseUrl?: string
|
izanagiBaseUrl?: string
|
||||||
|
foreignStock?: ForeignStockEndpointConfig
|
||||||
|
mainSiteBaseUrl?: string
|
||||||
|
mainSiteEtGatePath?: string
|
||||||
|
mainSiteAssetsValuationsPath?: string
|
||||||
|
mainSiteExchangeOrderInputPath?: string
|
||||||
|
mainSiteExchangeOrderPasswordPath?: string
|
||||||
|
mainSiteExchangeOrderConfirmPath?: string
|
||||||
|
mainSiteExchangeOrderCompletePath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAIN_SITE_DEFAULT_PATHS = {
|
||||||
|
etGate: '/ETGate/',
|
||||||
|
assetsValuations: '/account/api/assets/valuations/current',
|
||||||
|
exchangeOrderInput: '/exchange/order/input',
|
||||||
|
exchangeOrderPassword: '/exchange/api/order/input/password',
|
||||||
|
exchangeOrderConfirm: '/exchange/order/confirm',
|
||||||
|
exchangeOrderComplete: '/exchange/order/complete',
|
||||||
|
} as const
|
||||||
|
|
||||||
export const loginWithPasskey = async (
|
export const loginWithPasskey = async (
|
||||||
options: LoginWithPasskeyOptions,
|
options: LoginWithPasskeyOptions,
|
||||||
clientOptions: SbiClientOptions = {},
|
clientOptions: SbiClientOptions = {},
|
||||||
@@ -52,7 +77,257 @@ export const createPasskeySession = async (
|
|||||||
clientOptions: SbiClientOptions = {},
|
clientOptions: SbiClientOptions = {},
|
||||||
): Promise<SbiSession> => {
|
): Promise<SbiSession> => {
|
||||||
const endpoints = resolveSbiEndpointConfig(options)
|
const endpoints = resolveSbiEndpointConfig(options)
|
||||||
const started = startPasskeyLogin(endpoints.authBaseUrl)
|
const domesticAccess = await requestPasskeyAccessToken({
|
||||||
|
authBaseUrl: endpoints.authBaseUrl,
|
||||||
|
passkeyCredential: options.passkeyCredential,
|
||||||
|
channel: 'kabu-app',
|
||||||
|
})
|
||||||
|
const loginResponse = await finishPasskeyLogin({
|
||||||
|
accessToken: domesticAccess.accessToken,
|
||||||
|
mtsBaseUrl: endpoints.mtsBaseUrl,
|
||||||
|
})
|
||||||
|
const profile = parsePasskeyLoginProfile(loginResponse)
|
||||||
|
const foreignStock = endpoints.foreignStock
|
||||||
|
? await createForeignStockSession(
|
||||||
|
endpoints.foreignStock,
|
||||||
|
(
|
||||||
|
await requestPasskeyAccessToken({
|
||||||
|
authBaseUrl: endpoints.authBaseUrl,
|
||||||
|
passkeyCredential: options.passkeyCredential,
|
||||||
|
channel: 'foreign-kabu-app',
|
||||||
|
})
|
||||||
|
).accessToken,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const session: SbiSession = {
|
||||||
|
mtsBaseUrl: endpoints.mtsBaseUrl,
|
||||||
|
izanagiBaseUrl: endpoints.izanagiBaseUrl,
|
||||||
|
foreignStock,
|
||||||
|
mainSite: endpoints.mainSiteBaseUrl
|
||||||
|
? {
|
||||||
|
baseUrl: endpoints.mainSiteBaseUrl,
|
||||||
|
etGatePath: endpoints.mainSiteEtGatePath,
|
||||||
|
assetsValuationsPath: endpoints.mainSiteAssetsValuationsPath,
|
||||||
|
exchangeOrderInputPath: endpoints.mainSiteExchangeOrderInputPath,
|
||||||
|
exchangeOrderPasswordPath: endpoints.mainSiteExchangeOrderPasswordPath,
|
||||||
|
exchangeOrderConfirmPath: endpoints.mainSiteExchangeOrderConfirmPath,
|
||||||
|
exchangeOrderCompletePath: endpoints.mainSiteExchangeOrderCompletePath,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
profile,
|
||||||
|
loginResponse,
|
||||||
|
tradePassword: clientOptions.tradePassword,
|
||||||
|
tradeAuthentication: clientOptions.tradeAuthentication,
|
||||||
|
}
|
||||||
|
if (clientOptions.deviceId) {
|
||||||
|
await registerDeviceId(session, clientOptions.deviceId)
|
||||||
|
session.deviceIdRegistered = true
|
||||||
|
}
|
||||||
|
return session
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSbiEndpointConfig = (options: LoginWithPasskeyOptions): SbiEndpointConfig => {
|
||||||
|
const authBaseUrl = options.authBaseUrl ?? process.env.SBI_AUTH_BASE_URL
|
||||||
|
const mtsBaseUrl = options.mtsBaseUrl ?? process.env.SBI_MTS_BASE_URL
|
||||||
|
const izanagiBaseUrl = options.izanagiBaseUrl ?? process.env.SBI_IZANAGI_BASE_URL
|
||||||
|
const foreignStock = resolveForeignStockEndpointConfig(options)
|
||||||
|
const mainSiteBaseUrl = options.mainSiteBaseUrl ?? process.env.SBI_MAIN_SITE_BASE_URL
|
||||||
|
const mainSiteEtGatePath =
|
||||||
|
options.mainSiteEtGatePath ??
|
||||||
|
process.env.SBI_MAIN_SITE_ET_GATE_PATH ??
|
||||||
|
MAIN_SITE_DEFAULT_PATHS.etGate
|
||||||
|
const mainSiteAssetsValuationsPath =
|
||||||
|
options.mainSiteAssetsValuationsPath ??
|
||||||
|
process.env.SBI_MAIN_SITE_ASSETS_VALUATIONS_PATH ??
|
||||||
|
MAIN_SITE_DEFAULT_PATHS.assetsValuations
|
||||||
|
const mainSiteExchangeOrderInputPath =
|
||||||
|
options.mainSiteExchangeOrderInputPath ??
|
||||||
|
process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_INPUT_PATH ??
|
||||||
|
MAIN_SITE_DEFAULT_PATHS.exchangeOrderInput
|
||||||
|
const mainSiteExchangeOrderPasswordPath =
|
||||||
|
options.mainSiteExchangeOrderPasswordPath ??
|
||||||
|
process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_PASSWORD_PATH ??
|
||||||
|
MAIN_SITE_DEFAULT_PATHS.exchangeOrderPassword
|
||||||
|
const mainSiteExchangeOrderConfirmPath =
|
||||||
|
options.mainSiteExchangeOrderConfirmPath ??
|
||||||
|
process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_CONFIRM_PATH ??
|
||||||
|
MAIN_SITE_DEFAULT_PATHS.exchangeOrderConfirm
|
||||||
|
const mainSiteExchangeOrderCompletePath =
|
||||||
|
options.mainSiteExchangeOrderCompletePath ??
|
||||||
|
process.env.SBI_MAIN_SITE_EXCHANGE_ORDER_COMPLETE_PATH ??
|
||||||
|
MAIN_SITE_DEFAULT_PATHS.exchangeOrderComplete
|
||||||
|
|
||||||
|
if (!authBaseUrl) throw new Error('SBI_AUTH_BASE_URL is required')
|
||||||
|
if (!mtsBaseUrl) throw new Error('SBI_MTS_BASE_URL is required')
|
||||||
|
|
||||||
|
return {
|
||||||
|
authBaseUrl,
|
||||||
|
mtsBaseUrl,
|
||||||
|
izanagiBaseUrl: optionalUrl(izanagiBaseUrl),
|
||||||
|
foreignStock,
|
||||||
|
mainSiteBaseUrl: optionalUrl(mainSiteBaseUrl),
|
||||||
|
mainSiteEtGatePath,
|
||||||
|
mainSiteAssetsValuationsPath,
|
||||||
|
mainSiteExchangeOrderInputPath,
|
||||||
|
mainSiteExchangeOrderPasswordPath,
|
||||||
|
mainSiteExchangeOrderConfirmPath,
|
||||||
|
mainSiteExchangeOrderCompletePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const optionalUrl = (value: string | undefined) => {
|
||||||
|
if (!value) return undefined
|
||||||
|
return new URL(value).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveForeignStockEndpointConfig = (
|
||||||
|
options: LoginWithPasskeyOptions,
|
||||||
|
): ForeignStockEndpointConfig | undefined => {
|
||||||
|
const baseUrl =
|
||||||
|
options.foreignStockBaseUrl ??
|
||||||
|
options.usStockBaseUrl ??
|
||||||
|
process.env.SBI_FOREIGN_STOCK_BASE_URL ??
|
||||||
|
process.env.SBI_US_STOCK_BASE_URL
|
||||||
|
const restUrl = options.foreignStockRestUrl ?? process.env.SBI_FOREIGN_STOCK_REST_URL
|
||||||
|
const graphqlBffUrl =
|
||||||
|
options.foreignStockGraphqlBffUrl ?? process.env.SBI_FOREIGN_STOCK_GRAPHQL_BFF_URL
|
||||||
|
const graphqlIntUrl =
|
||||||
|
options.foreignStockGraphqlIntUrl ?? process.env.SBI_FOREIGN_STOCK_GRAPHQL_INT_URL
|
||||||
|
const userAgent = options.foreignStockUserAgent ?? process.env.SBI_FOREIGN_STOCK_USER_AGENT
|
||||||
|
|
||||||
|
if (!baseUrl && !restUrl && !graphqlBffUrl && !graphqlIntUrl) return undefined
|
||||||
|
if (!baseUrl && (!restUrl || !graphqlBffUrl || !graphqlIntUrl)) {
|
||||||
|
throw new Error(
|
||||||
|
'foreign stock endpoints require foreignStockBaseUrl/usStockBaseUrl or all foreignStockRestUrl, foreignStockGraphqlBffUrl, and foreignStockGraphqlIntUrl',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
baseUrl: optionalUrl(baseUrl),
|
||||||
|
restUrl: optionalUrl(restUrl) ?? requiredDerivedUrl(baseUrl, '/rest/'),
|
||||||
|
graphqlBffUrl: optionalUrl(graphqlBffUrl) ?? requiredDerivedUrl(baseUrl, '/graphql/bff'),
|
||||||
|
graphqlIntUrl: optionalUrl(graphqlIntUrl) ?? requiredDerivedUrl(baseUrl, '/graphql/int'),
|
||||||
|
userAgent: userAgent || 'SBIFStockAndroid/1.6.10(csbi/0)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredDerivedUrl = (baseUrl: string | undefined, path: string) => {
|
||||||
|
if (!baseUrl) throw new Error(`foreign stock base URL is required to derive ${path}`)
|
||||||
|
return new URL(path, baseUrl).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const createForeignStockSession = async (
|
||||||
|
endpoints: ForeignStockEndpointConfig,
|
||||||
|
ssoToken: string | undefined,
|
||||||
|
): Promise<ForeignStockSession> => {
|
||||||
|
if (!ssoToken) {
|
||||||
|
throw new Error('foreign stock SSO login requires a passkey callback access token')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(new URL('account/authentication:ssoLogin', endpoints.restUrl), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ssoToken }),
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`foreign stock SSO login failed with HTTP ${response.status}: ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : undefined
|
||||||
|
} catch {
|
||||||
|
throw new Error('foreign stock SSO login returned non-JSON response')
|
||||||
|
}
|
||||||
|
const objectBody = body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
|
||||||
|
const sessionId = response.headers.get('Set-Session') ?? stringField(objectBody, 'sessionId')
|
||||||
|
const accountId = response.headers.get('Account-Id') ?? stringField(objectBody, 'accountId')
|
||||||
|
if (!sessionId || !accountId) {
|
||||||
|
throw new Error('foreign stock SSO login did not return Set-Session and Account-Id')
|
||||||
|
}
|
||||||
|
const marketPriceHash = await fetchForeignStockMarketPriceHash(endpoints, sessionId, accountId)
|
||||||
|
const candleHash = await fetchForeignStockHash(
|
||||||
|
endpoints,
|
||||||
|
sessionId,
|
||||||
|
accountId,
|
||||||
|
'information/chart/countries/US/candle_hashes',
|
||||||
|
'foreign stock candle hash',
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoints,
|
||||||
|
ssoToken,
|
||||||
|
sessionId,
|
||||||
|
accountId,
|
||||||
|
marketPriceHash,
|
||||||
|
candleHash,
|
||||||
|
loginAuthenticated: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchForeignStockMarketPriceHash = async (
|
||||||
|
endpoints: ForeignStockEndpointConfig,
|
||||||
|
sessionId: string,
|
||||||
|
accountId: string,
|
||||||
|
) => {
|
||||||
|
return fetchForeignStockHash(
|
||||||
|
endpoints,
|
||||||
|
sessionId,
|
||||||
|
accountId,
|
||||||
|
'information/market_price/countries/US/price_hashes',
|
||||||
|
'foreign stock market price hash',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchForeignStockHash = async (
|
||||||
|
endpoints: ForeignStockEndpointConfig,
|
||||||
|
sessionId: string,
|
||||||
|
accountId: string,
|
||||||
|
path: string,
|
||||||
|
label: string,
|
||||||
|
) => {
|
||||||
|
const response = await fetch(new URL(path, endpoints.restUrl), {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
authorization: `Bearer ${sessionId}`,
|
||||||
|
'account-id': accountId,
|
||||||
|
...(endpoints.userAgent ? { 'user-agent': endpoints.userAgent } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`${label} failed with HTTP ${response.status}: ${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = text ? JSON.parse(text) : undefined
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} returned non-JSON response`)
|
||||||
|
}
|
||||||
|
const objectBody = body && typeof body === 'object' ? (body as Record<string, unknown>) : {}
|
||||||
|
const hashValue = stringField(objectBody, 'hashValue')
|
||||||
|
if (!hashValue) {
|
||||||
|
throw new Error(`${label} response did not include hashValue`)
|
||||||
|
}
|
||||||
|
return hashValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringField = (object: Record<string, unknown>, key: string) => {
|
||||||
|
const value = object[key]
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestPasskeyAccessToken = async (options: {
|
||||||
|
authBaseUrl: string
|
||||||
|
passkeyCredential: PlaintextStoredWebAuthnCredential
|
||||||
|
channel: string
|
||||||
|
}): Promise<PasskeyAccessToken> => {
|
||||||
|
const started = startPasskeyLogin(options.authBaseUrl, options.channel)
|
||||||
const jar = new CookieJar()
|
const jar = new CookieJar()
|
||||||
const headers = defaultBrowserHeaders()
|
const headers = defaultBrowserHeaders()
|
||||||
|
|
||||||
@@ -68,7 +343,7 @@ export const createPasskeySession = async (
|
|||||||
const csrfToken = extractCsrfToken(entryText)
|
const csrfToken = extractCsrfToken(entryText)
|
||||||
|
|
||||||
const challengeUrl = new URL('/api/fido2/auth/challenge', started.url)
|
const challengeUrl = new URL('/api/fido2/auth/challenge', started.url)
|
||||||
challengeUrl.searchParams.set('cccid', 'kabu-app')
|
challengeUrl.searchParams.set('cccid', options.channel)
|
||||||
const challengeResponse = await fetchWithCookies(challengeUrl, {
|
const challengeResponse = await fetchWithCookies(challengeUrl, {
|
||||||
jar,
|
jar,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -81,16 +356,16 @@ export const createPasskeySession = async (
|
|||||||
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
...(csrfToken ? { 'x-csrf-token': csrfToken } : {}),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
assertOk(challengeResponse, 'passkey challenge')
|
assertOk(challengeResponse, `passkey challenge (${options.channel})`)
|
||||||
|
|
||||||
const challengeJson = await challengeResponse.json()
|
const challengeJson = await challengeResponse.json()
|
||||||
const credentialRequest = normalizeCredentialRequest(challengeJson, options.passkeyCredential)
|
const credentialRequest = normalizeCredentialRequest(challengeJson, options.passkeyCredential)
|
||||||
const assertion = createWebAuthnAssertion(options.passkeyCredential, credentialRequest)
|
const assertion = createWebAuthnAssertion(options.passkeyCredential, credentialRequest)
|
||||||
const csrf = credentialRequest.csrfToken ?? csrfToken
|
const csrf = credentialRequest.csrfToken ?? csrfToken
|
||||||
if (!csrf) throw new Error('missing CSRF token for passkey authentication')
|
if (!csrf) throw new Error(`missing CSRF token for passkey authentication (${options.channel})`)
|
||||||
|
|
||||||
const authUrl = new URL('/fido2/auth', started.url)
|
const authUrl = new URL('/fido2/auth', started.url)
|
||||||
authUrl.searchParams.set('cccid', 'kabu-app')
|
authUrl.searchParams.set('cccid', options.channel)
|
||||||
const authResponse = await fetchWithCookies(authUrl, {
|
const authResponse = await fetchWithCookies(authUrl, {
|
||||||
jar,
|
jar,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -116,7 +391,7 @@ export const createPasskeySession = async (
|
|||||||
})
|
})
|
||||||
|
|
||||||
const channelUrl = new URL(
|
const channelUrl = new URL(
|
||||||
authResponse.headers.get('location') ?? '/sso/channel?cccid=kabu-app',
|
authResponse.headers.get('location') ?? `/sso/channel?cccid=${options.channel}`,
|
||||||
started.url,
|
started.url,
|
||||||
)
|
)
|
||||||
const channelResponse = await fetchWithCookies(channelUrl, {
|
const channelResponse = await fetchWithCookies(channelUrl, {
|
||||||
@@ -128,50 +403,23 @@ export const createPasskeySession = async (
|
|||||||
'upgrade-insecure-requests': '1',
|
'upgrade-insecure-requests': '1',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
assertOk(channelResponse, 'passkey callback')
|
assertOk(channelResponse, `passkey callback (${options.channel})`)
|
||||||
|
|
||||||
const channelHtml = await channelResponse.text()
|
const channelHtml = await channelResponse.text()
|
||||||
const callbackUrl = extractCallbackUrl(channelHtml)
|
const callbackUrl = extractCallbackUrl(channelHtml)
|
||||||
if (!callbackUrl) throw new Error('passkey callback token was not found in sso/channel response')
|
if (!callbackUrl) {
|
||||||
|
throw new Error(
|
||||||
|
`passkey callback token was not found in sso/channel response (${options.channel})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const loginResponse = await finishPasskeyLogin({
|
return {
|
||||||
callbackUrl,
|
callbackUrl,
|
||||||
privateKeyPem: started.privateKeyPem,
|
accessToken: extractPasskeyAccessToken(callbackUrl, started.privateKeyPem),
|
||||||
mtsBaseUrl: endpoints.mtsBaseUrl,
|
|
||||||
})
|
|
||||||
const profile = parsePasskeyLoginProfile(loginResponse)
|
|
||||||
const session: SbiSession = {
|
|
||||||
mtsBaseUrl: endpoints.mtsBaseUrl,
|
|
||||||
izanagiBaseUrl: endpoints.izanagiBaseUrl,
|
|
||||||
profile,
|
|
||||||
loginResponse,
|
|
||||||
tradePassword: clientOptions.tradePassword,
|
|
||||||
tradeAuthentication: clientOptions.tradeAuthentication,
|
|
||||||
}
|
}
|
||||||
if (clientOptions.deviceId) {
|
|
||||||
await registerDeviceId(session, clientOptions.deviceId)
|
|
||||||
session.deviceIdRegistered = true
|
|
||||||
}
|
|
||||||
return session
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveSbiEndpointConfig = (options: LoginWithPasskeyOptions): SbiEndpointConfig => {
|
const startPasskeyLogin = (authBaseUrl: string, channel: string): PasskeyLoginStart => {
|
||||||
const authBaseUrl = options.authBaseUrl ?? process.env.SBI_AUTH_BASE_URL
|
|
||||||
const mtsBaseUrl = options.mtsBaseUrl ?? process.env.SBI_MTS_BASE_URL
|
|
||||||
const izanagiBaseUrl = options.izanagiBaseUrl ?? process.env.SBI_IZANAGI_BASE_URL
|
|
||||||
|
|
||||||
if (!authBaseUrl) throw new Error('SBI_AUTH_BASE_URL is required')
|
|
||||||
if (!mtsBaseUrl) throw new Error('SBI_MTS_BASE_URL is required')
|
|
||||||
|
|
||||||
return { authBaseUrl, mtsBaseUrl, izanagiBaseUrl: optionalUrl(izanagiBaseUrl) }
|
|
||||||
}
|
|
||||||
|
|
||||||
const optionalUrl = (value: string | undefined) => {
|
|
||||||
if (!value) return undefined
|
|
||||||
return new URL(value).toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const startPasskeyLogin = (authBaseUrl: string): PasskeyLoginStart => {
|
|
||||||
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
const { publicKey, privateKey } = generateKeyPairSync('rsa', {
|
||||||
modulusLength: 4096,
|
modulusLength: 4096,
|
||||||
publicExponent: 0x10001,
|
publicExponent: 0x10001,
|
||||||
@@ -181,7 +429,7 @@ const startPasskeyLogin = (authBaseUrl: string): PasskeyLoginStart => {
|
|||||||
|
|
||||||
const publicKeyParam = base64Url(publicKey, true)
|
const publicKeyParam = base64Url(publicKey, true)
|
||||||
const url = new URL(authBaseUrl)
|
const url = new URL(authBaseUrl)
|
||||||
url.searchParams.set('channel', 'kabu-app')
|
url.searchParams.set('channel', channel)
|
||||||
url.searchParams.set('pk', publicKeyParam)
|
url.searchParams.set('pk', publicKeyParam)
|
||||||
url.searchParams.set('ap', 'true')
|
url.searchParams.set('ap', 'true')
|
||||||
|
|
||||||
@@ -193,16 +441,9 @@ const startPasskeyLogin = (authBaseUrl: string): PasskeyLoginStart => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const finishPasskeyLogin = async (options: {
|
const finishPasskeyLogin = async (options: {
|
||||||
callbackUrl: string
|
accessToken: string
|
||||||
privateKeyPem: string
|
|
||||||
mtsBaseUrl: string
|
mtsBaseUrl: string
|
||||||
}): Promise<PasskeyLoginResponse> => {
|
}): Promise<PasskeyLoginResponse> => {
|
||||||
const encryptedToken = extractEncryptedToken(options.callbackUrl)
|
|
||||||
if (!encryptedToken) {
|
|
||||||
throw new Error('callbackUrl must contain token=...')
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = decryptPasskeyToken(encryptedToken, options.privateKeyPem)
|
|
||||||
const requestUrl = new URL('/mtsmobile/ssologingate', options.mtsBaseUrl)
|
const requestUrl = new URL('/mtsmobile/ssologingate', options.mtsBaseUrl)
|
||||||
const response = await fetch(requestUrl, {
|
const response = await fetch(requestUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -211,7 +452,7 @@ const finishPasskeyLogin = async (options: {
|
|||||||
},
|
},
|
||||||
body: new URLSearchParams({
|
body: new URLSearchParams({
|
||||||
KIND: 'L',
|
KIND: 'L',
|
||||||
TOKEN: accessToken,
|
TOKEN: options.accessToken,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -224,10 +465,19 @@ const finishPasskeyLogin = async (options: {
|
|||||||
status: response.status,
|
status: response.status,
|
||||||
body,
|
body,
|
||||||
text,
|
text,
|
||||||
|
accessToken: options.accessToken,
|
||||||
header: parseMtsHeader(text),
|
header: parseMtsHeader(text),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const extractPasskeyAccessToken = (callbackUrl: string, privateKeyPem: string) => {
|
||||||
|
const encryptedToken = extractEncryptedToken(callbackUrl)
|
||||||
|
if (!encryptedToken) {
|
||||||
|
throw new Error('callbackUrl must contain token=...')
|
||||||
|
}
|
||||||
|
return decryptPasskeyToken(encryptedToken, privateKeyPem)
|
||||||
|
}
|
||||||
|
|
||||||
const extractEncryptedToken = (callbackUrl: string) => {
|
const extractEncryptedToken = (callbackUrl: string) => {
|
||||||
const url = new URL(callbackUrl)
|
const url = new URL(callbackUrl)
|
||||||
if (url.searchParams.get('cmd') === 'pwlogin') return undefined
|
if (url.searchParams.get('cmd') === 'pwlogin') return undefined
|
||||||
@@ -335,8 +585,8 @@ const parsePasskeyLoginProfile = (response: PasskeyLoginResponse): AccountProfil
|
|||||||
sor: {
|
sor: {
|
||||||
defaultEnabled: sorDefaultCode === '1',
|
defaultEnabled: sorDefaultCode === '1',
|
||||||
defaultCode: emptyToUndefined(sorDefaultCode),
|
defaultCode: emptyToUndefined(sorDefaultCode),
|
||||||
lastMarket: emptyToUndefined(sorLastMarket),
|
lastMarket: mtsMarketToDomestic(emptyToUndefined(sorLastMarket)),
|
||||||
juniorNisaLastMarket: emptyToUndefined(sorLastMarketJrNisa),
|
juniorNisaLastMarket: mtsMarketToDomestic(emptyToUndefined(sorLastMarketJrNisa)),
|
||||||
},
|
},
|
||||||
notices: {
|
notices: {
|
||||||
hasImportantNotice: importantNoticeFlag === '1',
|
hasImportantNotice: importantNoticeFlag === '1',
|
||||||
@@ -458,8 +708,8 @@ const parsePasskeyLoginProfile = (response: PasskeyLoginResponse): AccountProfil
|
|||||||
sor: {
|
sor: {
|
||||||
defaultEnabled: sorDefaultCode === '1',
|
defaultEnabled: sorDefaultCode === '1',
|
||||||
defaultCode: emptyToUndefined(sorDefaultCode),
|
defaultCode: emptyToUndefined(sorDefaultCode),
|
||||||
lastMarket: emptyToUndefined(sorLastMarket),
|
lastMarket: mtsMarketToDomestic(emptyToUndefined(sorLastMarket)),
|
||||||
juniorNisaLastMarket: emptyToUndefined(sorLastMarketJrNisa),
|
juniorNisaLastMarket: mtsMarketToDomestic(emptyToUndefined(sorLastMarketJrNisa)),
|
||||||
},
|
},
|
||||||
notices: {
|
notices: {
|
||||||
hasImportantNotice: importantNoticeFlag === '1',
|
hasImportantNotice: importantNoticeFlag === '1',
|
||||||
@@ -722,8 +972,8 @@ const uint32be = (value: number) => {
|
|||||||
|
|
||||||
const extractCallbackUrl = (html: string) => {
|
const extractCallbackUrl = (html: string) => {
|
||||||
const match =
|
const match =
|
||||||
html.match(/sbikabu2:\\\/\\\/auth\\\/callback\?token=[^"'<\\]+/) ??
|
html.match(/[a-z][a-z0-9+.-]*:\\\/\\\/auth\\\/callback\?token=[^"'<\\]+/i) ??
|
||||||
html.match(/sbikabu2:\/\/auth\/callback\?token=[^"'<\\]+/)
|
html.match(/[a-z][a-z0-9+.-]*:\/\/auth\/callback\?token=[^"'<\\]+/i)
|
||||||
if (!match) return undefined
|
if (!match) return undefined
|
||||||
return match[0].replaceAll('\\/', '/')
|
return match[0].replaceAll('\\/', '/')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export type PasskeyLoginResponse = {
|
|||||||
status: number
|
status: number
|
||||||
body: ArrayBuffer
|
body: ArrayBuffer
|
||||||
text: string
|
text: string
|
||||||
|
accessToken?: string
|
||||||
header: {
|
header: {
|
||||||
sessionId: string
|
sessionId: string
|
||||||
trCode: string
|
trCode: string
|
||||||
@@ -89,9 +90,48 @@ export type PasskeyLoginResponse = {
|
|||||||
} | null
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ForeignStockEndpointConfig = {
|
||||||
|
baseUrl?: string
|
||||||
|
restUrl: string
|
||||||
|
graphqlBffUrl: string
|
||||||
|
graphqlIntUrl: string
|
||||||
|
userAgent?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ForeignStockSession = {
|
||||||
|
endpoints: ForeignStockEndpointConfig
|
||||||
|
ssoToken?: string
|
||||||
|
sessionId?: string
|
||||||
|
accountId?: string
|
||||||
|
marketPriceHash?: string
|
||||||
|
candleHash?: string
|
||||||
|
loginAuthenticated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MainSiteAuthCache = {
|
||||||
|
baseUrl: string
|
||||||
|
assetsUrl: string
|
||||||
|
cookieHeader: string
|
||||||
|
authenticatedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MainSiteSession = {
|
||||||
|
baseUrl?: string
|
||||||
|
etGatePath?: string
|
||||||
|
assetsValuationsPath?: string
|
||||||
|
exchangeOrderInputPath?: string
|
||||||
|
exchangeOrderPasswordPath?: string
|
||||||
|
exchangeOrderConfirmPath?: string
|
||||||
|
exchangeOrderCompletePath?: string
|
||||||
|
auth?: MainSiteAuthCache
|
||||||
|
authPromise?: Promise<MainSiteAuthCache>
|
||||||
|
}
|
||||||
|
|
||||||
export type SbiSession = {
|
export type SbiSession = {
|
||||||
mtsBaseUrl: string
|
mtsBaseUrl: string
|
||||||
izanagiBaseUrl?: string
|
izanagiBaseUrl?: string
|
||||||
|
foreignStock?: ForeignStockSession
|
||||||
|
mainSite?: MainSiteSession
|
||||||
profile: AccountProfile
|
profile: AccountProfile
|
||||||
loginResponse: PasskeyLoginResponse
|
loginResponse: PasskeyLoginResponse
|
||||||
tradePassword?: string
|
tradePassword?: string
|
||||||
@@ -104,6 +144,19 @@ export type LoginWithPasskeyOptions = {
|
|||||||
authBaseUrl?: string
|
authBaseUrl?: string
|
||||||
mtsBaseUrl?: string
|
mtsBaseUrl?: string
|
||||||
izanagiBaseUrl?: string
|
izanagiBaseUrl?: string
|
||||||
|
foreignStockBaseUrl?: string
|
||||||
|
usStockBaseUrl?: string
|
||||||
|
foreignStockRestUrl?: string
|
||||||
|
foreignStockGraphqlBffUrl?: string
|
||||||
|
foreignStockGraphqlIntUrl?: string
|
||||||
|
foreignStockUserAgent?: string
|
||||||
|
mainSiteBaseUrl?: string
|
||||||
|
mainSiteEtGatePath?: string
|
||||||
|
mainSiteAssetsValuationsPath?: string
|
||||||
|
mainSiteExchangeOrderInputPath?: string
|
||||||
|
mainSiteExchangeOrderPasswordPath?: string
|
||||||
|
mainSiteExchangeOrderConfirmPath?: string
|
||||||
|
mainSiteExchangeOrderCompletePath?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SbiClientOptions = {
|
export type SbiClientOptions = {
|
||||||
@@ -127,7 +180,10 @@ export type SbiTradeAuthenticationOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type IssueCode = string
|
export type IssueCode = string
|
||||||
export type MarketCode = string
|
export type DomesticMarketCode = 'XTKS'
|
||||||
|
export type SKabuMarketCode = 'STK'
|
||||||
|
export type UsStockMarketCode = 'XNAS' | 'XNYS' | 'ARCX'
|
||||||
|
export type MarketCode = DomesticMarketCode | SKabuMarketCode | UsStockMarketCode
|
||||||
export type OrderId = string
|
export type OrderId = string
|
||||||
export type WatchlistId = string
|
export type WatchlistId = string
|
||||||
export type PositionId = string
|
export type PositionId = string
|
||||||
@@ -136,7 +192,7 @@ export type ThemeId = string
|
|||||||
export type CurrencyAmount = {
|
export type CurrencyAmount = {
|
||||||
value: number | null
|
value: number | null
|
||||||
text: string
|
text: string
|
||||||
currency: 'JPY'
|
currency: 'JPY' | 'USD'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PercentValue = {
|
export type PercentValue = {
|
||||||
@@ -150,6 +206,35 @@ export type SignedTextValue = {
|
|||||||
sign?: 'positive' | 'negative' | 'zero'
|
sign?: 'positive' | 'negative' | 'zero'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AccountAssetsValuationSummary = {
|
||||||
|
assetsErrorType: unknown
|
||||||
|
valuation: number | null
|
||||||
|
netChange: number | null
|
||||||
|
percentChange: number | null
|
||||||
|
monthOnMonth: number | null
|
||||||
|
monthOnMonthRatio: unknown
|
||||||
|
profitLoss: number | null
|
||||||
|
profitLossRate: number | null
|
||||||
|
acquisitionCost: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccountAssetsValuationDetail = AccountAssetsValuationSummary & {
|
||||||
|
category: string
|
||||||
|
compositionRatio: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AccountAssetsValuations = {
|
||||||
|
fetchedAt: string
|
||||||
|
summary: AccountAssetsValuationSummary
|
||||||
|
summaryWithoutDeposit: AccountAssetsValuationSummary
|
||||||
|
summaryWithoutIdeco?: AccountAssetsValuationSummary
|
||||||
|
summaryWithoutDepositAndIdeco?: AccountAssetsValuationSummary
|
||||||
|
summaryDetails: AccountAssetsValuationDetail[]
|
||||||
|
summaryDetailsWithoutDeposit: AccountAssetsValuationDetail[]
|
||||||
|
summaryDetailsWithoutIdeco: AccountAssetsValuationDetail[]
|
||||||
|
summaryDetailsWithoutDepositAndIdeco: AccountAssetsValuationDetail[]
|
||||||
|
}
|
||||||
|
|
||||||
export type AccountType =
|
export type AccountType =
|
||||||
| 'general'
|
| 'general'
|
||||||
| 'specific'
|
| 'specific'
|
||||||
@@ -747,6 +832,58 @@ export type OrderReceipt = {
|
|||||||
error?: SbiMethodError
|
error?: SbiMethodError
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExchangeOrderSide = 'buy' | 'sell'
|
||||||
|
export type ExchangeSpecificMethod = 'foreign' | 'domestic'
|
||||||
|
export type ExchangeAccountKind = 'GENERAL' | 'JR_NISA'
|
||||||
|
export type ExchangeSellMethod = 'SELL_PART' | 'SELL_ALL'
|
||||||
|
|
||||||
|
export type ExchangeOrderPreview = {
|
||||||
|
currencyCode: string
|
||||||
|
currencyName?: string
|
||||||
|
side: ExchangeOrderSide
|
||||||
|
exchangeType?: string
|
||||||
|
accountKind?: ExchangeAccountKind
|
||||||
|
specificMethod?: ExchangeSpecificMethod
|
||||||
|
sellMethod?: ExchangeSellMethod | null
|
||||||
|
tradeQuantity?: string
|
||||||
|
orderAmount?: string
|
||||||
|
exchangeRate?: string
|
||||||
|
netAmount?: string
|
||||||
|
valueDate?: string
|
||||||
|
rateDateTime?: string
|
||||||
|
warningMessage?: string | null
|
||||||
|
isMaintenance?: boolean
|
||||||
|
csrfToken: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExchangeOrderReceipt = {
|
||||||
|
accepted: boolean
|
||||||
|
currencyCode?: string
|
||||||
|
side?: ExchangeOrderSide
|
||||||
|
message?: string
|
||||||
|
warningMessage?: string | null
|
||||||
|
rawTitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExchangeRateInfo = {
|
||||||
|
currencyCode: string
|
||||||
|
side: ExchangeOrderSide
|
||||||
|
referenceExchangeRate?: string
|
||||||
|
computeExchangeRate?: string
|
||||||
|
basePrice?: string
|
||||||
|
exchangeTradeType?: string
|
||||||
|
updateTime?: string
|
||||||
|
buyPossibleAmount?: string
|
||||||
|
sellPossibleAmount?: string
|
||||||
|
buyUnit?: string
|
||||||
|
sellUnit?: string
|
||||||
|
buyLimitMin?: string
|
||||||
|
buyLimitMax?: string
|
||||||
|
sellLimitMin?: string
|
||||||
|
sellLimitMax?: string
|
||||||
|
raw: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
export type ThemeInvestment = {
|
export type ThemeInvestment = {
|
||||||
id: ThemeId
|
id: ThemeId
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
Reference in New Issue
Block a user