This commit is contained in:
Shotaro Nakamura
2026-06-19 21:39:59 +09:00
parent 5a15172513
commit b843a5ff38
36 changed files with 3891 additions and 1008 deletions
+12
View File
@@ -0,0 +1,12 @@
- 無駄な情報は入れない。プロンプトの内容に関連付けた内容も入れない。
- 開発者向けではなく、ユーザー視点で UI を構築する
- 重複した UI の内容は削除
- 重複するなら積極的に削除
- 明らかにわかる情報はいれない
- 変な英訳も入れない
- Tailwind
- Tailwind のデフォルトの数値を優先
- bg-[#xxxxxx] は控え、代わりに bg-xxx-xxx を使用する
- もし使いたい色があるのなら @color で css に定義してから使う
- rounded-[xxpx] の代わりに rounded-lg, md, ... などをつかう
- その他も同様
+1 -1
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CSBIE</title>
<title>cSBIe</title>
</head>
<body>
<div id="app"></div>
+60 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { AnimatePresence } from 'motion-v'
import { computed, onMounted } from 'vue'
import { computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { revokeApiKey } from './api'
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 TradeView from './features/trade/TradeView.vue'
import { useTradingSession } from './features/trading/useTradingSession'
import { stockIdFromTradeRouteId, tradeRouteIdFromStockId } from './constants/trade'
import { routeNames, type RouteName } from './router'
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'
})
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) => {
if (name === 'settings') return router.push('/settings/api-keys')
if (name === 'trade') return navigateTrade()
return router.push({ name })
}
@@ -55,6 +74,7 @@ const {
const {
selectedStockCode,
selectedStockId,
tradeSide,
orderKind,
cashOrderAccountType,
@@ -86,6 +106,7 @@ const {
totalProfitLoss,
totalProfitLossRate,
orders,
cancelingOrderKey,
orderHistoryLoaded,
orderHistoryNotice,
positions,
@@ -118,6 +139,7 @@ const {
cashAssetRatio,
hasQuote,
selectStock,
selectStockByCode,
connect,
loadTradingData,
estimateCashOrder,
@@ -149,6 +171,25 @@ const revokeAndRefresh = async (id: string) => {
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 () => {
try {
await refreshAndMaybeConnect()
@@ -164,7 +205,7 @@ onMounted(async () => {
<AppSidebar :active-tab="activeTab" @navigate="navigate" />
<section :class="ui.workspace">
<AppHeader :active-tab="activeTab" />
<AppHeader v-if="activeTab !== 'settings'" :active-tab="activeTab" />
<OAuthApprovalPanel
v-if="oauthApproval.active && status.authenticated"
@@ -195,12 +236,17 @@ onMounted(async () => {
:cash-asset-ratio="cashAssetRatio"
:positions="positions"
:recent-orders="recentOrders"
:canceling-order-key="cancelingOrderKey"
:data-loading="dataLoading"
:connected="connected"
:order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice"
@connect="connect"
@open-position="(code) => openTradeForPosition(code, () => void navigate('trade'))"
@open-position="
(code) =>
openTradeForPosition(code, () => void navigateTrade(selectedStock.symbol || code))
"
@cancel-order="cancelOrder"
/>
<TradeView
@@ -239,7 +285,12 @@ onMounted(async () => {
:price-polling="pricePolling"
:has-quote="hasQuote"
@open-search="showSearch = true"
@select-stock="selectStock"
@select-stock="
(stock) => {
selectStock(stock)
navigateTrade(stock.symbol || stock.code)
}
"
@download-csv="downloadCsv"
@estimate="estimateCashOrder"
@confirm-order="askPlaceOrder"
@@ -250,6 +301,7 @@ onMounted(async () => {
:orders="orders"
:connected="connected"
:data-loading="dataLoading"
:canceling-order-key="cancelingOrderKey"
:order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice"
@refresh="loadTradingData"
@@ -292,7 +344,9 @@ onMounted(async () => {
:markets="markets"
:loading="searchLoading"
@close="showSearch = false"
@select="(stock) => openTradeForStock(stock, () => void navigate('trade'))"
@select="
(stock) => openTradeForStock(stock, () => void navigateTrade(stock.symbol || stock.code))
"
/>
</AnimatePresence>
@@ -301,6 +355,7 @@ onMounted(async () => {
:show-estimate="showEstimateDialog"
:show-order="showOrderDialog"
:stock-name="selectedStock.name"
:stock-market="selectedStock.market"
:side="tradeSide"
:kind="orderKind"
:account-type="cashOrderAccountType"
@@ -11,6 +11,7 @@ const settings = defineModel<ApiKeySettings>({ required: true })
const rpcMethods = [
'session.profile',
'account.profile',
'account.assets.current',
'account.power.buyingPower',
'account.power.collateralRatio',
'account.positions.cash',
@@ -66,6 +67,9 @@ const rpcMethods = [
'orders.themeInvestment.list',
'orders.themeInvestment.estimate',
'orders.themeInvestment.place',
'orders.exchange.rate',
'orders.exchange.estimate',
'orders.exchange.place',
] as const
const tradingMethods = [
@@ -81,6 +85,7 @@ const tradingMethods = [
'orders.ifd.placeCorrection',
'orders.ifd.placeCancel',
'orders.themeInvestment.place',
'orders.exchange.place',
] as const
const tradingMethodSet = new Set<string>(tradingMethods)
+72 -10
View File
@@ -1,18 +1,80 @@
<script setup lang="ts">
defineProps<{
title: string
eyebrow?: string
import { computed, onMounted, onUnmounted } from 'vue'
import { motion } from 'motion-v'
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>
<template>
<div class="fixed inset-0 z-20 grid place-items-center bg-[#101418]/75 backdrop-blur-sm">
<section
class="grid w-[28rem] gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-2xl shadow-black/35"
<motion.div
class="fixed inset-0 z-30 grid place-items-center overflow-y-auto bg-[#101418]/75 p-4 backdrop-blur-sm"
: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>
<h2 class="text-lg font-bold text-[#e3e3e9]">{{ title }}</h2>
<header
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 />
</section>
</div>
</motion.section>
</motion.div>
</template>
+48 -18
View File
@@ -1,31 +1,61 @@
<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
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<{
'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>
<template>
<div
class="grid min-h-12 grid-cols-2 gap-1 rounded-full bg-[#111418] p-1 outline outline-1 outline-[#33383f]"
>
<button
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)"
<LayoutGroup :id="groupId">
<div
class="relative grid min-h-12 overflow-hidden rounded-full bg-[#111418] p-1 outline outline-1 outline-[#33383f]"
:style="{ gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))` }"
>
{{ option.label }}
</button>
</div>
<button
v-for="option in props.options"
: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>
+28
View File
@@ -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)],
],
}
+45
View File
@@ -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
+25 -8
View File
@@ -33,19 +33,36 @@ export const cashOrderMarketOptions: Array<{
value: CashOrderMarket
}> = [
{ label: '自動', value: 'auto' },
{ label: '東証', value: 'TKY' },
{ label: '名証', value: 'NGY' },
{ label: '福証', value: 'FKO' },
{ label: '札証', value: 'SPR' },
{ label: 'SOR', value: 'SOR' },
{ label: 'PTS', value: 'PTS' },
{ label: 'PTS(X)', value: 'PTX' },
{ label: '東証', value: 'XTKS' },
{ label: 'NASDAQ', value: 'XNAS' },
{ label: 'NYSE', value: 'XNYS' },
{ label: 'NYSE Arca', value: 'ARCX' },
]
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<{
label: string
value: CashOrderMarket
}> = [{ label: 'S株', value: 'STK' }]
}> = []
export const cashOrderPriceConditionOptions: Array<{
label: string
@@ -1,14 +1,19 @@
<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 UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
import type { OrderRow } from '../../types/trading'
import { orderAmountText, orderQuantityText } from '../trading/trading-data'
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
defineProps<{
orders: OrderRow[]
connected: boolean
dataLoading: boolean
cancelingOrderKey: string
orderHistoryLoaded: boolean
orderHistoryNotice: string
}>()
@@ -17,6 +22,25 @@ const emit = defineEmits<{
refresh: []
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>
<template>
@@ -37,7 +61,7 @@ const emit = defineEmits<{
<div v-for="order in orders" :key="order.id" :class="ui.orderRow">
<span>
<strong>{{ order.stock }}</strong>
<small>{{ order.date }} / {{ order.kind === 's' ? 'S株' : '通常単元' }}</small>
<small>{{ order.date }} / 通常単元</small>
</span>
<span :class="order.side === 'buy' ? ui.positive : ui.negative">
{{ order.side === 'buy' ? '購入' : '売却' }}
@@ -48,13 +72,15 @@ const emit = defineEmits<{
{{ order.status }}
</span>
<button
v-if="order.status === '注文中'"
v-if="canCancel(order)"
:class="ui.ghostButton"
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>
</div>
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
@@ -71,4 +97,43 @@ const emit = defineEmits<{
</p>
</div>
</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>
+135 -116
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ArrowLeft, Send, ShieldCheck } from 'lucide-vue-next'
import { AnimatePresence } from 'motion-v'
import UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
@@ -14,13 +15,14 @@ import type {
OrderPreview,
TradeSide,
} from '../../types/trading'
import { currency } from '../../utils/format'
import { currencyForMarket } from '../../utils/format'
defineProps<{
estimate: OrderPreview | null
showEstimate: boolean
showOrder: boolean
stockName: string
stockMarket: string
side: TradeSide
kind: OrderKind
accountType: CashOrderAccountType
@@ -76,138 +78,155 @@ const accountTypeLabel = (type: CashOrderAccountType) =>
</script>
<template>
<UiModal v-if="showEstimate && estimate" eyebrow="見積" :title="stockName">
<dl :class="ui.confirmList">
<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'">
<AnimatePresence>
<UiModal
v-if="showEstimate && estimate"
key="estimate-dialog"
eyebrow="見積"
:title="stockName"
>
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>執行条件</dt>
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
</div>
<div v-if="price > 0" :class="ui.confirmRow">
<dt>注文価格</dt>
<dd>{{ currency(price) }}</dd>
<dt>売買</dt>
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>有効期限</dt>
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
<dt>数量</dt>
<dd>{{ quantity }}株</dd>
</div>
<div :class="ui.confirmRow">
<dt>特殊注文</dt>
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
<dt>預り区分</dt>
<dd>{{ accountTypeLabel(accountType) }}</dd>
</div>
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
<dt>逆指値</dt>
<dd>{{ currency(triggerPrice) }} {{ triggerZoneLabel(triggerZone) }}</dd>
<div :class="ui.confirmRow">
<dt>注文市場</dt>
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
</div>
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
<dt>OCO条件</dt>
<dd>
{{ priceConditionLabel(secondaryPriceCondition) }}
<template v-if="secondaryPrice > 0"> {{ currency(secondaryPrice) }}</template>
</dd>
<template>
<div :class="ui.confirmRow">
<dt>執行条件</dt>
<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>
</template>
<div :class="ui.confirmRow">
<dt>概算</dt>
<dd>{{ currency(amount) }}</dd>
</dl>
<p :class="ui.dialogNote">
{{ 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>
</dl>
<p :class="ui.dialogNote">
{{ 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>
</AnimatePresence>
<UiModal v-if="showOrder" eyebrow="注文確認" :title="stockName">
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<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'">
<AnimatePresence>
<UiModal v-if="showOrder" key="order-dialog" eyebrow="注文確認" :title="stockName">
<dl :class="ui.confirmList">
<div :class="ui.confirmRow">
<dt>執行条件</dt>
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
</div>
<div v-if="price > 0" :class="ui.confirmRow">
<dt>注文価格</dt>
<dd>{{ currency(price) }}</dd>
<dt>区分</dt>
<dd>通常単元</dd>
</div>
<div :class="ui.confirmRow">
<dt>有効期限</dt>
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
<dt>売買</dt>
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>特殊注文</dt>
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
<dt>数量</dt>
<dd>{{ quantity }}株</dd>
</div>
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
<dt>逆指値</dt>
<dd>{{ currency(triggerPrice) }} {{ triggerZoneLabel(triggerZone) }}</dd>
<div :class="ui.confirmRow">
<dt>預り区分</dt>
<dd>{{ accountTypeLabel(accountType) }}</dd>
</div>
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
<dt>OCO条件</dt>
<dd>
{{ priceConditionLabel(secondaryPriceCondition) }}
<template v-if="secondaryPrice > 0"> {{ currency(secondaryPrice) }}</template>
</dd>
<div :class="ui.confirmRow">
<dt>注文市場</dt>
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
</div>
</template>
<div :class="ui.confirmRow">
<dt>概算</dt>
<dd>{{ currency(amount) }}</dd>
<template>
<div :class="ui.confirmRow">
<dt>執行条件</dt>
<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>
</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>
</UiModal>
</UiModal>
</AnimatePresence>
</template>
@@ -1,10 +1,20 @@
<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 UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
import type { OrderRow, Position } from '../../types/trading'
import { currency, signedCurrency, signedPercent } from '../../utils/format'
import { orderAmountText, orderQuantityText } from '../trading/trading-data'
import {
currency,
currencyForMarket,
signedCurrency,
signedCurrencyForMarket,
signedPercent,
} from '../../utils/format'
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
defineProps<{
showPortfolioSpinner: boolean
@@ -17,6 +27,7 @@ defineProps<{
cashAssetRatio: number
positions: Position[]
recentOrders: OrderRow[]
cancelingOrderKey: string
dataLoading: boolean
connected: boolean
orderHistoryLoaded: boolean
@@ -26,7 +37,27 @@ defineProps<{
const emit = defineEmits<{
connect: []
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>
<template>
@@ -137,12 +168,14 @@ const emit = defineEmits<{
position.type ?? (position.quantity >= 100 ? '単元' : 'S株')
}}</b>
<span>{{ position.quantity }}</span>
<span :class="ui.muted">{{ currency(position.marketValue) }}</span>
<span :class="ui.muted">{{
currencyForMarket(position.marketValue, position.market)
}}</span>
<span
class="grid justify-items-end gap-0.5"
: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>
</span>
</button>
@@ -162,14 +195,28 @@ const emit = defineEmits<{
<span class="grid gap-1">
<strong>{{ order.stock }}</strong>
<small
>{{ order.side === 'buy' ? '買付' : '売却' }}({{
order.kind === 's' ? 'S株' : '単元'
}}) ・ {{ orderQuantityText(order) }}</small
>{{ order.side === 'buy' ? '買付' : '売却' }}(単元) ・
{{ orderQuantityText(order) }}</small
>
<small :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]">
{{ order.status }}
</small>
</span>
<span class="grid justify-items-end gap-1">
<small>{{ order.date.slice(5, 10) }}</small>
<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>
</div>
</div>
@@ -188,4 +235,43 @@ const emit = defineEmits<{
</div>
</article>
</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>
@@ -1,24 +1,54 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { type ComponentPublicInstance, nextTick, ref, watch } from 'vue'
import { motion } from 'motion-v'
import Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue'
import UiField from '../../components/ui/UiField.vue'
import searchEmptyImage from '../../assets/search-empty.png'
import { uiMotion } from '../../constants/motion'
import { ui } from '../../styles/ui'
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 sheetTransition = { type: 'spring', damping: 28, stiffness: 320 } as const
const overlayTransition = uiMotion.overlay.fadeDefault
const sheetTransition = uiMotion.trade.searchSheet
defineProps<{
const props = defineProps<{
stocks: Stock[]
selectedStockCode: string
countries: string[]
markets: string[]
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 countryFilter = defineModel<string>('countryFilter', { required: true })
@@ -93,17 +123,27 @@ const emit = defineEmits<{
<button
v-for="stock in stocks"
: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"
:aria-current="selectedStockCode === stock.code ? 'true' : undefined"
:aria-selected="selectedStockCode === stock.code"
:tabindex="selectedStockCode === stock.code ? 0 : -1"
@click="emit('select', stock)"
@keyup.enter="emit('select', stock)"
>
<span class="grid gap-1">
<strong>{{ stock.name }}</strong>
<small>{{ stock.code }} / {{ stock.country }} / {{ stock.market }}</small>
</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" />
</span>
</button>
@@ -5,16 +5,19 @@ import {
KeyRound,
Pencil,
Plug,
Plus,
RefreshCw,
Save,
ShieldCheck,
Trash2,
UserRoundCog,
} from 'lucide-vue-next'
import { AnimatePresence } from 'motion-v'
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { ApiKey, ApiKeySettings, SbiPasskey } from '../../api'
import ApiKeyPolicyEditor from '../../components/ApiKeyPolicyEditor.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
defineProps<{
@@ -51,6 +54,10 @@ const activeSection = computed<SettingsSection>(() => {
return route.params.section === 'passkeys' ? 'passkeys' : 'api-keys'
})
const isNewApiKey = computed(
() => activeSection.value === 'api-keys' && route.params.mode === 'new',
)
const settingsGroups = [
{
label: 'アクセス',
@@ -80,8 +87,16 @@ const openSection = (section: SettingsSection) => {
router.push(`/settings/${section}`)
}
const openNewApiKey = () => {
router.push('/settings/api-keys/new')
}
const sectionTitle = computed(() =>
activeSection.value === 'api-keys' ? 'API キー' : 'SBI パスキー',
isNewApiKey.value
? 'API Key 発行'
: activeSection.value === 'api-keys'
? 'API キー'
: 'SBI パスキー',
)
const openApiKeyEditor = (key: ApiKey) => {
@@ -103,19 +118,12 @@ const saveEditingApiKey = () => {
</script>
<template>
<section
class="grid min-h-[calc(100dvh-9rem)] min-w-0 max-w-full gap-0 lg:grid-cols-[18rem_minmax(0,1fr)]"
>
<section class="grid min-h-[calc(100dvh-9rem)] min-w-0 max-w-full gap-0 lg:block">
<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"
>
<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">
<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>
@@ -153,32 +161,43 @@ const saveEditingApiKey = () => {
</div>
</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
class="flex flex-wrap items-center justify-between gap-3 border-b border-[#30343a] pb-5"
>
<div class="grid gap-1">
<p :class="ui.eyebrow">Settings</p>
<h2 class="text-2xl font-black text-[#e3e3e9]">{{ sectionTitle }}</h2>
</div>
<button :class="ui.ghostButton" type="button" @click="emit('refresh')">
<RefreshCw class="h-4 w-4" aria-hidden="true" />
更新
</button>
<div class="flex flex-wrap items-center gap-2">
<button
v-if="isNewApiKey"
: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>
<template v-if="activeSection === 'api-keys'">
<article :class="[ui.panel, 'min-w-0 overflow-hidden']">
<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>
<template v-if="isNewApiKey">
<article class="grid min-w-0 content-start gap-4 overflow-hidden bg-transparent">
<label :class="ui.label">
名前
<input v-model="apiKeyLabel" :class="ui.input" placeholder="例: trade-bot-readonly" />
@@ -193,18 +212,14 @@ const saveEditingApiKey = () => {
></textarea>
</label>
</article>
</template>
<article :class="[ui.panel, 'min-w-0 overflow-hidden']">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p :class="ui.eyebrow">Active keys</p>
<h3 class="text-lg font-black">発行済みキー</h3>
</div>
</div>
<template v-else-if="activeSection === 'api-keys'">
<article class="grid min-w-0 content-start gap-4 overflow-hidden bg-transparent">
<div v-if="apiKeys.length" :class="ui.list">
<div v-for="key in apiKeys" :key="key.id" :class="ui.keyRow">
<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">
<button
:class="ui.ghostButton"
@@ -238,10 +253,9 @@ const saveEditingApiKey = () => {
</template>
<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>
<p :class="ui.eyebrow">Profiles</p>
<h3 class="text-lg font-black">パスキー追加</h3>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('addSbiPasskey')">
@@ -279,10 +293,9 @@ const saveEditingApiKey = () => {
</div>
</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>
<p :class="ui.eyebrow">Connection</p>
<h3 class="text-lg font-black">プロフィール切り替え</h3>
</div>
<button :class="ui.ghostButton" type="button" @click="emit('connect')">
@@ -328,38 +341,24 @@ const saveEditingApiKey = () => {
</template>
</div>
<div
v-if="editingApiKey"
class="fixed inset-0 z-30 grid place-items-center overflow-y-auto bg-[#101418]/75 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
@click.self="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"
<AnimatePresence>
<UiModal
v-if="editingApiKey"
:title="editingApiKey.label"
size="lg"
@close="closeApiKeyEditor"
>
<header
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 :class="ui.eyebrow">Edit API key</p>
<h3 class="truncate text-xl font-black text-[#e3e3e9]">
{{ editingApiKey.label }}
</h3>
</div>
<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>
<template #actions>
<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>
</template>
<ApiKeyPolicyEditor v-model="editingApiKey" permissions-open />
</section>
</div>
</UiModal>
</AnimatePresence>
</section>
</template>
@@ -2,7 +2,9 @@
import * as d3 from 'd3'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Spinner from '../../components/ui/Spinner.vue'
import { marketTimeZones } from '../../constants/market'
import type { ChartMode, ChartNotice, ChartRange, RealtimePricePoint } from '../../types/trading'
import { currencyForMarket } from '../../utils/format'
const props = defineProps<{
points: RealtimePricePoint[]
@@ -10,6 +12,7 @@ const props = defineProps<{
active: boolean
mode: ChartMode
range: ChartRange
market?: string
previousClose?: number
notice?: ChartNotice | null
}>()
@@ -40,7 +43,8 @@ const tooltip = ref({
x: 0,
y: 0,
price: '',
date: '',
jstDate: '',
localDate: '',
detail: '',
})
@@ -62,7 +66,6 @@ const dayMs = 24 * 60 * 60 * 1000
const tolerancePx = 10
const offscreenPointBuffer = 4
const minXAxisTickSpacing = 72
const chartPoints = computed<ChartPoint[]>(() =>
props.points
.map((point, index) => ({ ...point, index, date: new Date(point.at) }))
@@ -80,6 +83,31 @@ const labelFor = (point: ChartPoint) => {
}).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 (
point.date.getHours() === 0 &&
point.date.getMinutes() === 0 &&
@@ -97,6 +125,9 @@ const labelFor = (point: ChartPoint) => {
}
const closePrice = (point: ChartPoint) => point.close ?? point.price
const marketTimeZone = computed(
() => marketTimeZones[props.market?.toUpperCase() ?? ''] ?? 'Asia/Tokyo',
)
const previousCloseValue = computed(() =>
props.range === '1D' && Number.isFinite(props.previousClose) && props.previousClose
@@ -112,10 +143,31 @@ const ohlcFor = (point: ChartPoint) => {
return { open, high, low, close }
}
const tooltipDateFor = (point: ChartPoint) => {
const hasTime =
point.date.getHours() !== 0 || point.date.getMinutes() !== 0 || point.date.getSeconds() !== 0
const zonedClockParts = (date: Date, timeZone: string) => {
const parts = Object.fromEntries(
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', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -124,22 +176,24 @@ const tooltipDateFor = (point: ChartPoint) => {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short',
}
: {}),
}).format(point.date)
}
const tooltipStyle = computed(() => {
const width = 152
const width = 220
const height = 92
const pad = 8
const left = Math.min(
Math.max(pad, tooltip.value.x + 12),
Math.max(pad, size.value.width - width - pad),
)
const top =
tooltip.value.y > 76
? Math.max(pad, tooltip.value.y - 70)
: Math.min(size.value.height - 64, tooltip.value.y + 14)
tooltip.value.y > height + pad
? Math.max(pad, tooltip.value.y - height)
: Math.min(size.value.height - height - pad, tooltip.value.y + 14)
return {
left: `${left}px`,
top: `${top}px`,
@@ -487,11 +541,12 @@ const showHover = (point: ChartPoint) => {
visible: true,
x,
y,
price: `${closePrice(point).toLocaleString('ja-JP')}円`,
date: tooltipDateFor(point),
price: currencyForMarket(closePrice(point), props.market),
jstDate: tooltipDateFor(point, 'Asia/Tokyo'),
localDate: tooltipDateFor(point, marketTimeZone.value),
detail:
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>
<div
v-if="notice"
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="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="tooltip.visible && 'translate-y-0 opacity-100'"
:style="tooltipStyle"
>
<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]">
{{ tooltip.detail }}
</div>
+391 -369
View File
@@ -4,6 +4,7 @@ import {
Download,
FileCheck2,
Minus,
TriangleAlert,
Plus,
RotateCcw,
Search,
@@ -23,6 +24,7 @@ import {
tradeSideOptions,
} from '../../constants/trade'
import { ui } from '../../styles/ui'
import { uiMotion } from '../../constants/motion'
import type {
ChartMode,
ChartNotice,
@@ -39,7 +41,7 @@ import type {
Stock,
TradeSide,
} from '../../types/trading'
import { currency } from '../../utils/format'
import { currency, currencyForMarket } from '../../utils/format'
import RealtimePriceChart from './RealtimePriceChart.vue'
type StockInfoTab = 'detail' | 'holding'
@@ -60,7 +62,7 @@ type CashOrderDateOption = {
value: string
}
const chartRangeOptions: ChartRange[] = ['1D', '7D', '1M', '3M', '1Y', 'ALL']
const chartRangeOptions: ChartRange[] = ['1D', '3D', '3M', '1Y', 'ALL']
defineProps<{
viewedStocks: Stock[]
@@ -128,9 +130,9 @@ const secondaryPriceConditionRequiresPrice = computed(() =>
cashOrderSecondaryPriceCondition.value,
),
)
const tabTransition = { type: 'spring', damping: 28, stiffness: 360 } as const
const tabContentTransition = { duration: 0.18, ease: 'easeOut' } as const
const advancedTransition = { duration: 0.18, ease: 'easeOut' } as const
const tabTransition = uiMotion.trade.tabIndicator
const tabContentTransition = uiMotion.trade.tabContentFade
const advancedTransition = uiMotion.trade.advancedOptions
const emit = defineEmits<{
openSearch: []
@@ -143,41 +145,8 @@ const emit = defineEmits<{
<template>
<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">
<article :class="ui.stockPanel">
<article :class="ui.stockMetadataPanel">
<div :class="ui.stockTitle">
<div>
<p :class="ui.eyebrow">{{ selectedStock.symbol }}</p>
@@ -186,7 +155,7 @@ const emit = defineEmits<{
<div :class="ui.quoteBox">
<strong>
<template v-if="hasQuote(selectedStock)">{{
currency(selectedStock.price)
currencyForMarket(selectedStock.price, selectedStock.market)
}}</template>
<Spinner v-else size="sm" />
</strong>
@@ -199,210 +168,226 @@ const emit = defineEmits<{
<Spinner v-else size="sm" />
</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>
<LayoutGroup id="stock-info-tabs">
<article :class="ui.infoTabPanel">
<div :class="ui.infoTabList" role="tablist" aria-label="銘柄別情報">
<div :class="ui.stockBodyGrid">
<article :class="[ui.stockPanel, ui.stockPanelSpan]">
<div :class="ui.periodTabs">
<button
id="stock-detail-tab"
:class="[ui.infoTabButton, activeStockInfoTab === 'detail' && ui.infoTabButtonActive]"
v-for="range in chartRangeOptions"
:key="range"
:class="[ui.periodButton, chartRange === range && ui.periodButtonActive]"
type="button"
role="tab"
:aria-selected="activeStockInfoTab === 'detail'"
aria-controls="stock-detail-panel"
@click="activeStockInfoTab = 'detail'"
:aria-pressed="chartRange === range"
@click="chartRange = 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"
/>
{{ range }}
</button>
</div>
<div :class="ui.infoTabBody">
<AnimatePresence mode="wait" :initial="false">
<motion.dl
v-if="activeStockInfoTab === 'detail'"
id="stock-detail-panel"
key="detail"
:class="ui.detailGrid"
role="tabpanel"
aria-labelledby="stock-detail-tab"
:initial="{ opacity: 0, y: 8 }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, y: -8 }"
: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)">{{
currency(selectedStock.open)
}}</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)">{{
currency(selectedStock.high)
}}</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)">{{
currency(selectedStock.low)
}}</template>
<Spinner v-else size="sm" />
</dd>
</div>
</motion.dl>
<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>
<motion.div
v-else
id="stock-holding-panel"
key="holding"
role="tabpanel"
aria-labelledby="stock-holding-tab"
:initial="{ opacity: 0, y: 8 }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, y: -8 }"
:transition="tabContentTransition"
>
<div v-if="selectedPosition" :class="ui.holdingNote">
保有 {{ selectedPosition.quantity }}株 / 平均
{{ currency(selectedPosition.avgPrice) }}
</div>
<p v-else :class="ui.holdingEmpty">保有なし</p>
</motion.div>
</AnimatePresence>
<div :class="[ui.chartBox, 'overflow-visible']">
<RealtimePriceChart
v-if="hasQuote(selectedStock)"
ref="priceChart"
:points="realtimePricePoints"
:stock-name="selectedStock.name"
:active="pricePolling"
:mode="chartMode"
:range="chartRange"
:market="selectedStock.market"
:previous-close="selectedStock.prevClose"
:notice="chartNotice"
/>
<span v-else class="grid h-full place-items-center text-[#8f949d]">
<Spinner />
</span>
<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>
</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 :class="ui.tradeSeparator" aria-hidden="true"></div>
@@ -422,11 +407,13 @@ const emit = defineEmits<{
min="1"
placeholder="株数を入力"
/>
<details
:class="ui.advancedOptions"
@toggle="advancedOptionsOpen = ($event.target as HTMLDetailsElement).open"
>
<summary :class="ui.advancedSummary">
<section :class="ui.advancedOptions">
<button
type="button"
:class="ui.advancedSummary"
:aria-expanded="advancedOptionsOpen"
@click="advancedOptionsOpen = !advancedOptionsOpen"
>
<span>Advanced Options</span>
<motion.span
:class="ui.advancedSummaryIconWrap"
@@ -435,134 +422,53 @@ const emit = defineEmits<{
>
<ChevronDown :class="ui.advancedSummaryIcon" aria-hidden="true" />
</motion.span>
</summary>
<motion.div
:class="ui.advancedBody"
:initial="{ opacity: 0, y: -4 }"
:animate="{ opacity: 1, y: 0 }"
:transition="advancedTransition"
>
<span :class="ui.advancedLabel">預り区分</span>
<LayoutGroup id="cash-order-account-type">
<div :class="ui.accountTypeGrid" role="radiogroup" aria-label="預り区分">
<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'"
</button>
<AnimatePresence>
<motion.div
v-if="advancedOptionsOpen"
key="advanced-options"
:class="ui.advancedBody"
:initial="{ opacity: 0, height: 0, y: -4 }"
:animate="{ opacity: 1, height: 'auto', y: 0 }"
:exit="{ opacity: 0, height: 0, y: -4 }"
:transition="advancedTransition"
>
<option
v-for="option in cashOrderMarketOptions"
:key="option.value"
:value="option.value"
>
{{ 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"
<span :class="ui.advancedLabel">預り区分</span>
<LayoutGroup id="cash-order-account-type">
<div :class="ui.accountTypeGrid" role="radiogroup" aria-label="預り区分">
<button
v-for="option in cashOrderAccountTypeOptions"
: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 }}
</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執行条件"
<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="注文市場">
<option
v-for="option in cashOrderMarketOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</UiField>
<template>
<UiField v-model="cashOrderPriceCondition" as="select" label="執行条件">
<option
v-for="option in cashOrderPriceConditionOptions"
:key="option.value"
@@ -572,25 +478,106 @@ const emit = defineEmits<{
</option>
</UiField>
<UiField
v-if="secondaryPriceConditionRequiresPrice"
v-model="cashOrderSecondaryPriceInput"
label="OCO価格"
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"
: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>
</motion.div>
</details>
</motion.div>
</AnimatePresence>
</section>
</div>
</div>
</div>
<div :class="ui.ticketBottom">
<div :class="ui.estimateSummary">
<span>概算金額</span>
<strong>{{ currency(estimatedAmount) }}</strong>
<strong>{{ currencyForMarket(estimatedAmount, selectedStock.market) }}</strong>
</div>
<div :class="ui.actions">
<UiButton type="button" :disabled="!canRequestCashEstimate" @click="emit('estimate')">
@@ -609,5 +596,40 @@ const emit = defineEmits<{
</div>
</div>
</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>
</template>
@@ -68,6 +68,11 @@ const nullableNumberValue = (value: unknown): number | null => {
export const textValue = (value: unknown, fallback = ''): string =>
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) => {
const issue = asRecord(value)
return {
@@ -110,8 +115,8 @@ const boxFromHistory = (history: number[]) => {
export const stockFromIssue = (issue: ReturnType<typeof issueFrom>): Stock => ({
code: issue.code,
name: issue.name || issue.code,
symbol: issue.code ? `${issue.code}${issue.market ? `.${issue.market}` : ''}` : '',
country: '日本',
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
country: countryFromMarket(issue.market),
market: issue.market,
sector: '',
price: 0,
@@ -163,8 +168,8 @@ export const stockFromBoard = (
return {
code: issue.code,
name: issue.name,
symbol: issue.code ? `${issue.code}${issue.market ? `.${issue.market}` : ''}` : '',
country: '日本',
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
country: countryFromMarket(issue.market),
market: issue.market,
sector: '',
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 digits = value.replace(/\D/g, '')
if (digits.length < 8) return ''
@@ -319,6 +333,8 @@ export const chartNoticeFromIssueChart = (value: unknown): ChartNotice | null =>
const today = japanToday()
if (!latestDate || latestDate >= today) return null
if (!isWeekendInJapan()) return null
const formattedLatestDate = new Intl.DateTimeFormat('ja-JP', {
timeZone: 'Asia/Tokyo',
month: 'numeric',
@@ -326,7 +342,7 @@ export const chartNoticeFromIssueChart = (value: unknown): ChartNotice | null =>
}).format(new Date(`${latestDate}T00:00:00+09:00`))
return {
title: isWeekendInJapan() ? '本日は休場日です' : '本日の取引データはまだありません',
title: '本日は休場日です',
detail: `最新のチャートは${formattedLatestDate}です`,
}
}
@@ -365,7 +381,7 @@ export const orderFromApi = (value: unknown): OrderRow | null => {
const issue = issueFrom(item.issue)
if (!issue.code) return null
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 executedQuantity = nullableNumberValue(item.executedQuantity)
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 ?? ''}`)),
date: textValue(item.orderedAt, textValue(item.expiresAt)),
stock: issue.name || issue.code,
market: issue.market,
side,
kind,
quantity,
@@ -3,8 +3,9 @@ import { createRpcSocket } from '../../api'
import {
cashOrderAccountTypeOptions as defaultCashOrderAccountTypeOptions,
cashOrderMarketOptions as defaultCashOrderMarketOptions,
sKabuOrderMarketOptions,
searchableMarkets,
} from '../../constants/trade'
import { countryTimeZones, marketSessions, marketTimeZones } from '../../constants/market'
import type {
ChartMode,
ChartRange,
@@ -32,6 +33,7 @@ import {
fulfilledValues,
isOrderPreview,
issueFrom,
marketDateKey,
numberValue,
orderFromApi,
orderHistoryKey,
@@ -39,7 +41,6 @@ import {
chartNoticeFromIssueChart,
pricePointsFromIssueChart,
positionFromApi,
startOfMarketDateUtcMs,
stockFromBoard,
stockFromIssue,
stockFromPosition,
@@ -85,8 +86,7 @@ const cashOrderPriceConditionRequiresPrice = (condition: CashOrderPriceCondition
const chartRangeOptions = {
'1D': { period: 'minute', unit: 5, count: 120 },
'7D': { period: 'day', unit: 1, count: 7 },
'1M': { period: 'day', unit: 1, count: 31 },
'3D': { period: 'minute', unit: 15, count: 9999 },
'3M': { period: 'day', unit: 1, count: 93 },
'1Y': { period: 'day', unit: 1, count: 365 },
ALL: { period: 'month', unit: 1, count: 9999 },
@@ -95,36 +95,54 @@ const chartRangeOptions = {
{ 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) =>
marketTimeZones[stock.market.toUpperCase()] ?? countryTimeZones[stock.country] ?? null
const marketClockFormatter = (timeZone: string) =>
new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
})
const marketClockParts = (timeZone: string, now = new Date()) => {
const parts = Object.fromEntries(
marketClockFormatter(timeZone)
.formatToParts(now)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value]),
) as Partial<Record<'weekday' | 'hour' | 'minute', string>>
return {
weekday: parts.weekday ?? '',
hour: Number(parts.hour ?? 0),
minute: Number(parts.minute ?? 0),
}
}
const hasMarketOpenedToday = (market: string, timeZone: string) => {
const sessions = marketSessions[market.toUpperCase()]
const openMinutes = sessions?.[0]?.[0]
if (openMinutes == null) return true
const parts = marketClockParts(timeZone)
if (parts.weekday === 'Sat' || parts.weekday === 'Sun') return false
return parts.hour * 60 + parts.minute >= openMinutes
}
const isMarketSessionOpen = (market: string, timeZone: string) => {
const sessions = marketSessions[market.toUpperCase()]
if (!sessions?.length) return true
const parts = marketClockParts(timeZone)
if (parts.weekday === 'Sat' || parts.weekday === 'Sun') return false
const minutes = parts.hour * 60 + parts.minute
return sessions.some(([open, close]) => minutes >= open && minutes < close)
}
const normalizeApkOrderTermDate = (value: string) => {
const digits = value.replace(/\D/g, '')
if (digits.length === 6) return `20${digits}`
@@ -138,13 +156,13 @@ const displayApkOrderTermDate = (value: string) => {
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 markets: CashOrderMarket[] = []
for (let index = 0; index < value.length; index += 3) {
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
}
@@ -156,11 +174,12 @@ const priceMatchesStep = (price: number, step: number) => {
export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const selectedStockCode = ref('')
const selectedStockId = ref('')
const viewedStockCodes = ref<string[]>([])
const tradeSide = ref<TradeSide>('buy')
const orderKind = ref<OrderKind>('s')
const orderKind = ref<OrderKind>('standard')
const cashOrderAccountType = ref<CashOrderAccountType>('specific')
const cashOrderMarket = ref<CashOrderMarket>('STK')
const cashOrderMarket = ref<CashOrderMarket>('auto')
const cashOrderPriceCondition = ref<CashOrderPriceCondition>('market')
const cashOrderTerm = ref<CashOrderTerm>('day')
const cashOrderDateInput = ref('')
@@ -188,11 +207,13 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const sbiConnected = ref(false)
const dataLoading = ref(false)
const searchLoading = ref(false)
const totalAssetValueFromAssets = ref<number | null>(null)
const buyingPower = ref(0)
const holdingsMarketValue = ref(0)
const totalProfitLoss = ref(0)
const totalProfitLossRate = ref(0)
const orders = ref<OrderRow[]>([])
const cancelingOrderKey = ref('')
const orderHistoryLoaded = ref(false)
const orderHistoryNotice = ref('')
const positions = ref<Position[]>([])
@@ -211,6 +232,24 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const errorMessage = (cause: unknown, fallback: string) =>
cause instanceof Error ? cause.message : fallback
const stockId = (stock: Pick<Stock, 'code' | 'market'>) =>
stock.market ? `${stock.market}:${stock.code}` : stock.code
const stockRefFromId = (id: string) => {
const normalized = id.trim()
const separator = normalized.indexOf(':')
if (separator <= 0) return { code: normalized, market: '' }
const market = normalized.slice(0, separator).toUpperCase()
const code = normalized.slice(separator + 1)
if (!searchableMarkets.includes(market as CashOrderMarket)) {
return { code: normalized, market: '' }
}
return { code, market }
}
const codeFromStockId = (id: string) => stockRefFromId(id).code
const reportDataError = (message: string, cause?: unknown) => {
if (cause) {
console.error(`[csbie-ui] データ取得エラー: ${message}`, cause)
@@ -219,12 +258,28 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
console.error(`[csbie-ui] データ取得エラー: ${message}`)
}
const selectedStock = computed(
() =>
stocks.value.find((stock) => stock.code === selectedStockCode.value) ??
stocks.value[0] ??
emptyStock,
)
const selectedStock = computed(() => {
const selectedRef = stockRefFromId(selectedStockId.value)
const exact =
stockById.value.get(selectedStockId.value) ??
stocks.value.find((stock) => stock.symbol === selectedStockId.value)
if (exact) return exact
if (!selectedRef.market) {
const codeMatch = stockByCode.value.get(selectedStockCode.value)
if (codeMatch) return codeMatch
}
if (selectedStockCode.value) {
return stockFromIssue({
code: selectedStockCode.value,
market: selectedRef.market,
name: selectedStockCode.value,
})
}
return stocks.value[0] ?? emptyStock
})
const socketReady = computed(() => ws.value?.readyState === WebSocket.OPEN)
const connected = computed(() => sbiConnected.value && socketReady.value)
const orderQuantity = computed(() => Number(quantityInput.value || 0))
@@ -237,14 +292,21 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
)
const cashOrderTriggerPrice = computed(() => Number(cashOrderTriggerPriceInput.value || 0))
const cashOrderSecondaryPrice = computed(() => Number(cashOrderSecondaryPriceInput.value || 0))
const selectedStockIsUs = computed(() => usMarkets.has(selectedStock.value.market))
const resolvedCashOrderMarket = computed(() => {
if (orderKind.value === 's') return 'STK'
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(() => {
if (orderKind.value !== 's') return true
const sKabu = cashPreOrder.value ? asRecord(cashPreOrder.value.sKabu) : null
return sKabu?.available !== false
return true
})
const cashOrderIppanMarginPaymentLimit = computed(() => {
const margin = cashPreOrder.value ? asRecord(cashPreOrder.value.margin) : {}
@@ -270,9 +332,17 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
return options
})
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)
if (!exchangeList) return defaultCashOrderMarketOptions
if (!exchangeList) {
return defaultCashOrderMarketOptions.filter(
(option) => option.value === 'auto' || option.value === 'XTKS',
)
}
const markets = parseApkExchangeMarkets(exchangeList)
const options = markets
.map((market) => defaultCashOrderMarketOptions.find((option) => option.value === market))
@@ -280,7 +350,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
return options.length ? options : defaultCashOrderMarketOptions
})
const preferredCashOrderMarket = computed(() => {
if (orderKind.value === 's') return 'STK'
if (selectedStockIsUs.value) return 'auto'
const index = Number(textValue(cashPreOrder.value?.exchangeListIndexFlag))
const indexedOption =
Number.isInteger(index) && index > 0 ? cashOrderMarketOptions.value[index - 1] : undefined
@@ -303,7 +373,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
.filter(Boolean),
)
const cashOrderTermOptions = computed<CashOrderTermOption[]>(() => {
if (orderKind.value === 's') return [{ label: '当日中', value: 'day' }]
if (selectedStockIsUs.value) return [{ label: '当日中', value: 'day' }]
const terms = apkOrderTerms.value
if (!terms.length) {
return [
@@ -360,52 +430,36 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const cashOrderKey = computed(() =>
JSON.stringify({
issueCode: selectedStock.value.code,
market: resolvedCashOrderMarket.value,
preOrderMarket: orderKind.value === 's' ? selectedStock.value.market : undefined,
market: cashOrderRequestMarket.value,
side: tradeSide.value,
quantity: orderQuantity.value,
kind: orderKind.value,
kind: cashOrderKind.value,
preOrderMarket: cashOrderPreOrderMarket.value,
accountType: cashOrderAccountType.value,
depositType: cashOrderAccountType.value,
priceCondition: orderKind.value === 's' ? undefined : cashOrderPriceCondition.value,
price:
orderKind.value !== 's' && cashOrderPrimaryRequiresPrice.value
? orderPrice.value
: undefined,
orderTerm: orderKind.value === 's' ? undefined : cashOrderTerm.value,
orderDate:
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,
priceCondition: cashOrderPriceCondition.value,
price: cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined,
orderTerm: cashOrderTerm.value,
orderDate: cashOrderTerm.value === 'date' ? cashOrderDateInput.value : undefined,
orderMethod: cashOrderMethod.value,
triggerZone: cashOrderMethod.value !== 'normal' ? cashOrderTriggerZone.value : undefined,
triggerPrice: cashOrderMethod.value !== 'normal' ? cashOrderTriggerPrice.value : undefined,
secondaryPriceCondition:
orderKind.value !== 's' && cashOrderMethod.value === 'oco'
? cashOrderSecondaryPriceCondition.value
: undefined,
cashOrderMethod.value === 'oco' ? cashOrderSecondaryPriceCondition.value : undefined,
secondaryPrice:
orderKind.value !== 's' &&
cashOrderMethod.value === 'oco' &&
cashOrderSecondaryRequiresPrice.value
cashOrderMethod.value === 'oco' && cashOrderSecondaryRequiresPrice.value
? cashOrderSecondaryPrice.value
: undefined,
ippanMarginPaymentLimit:
orderKind.value !== 's' ? cashOrderIppanMarginPaymentLimit.value : undefined,
ippanMarginPaymentLimit: cashOrderIppanMarginPaymentLimit.value,
}),
)
const canRequestCashEstimate = computed(() => {
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 (orderKind.value === 's') return true
if (cashOrderPrimaryRequiresPrice.value && orderPrice.value <= 0) return false
if (selectedStockIsUs.value && cashOrderMethod.value !== 'normal') return false
if (
cashOrderPrimaryRequiresPrice.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 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 viewedStocks = computed(() =>
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)),
)
const filteredStocks = computed(() => {
@@ -459,22 +517,37 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const matchesMarket = marketFilter.value === 'all' || stock.market === marketFilter.value
return matchesCountry && matchesMarket
}
const baseStocks = query
? stocks.value.filter((stock) => {
const matchesQuery =
stock.name.toLowerCase().includes(query) ||
stock.code.includes(query) ||
stock.symbol.toLowerCase().includes(query)
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 matchesQuery =
stock.name.toLowerCase().includes(query) ||
stock.code.includes(query) ||
stock.symbol.toLowerCase().includes(query)
return matchesQuery && matchesFilters(stock)
})
const exists = baseStocks.some((stock) => stockId(stock) === selectedStockId.value)
const selected =
stockById.value.get(selectedStockId.value) ?? stockByCode.value.get(selectedStockCode.value)
if (!exists && selected && matchesFilters(selected)) {
return [selected, ...baseStocks]
}
return baseStocks
})
const selectedPosition = computed(() =>
positions.value.find((position) => position.code === selectedStock.value.code),
positions.value.find(
(position) =>
position.code === selectedStock.value.code &&
position.market === selectedStock.value.market,
),
)
const recentOrders = computed(() => orders.value.slice(0, 2))
const totalAssetValue = computed(() => holdingsMarketValue.value + buyingPower.value)
const totalAssetValue = computed(
() => totalAssetValueFromAssets.value ?? holdingsMarketValue.value + buyingPower.value,
)
const stockAssetRatio = computed(() => {
if (!totalAssetValue.value) return 0
return (holdingsMarketValue.value / totalAssetValue.value) * 100
@@ -486,15 +559,41 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const selectedStockTimeZone = computed(() => timeZoneForStock(selectedStock.value))
const chartPricePoints = computed(() => {
const points = [...historicalPricePoints.value, ...realtimePricePoints.value]
if (chartRange.value !== '1D') return points
const timeZone = selectedStockTimeZone.value
if (chartRange.value !== '1D') {
return points
}
if (!timeZone) return points
const start = startOfMarketDateUtcMs(timeZone)
return points.filter((point) => {
const keyForPoint = (point: RealtimePricePoint) => {
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) => {
@@ -504,7 +603,19 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const selectStock = (stock: Stock) => {
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
lastCashEstimate.value = null
lastCashEstimateKey.value = ''
@@ -546,6 +657,11 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
if (stock.code && stock.code !== selectedStock.value.code) return
mergeStocks([stock])
const timeZone = selectedStockTimeZone.value
if (!timeZone || !isMarketSessionOpen(stock.market, timeZone)) {
stopBoardPolling()
return
}
appendRealtimePricePoint(stock.price)
}
@@ -617,18 +733,20 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
},
reject: (reason) => {
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 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) {
if (!stock.code) continue
const current = merged.get(stock.code)
merged.set(stock.code, {
const id = stockId(stock)
const current = merged.get(id)
merged.set(id, {
...current,
...stock,
name: stock.name || current?.name || stock.code,
@@ -644,7 +762,8 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const nextCode = stocks.value[0]?.code ?? ''
if (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([
rpcCallOptional<RecordLike>('orders.inquiry.open'),
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') {
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 () => {
dataLoading.value = true
try {
const cashPositions = await rpcCallOptional<RecordLike>(
'account.positions.cash',
undefined,
15_000,
)
const nextPositions = asArray(cashPositions.positions)
const [assetsResult, ...positionResults] = await Promise.allSettled([
rpcCallOptional<RecordLike>('account.assets.current', undefined, 20_000),
rpcCallOptional<RecordLike>('account.positions.cash', undefined, 15_000),
rpcCallOptional<RecordLike>('account.positions.cash', { market: 'XNAS' }, 15_000),
])
const cashPositionLists = fulfilledValues(positionResults)
const cashPositions = cashPositionLists[0] ?? {}
const nextPositions = cashPositionLists
.flatMap((list) => asArray(list.positions))
.map(positionFromApi)
.filter((position): position is Position => Boolean(position))
positions.value = nextPositions
@@ -697,12 +843,23 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
totalProfitLoss.value = numberValue(cashPositions.totalProfitLoss)
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([
loadOrderHistoryFromSdk(),
rpcCallOptional<RecordLike>('account.power.buyingPower'),
])
if (powerResult.status === 'fulfilled') {
if (!hasAccountAssets && powerResult.status === 'fulfilled') {
buyingPower.value = numberValue(
powerResult.value.cashBuyingPower ?? powerResult.value.withdrawableAmount,
)
@@ -720,6 +877,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
'market.issue.board',
{
issueCode: position.code,
market: position.market,
},
8_000,
),
@@ -795,13 +953,16 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const stock = selectedStock.value
if (!connected.value || !stock.code) return
const timeZone = selectedStockTimeZone.value
if (!timeZone || !isMarketSessionOpen(stock.market, timeZone)) return
appendRealtimePricePoint(stock.price)
const requestId = ++boardPollingRequestId
try {
const subscribed = await rpcCall<RecordLike>('market.issue.pollBoard.subscribe', {
issueCode: stock.code,
market: stock.market || undefined,
market: stock.market,
})
if (requestId !== boardPollingRequestId) {
const staleSubscriptionId = textValue(subscribed.subscriptionId)
@@ -835,7 +996,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
}
const chart = await rpcCall<RecordLike>('market.issue.chart', {
issueCode: stock.code,
market: stock.market || undefined,
market: stock.market,
period: chartOptions.period,
unit: chartOptions.unit,
count: chartOptions.count,
@@ -852,8 +1013,17 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const searchIssues = async (query: string) => {
if (!connected.value || query.trim().length < 2) return
const result = await rpcCall<RecordLike>('market.issue.search', { query, limit: 12 })
const issues = asArray(result.issues)
const marketsToSearch =
marketFilter.value !== 'all'
? [marketFilter.value as CashOrderMarket]
: searchableMarkets.filter((market) => market !== 'auto')
const results = await Promise.allSettled(
marketsToSearch.map((market) =>
rpcCall<RecordLike>('market.issue.search', { query, market, limit: 12 }),
),
)
const issues = fulfilledValues(results)
.flatMap((result) => asArray(result.issues))
.map(issueFrom)
.filter((issue) => issue.code)
mergeStocks(issues.map(stockFromIssue))
@@ -861,7 +1031,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
issues.map((issue) =>
rpcCall<RecordLike>('market.issue.board', {
issueCode: issue.code,
market: issue.market || undefined,
market: issue.market,
}).then((board) => stockFromBoard(board, issue)),
),
)
@@ -887,42 +1057,27 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const cashOrderParams = () => ({
issueCode: selectedStock.value.code,
market: resolvedCashOrderMarket.value || undefined,
preOrderMarket: orderKind.value === 's' ? selectedStock.value.market || undefined : undefined,
market: cashOrderRequestMarket.value,
side: tradeSide.value,
quantity: orderQuantity.value,
kind: orderKind.value === 's' ? 's' : undefined,
kind: cashOrderKind.value,
preOrderMarket: cashOrderPreOrderMarket.value,
accountType: cashOrderAccountType.value,
depositType: cashOrderAccountType.value,
price:
orderKind.value !== 's' && cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined,
priceCondition: orderKind.value !== 's' ? cashOrderPriceCondition.value : undefined,
orderTerm: orderKind.value !== 's' ? cashOrderTerm.value : undefined,
orderDate:
orderKind.value !== 's' && cashOrderTerm.value === 'date'
? cashOrderDateInput.value
: 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,
price: cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined,
priceCondition: cashOrderPriceCondition.value,
orderTerm: cashOrderTerm.value,
orderDate: cashOrderTerm.value === 'date' ? cashOrderDateInput.value : undefined,
orderMethod: cashOrderMethod.value,
triggerZone: cashOrderMethod.value !== 'normal' ? cashOrderTriggerZone.value : undefined,
triggerPrice: cashOrderMethod.value !== 'normal' ? cashOrderTriggerPrice.value : undefined,
secondaryPriceCondition:
orderKind.value !== 's' && cashOrderMethod.value === 'oco'
? cashOrderSecondaryPriceCondition.value
: undefined,
cashOrderMethod.value === 'oco' ? cashOrderSecondaryPriceCondition.value : undefined,
secondaryPrice:
orderKind.value !== 's' &&
cashOrderMethod.value === 'oco' &&
cashOrderSecondaryRequiresPrice.value
cashOrderMethod.value === 'oco' && cashOrderSecondaryRequiresPrice.value
? cashOrderSecondaryPrice.value
: undefined,
ippanMarginPaymentLimit:
orderKind.value !== 's' ? cashOrderIppanMarginPaymentLimit.value : undefined,
ippanMarginPaymentLimit: cashOrderIppanMarginPaymentLimit.value,
})
const refreshCashPreOrder = async () => {
@@ -934,11 +1089,10 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
try {
const preOrder = await rpcCall<RecordLike>('orders.cash.preOrder', {
issueCode: selectedStock.value.code,
market: resolvedCashOrderMarket.value,
preOrderMarket:
orderKind.value === 's' ? selectedStock.value.market || undefined : undefined,
market: cashOrderRequestMarket.value,
side: tradeSide.value,
kind: orderKind.value === 's' ? 's' : undefined,
kind: cashOrderKind.value,
preOrderMarket: cashOrderPreOrderMarket.value,
accountType: cashOrderAccountType.value,
depositType: cashOrderAccountType.value,
})
@@ -969,6 +1123,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
id: textValue(receipt.orderId, `ord-${Date.now()}`),
date: textValue(receipt.acceptedAt, new Date().toLocaleString('ja-JP')),
stock: selectedStock.value.name,
market: selectedStock.value.market,
side: tradeSide.value,
kind: orderKind.value,
quantity: orderQuantity.value,
@@ -982,14 +1137,30 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
}
const cancelOrder = async (order: OrderRow) => {
await rpcCall('orders.cash.placeCancel', {
orderNumber: order.orderNumber || order.id,
orderId: order.id,
tradeId: order.tradeId || undefined,
allowTrading: true,
})
order.status = '取消済'
await loadTradingData()
if (!order.orderNumber) {
reportDataError('注文番号を取得できないため取消できません')
return
}
const key = orderHistoryKey(order)
if (cancelingOrderKey.value) return
cancelingOrderKey.value = key
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 = () => {
@@ -1026,21 +1197,15 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
let searchTimer: ReturnType<typeof setTimeout> | undefined
let searchRequestId = 0
watch(orderKind, (kind) => {
if (kind === 's') {
cashOrderMarket.value = 'STK'
cashOrderPriceCondition.value = 'market'
cashOrderTerm.value = 'day'
cashOrderDateInput.value = ''
cashOrderMethod.value = 'normal'
cashOrderTriggerPriceInput.value = ''
cashOrderSecondaryPriceInput.value = ''
priceInput.value = ''
return
}
if (cashOrderMarket.value === 'STK') {
cashOrderMarket.value = 'auto'
}
watch(orderKind, () => {
cashOrderMarket.value = 'auto'
cashOrderPriceCondition.value = 'market'
cashOrderTerm.value = 'day'
cashOrderDateInput.value = ''
cashOrderMethod.value = 'normal'
cashOrderTriggerPriceInput.value = ''
cashOrderSecondaryPriceInput.value = ''
priceInput.value = ''
})
watch(cashOrderPriceCondition, (condition) => {
@@ -1141,6 +1306,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
return {
selectedStockCode,
selectedStockId,
tradeSide,
orderKind,
cashOrderAccountType,
@@ -1172,6 +1338,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
totalProfitLoss,
totalProfitLossRate,
orders,
cancelingOrderKey,
orderHistoryLoaded,
orderHistoryNotice,
positions,
@@ -1205,6 +1372,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
cashAssetRatio,
hasQuote,
selectStock,
selectStockByCode,
connect,
loadTradingData,
estimateCashOrder,
+6 -2
View File
@@ -10,11 +10,15 @@ export const router = createRouter({
routes: [
{ path: '/', redirect: '/portfolio' },
{ 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: '/api-keys', 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: '/:pathMatch(.*)*', redirect: '/portfolio' },
],
+12 -6
View File
@@ -69,12 +69,15 @@ export const ui = {
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]',
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]',
watchRowActive: 'border-[#c3c7cf] bg-[#242930]',
centerStack: 'order-1 col-span-3 grid content-start gap-5',
'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: 'bg-gray-900',
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',
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',
stockHoldingNote:
'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',
smallTab: 'min-h-8 rounded-full bg-transparent px-3 text-xs font-bold text-[#9aa0a9]',
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]',
boxplot: 'grid min-h-36 content-center gap-5 rounded-[24px] bg-[#111418] p-7',
boxplotScale: 'flex justify-between text-xs text-[#8f949d]',
@@ -102,6 +105,7 @@ export const ui = {
infoTabButtonActive: 'text-[#d3e3fd]',
infoTabIndicator: 'absolute inset-x-0 bottom-[-1px] block h-0.5 rounded-full bg-[#d3e3fd]',
infoTabBody: 'overflow-hidden pt-4',
infoTabBodyInner: 'relative min-h-[9.5rem]',
detailGrid: 'grid grid-cols-3 gap-3',
detailItem: 'grid min-h-16 gap-1 rounded-[14px] bg-[#111418] p-3',
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]',
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]',
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',
confirmRow: 'flex justify-between gap-3 border-b border-[#33383f] pb-2',
dialogNote: 'leading-7 text-[#c3c7cf]',
+3 -2
View File
@@ -1,7 +1,7 @@
export type TradeSide = 'buy' | 'sell'
export type OrderKind = 'standard' | 's'
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 =
| 'limit'
| 'limitAtOpen'
@@ -16,7 +16,7 @@ export type CashOrderTerm = 'day' | 'week' | 'date'
export type CashOrderMethod = 'normal' | 'stop' | 'oco'
export type CashOrderTriggerZone = 'above' | 'below'
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 = {
id: number
@@ -91,6 +91,7 @@ export type OrderRow = {
id: string
date: string
stock: string
market: string
side: TradeSide
kind: OrderKind
quantity: number | null
+16 -5
View File
@@ -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', {
style: 'currency',
currency: 'JPY',
maximumFractionDigits: 0,
currency: currencyCode,
maximumFractionDigits: currencyCode === 'JPY' ? 0 : 3,
}).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 signedCurrency = (value: number) =>
`${value >= 0 ? '+' : '-'}${currency(Math.abs(value))}`
export const signedCurrency = (value: number, currencyCode: CurrencyCode = 'JPY') =>
`${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) =>
`${value >= 0 ? '+' : ''}${value.toFixed(fractionDigits)}%`