first commit

This commit is contained in:
Shotaro Nakamura
2026-06-18 22:59:53 +09:00
commit 20434dcd11
88 changed files with 13950 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>CSBIE</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@repo/csbie-ui",
"private": true,
"type": "module",
"scripts": {
"clean": "rm -rf dist",
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@simplewebauthn/browser": "^13.1.2",
"lucide-vue-next": "^1.0.0",
"motion-v": "^2.3.0",
"tailwindcss": "^4.1.10",
"vite": "^8",
"vue": "^3.5.16",
"vue-router": "^5.1.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "^5",
"vue-tsc": "^3"
}
}
+310
View File
@@ -0,0 +1,310 @@
<script setup lang="ts">
import { AnimatePresence } from 'motion-v'
import { computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { revokeApiKey } from './api'
import AppHeader from './components/layout/AppHeader.vue'
import AppSidebar from './components/layout/AppSidebar.vue'
import SearchDialog from './features/search/SearchDialog.vue'
import AuthGate from './features/auth/AuthGate.vue'
import { useAuthAdmin } from './features/auth/useAuthAdmin'
import HistoryView from './features/history/HistoryView.vue'
import OAuthApprovalPanel from './features/oauth/OAuthApprovalPanel.vue'
import { useOAuthApproval } from './features/oauth/useOAuthApproval'
import OrderDialogs from './features/orders/OrderDialogs.vue'
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 { routeNames, type RouteName } from './router'
import { ui } from './styles/ui'
const route = useRoute()
const router = useRouter()
const activeTab = computed<RouteName>(() => {
return routeNames.includes(route.name as RouteName) ? (route.name as RouteName) : 'portfolio'
})
const showAuthGate = computed(() => true)
const navigate = (name: RouteName) => router.push({ name })
const {
status,
apiKeys,
sbiPasskeys,
selectedPasskeyId,
setupPassword,
authBusy,
apiKeyLabel,
newApiKeySettings,
newApiToken,
sbiLabel,
sbiCredentialJson,
tradePassword,
sbiDeviceId,
refresh,
addApiKey,
saveApiKeySettings,
setupOwnerPasskey,
loginWithPasskey,
addSbiPasskey,
removeSbiPasskey,
} = useAuthAdmin()
const {
selectedStockCode,
tradeSide,
orderKind,
cashOrderAccountType,
cashOrderMarket,
cashOrderPriceCondition,
cashOrderTerm,
cashOrderDateInput,
cashOrderMethod,
cashOrderTriggerZone,
cashOrderTriggerPriceInput,
cashOrderSecondaryPriceCondition,
cashOrderSecondaryPriceInput,
quantityInput,
priceInput,
chartMode,
showSearch,
searchQuery,
countryFilter,
marketFilter,
showEstimateDialog,
showOrderDialog,
lastCashEstimate,
connected,
dataLoading,
searchLoading,
buyingPower,
holdingsMarketValue,
totalProfitLoss,
totalProfitLossRate,
orders,
orderHistoryLoaded,
orderHistoryNotice,
positions,
chartPricePoints,
pricePolling,
selectedStock,
orderQuantity,
orderPrice,
cashOrderPrimaryRequiresPrice,
cashOrderTriggerPrice,
cashOrderSecondaryPrice,
estimatedAmount,
showPortfolioSpinner,
canRequestCashEstimate,
canPlaceCashOrder,
countries,
markets,
viewedStocks,
filteredStocks,
selectedPosition,
recentOrders,
totalAssetValue,
stockAssetRatio,
cashAssetRatio,
boxPlotStyle,
hasQuote,
selectStock,
connect,
loadTradingData,
estimateCashOrder,
askPlaceOrder,
placeCashOrder,
cancelOrder,
downloadCsv,
openTradeForStock,
openTradeForPosition,
} = useTradingSession(selectedPasskeyId)
const { oauthApproval, oauthSettings, loadOAuthApproval, approveOAuth } = useOAuthApproval()
const refreshAndMaybeConnect = () =>
refresh({
autoConnect: true,
connect: () => {
if (!connected.value) connect()
},
})
const addSbiPasskeyAndConnect = async () => {
await addSbiPasskey()
connect()
}
const revokeAndRefresh = async (id: string) => {
await revokeApiKey(id)
await refresh()
}
onMounted(async () => {
try {
await refreshAndMaybeConnect()
} catch {
status.value = { configured: false, authenticated: false }
}
await loadOAuthApproval()
})
</script>
<template>
<main :class="ui.appShell">
<AppSidebar :active-tab="activeTab" @navigate="navigate" />
<section :class="ui.workspace">
<AppHeader :active-tab="activeTab" />
<OAuthApprovalPanel
v-if="oauthApproval.active && status.authenticated"
v-model:settings="oauthSettings"
:approval="oauthApproval"
@approve="approveOAuth"
/>
<AuthGate
v-else-if="showAuthGate && !status.authenticated"
v-model:setup-password="setupPassword"
:status="status"
:auth-busy="authBusy"
@login="loginWithPasskey"
@setup="setupOwnerPasskey"
/>
<template v-else>
<PortfolioView
v-if="activeTab === 'portfolio'"
:show-portfolio-spinner="showPortfolioSpinner"
:total-asset-value="totalAssetValue"
:buying-power="buyingPower"
:holdings-market-value="holdingsMarketValue"
:total-profit-loss="totalProfitLoss"
:total-profit-loss-rate="totalProfitLossRate"
:stock-asset-ratio="stockAssetRatio"
:cash-asset-ratio="cashAssetRatio"
:positions="positions"
:recent-orders="recentOrders"
:data-loading="dataLoading"
:connected="connected"
:order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice"
@connect="connect"
@open-position="(code) => openTradeForPosition(code, () => void navigate('trade'))"
/>
<TradeView
v-if="activeTab === 'trade'"
v-model:trade-side="tradeSide"
v-model:order-kind="orderKind"
v-model:cash-order-account-type="cashOrderAccountType"
v-model:cash-order-market="cashOrderMarket"
v-model:cash-order-price-condition="cashOrderPriceCondition"
v-model:cash-order-term="cashOrderTerm"
v-model:cash-order-date-input="cashOrderDateInput"
v-model:cash-order-method="cashOrderMethod"
v-model:cash-order-trigger-zone="cashOrderTriggerZone"
v-model:cash-order-trigger-price-input="cashOrderTriggerPriceInput"
v-model:cash-order-secondary-price-condition="cashOrderSecondaryPriceCondition"
v-model:cash-order-secondary-price-input="cashOrderSecondaryPriceInput"
v-model:quantity-input="quantityInput"
v-model:price-input="priceInput"
v-model:chart-mode="chartMode"
:viewed-stocks="viewedStocks"
:selected-stock="selectedStock"
:selected-position="selectedPosition"
:connected="connected"
:order-quantity="orderQuantity"
:estimated-amount="estimatedAmount"
:can-request-cash-estimate="canRequestCashEstimate"
:can-place-cash-order="canPlaceCashOrder"
:realtime-price-points="chartPricePoints"
:price-polling="pricePolling"
:box-plot-style="boxPlotStyle"
:has-quote="hasQuote"
@open-search="showSearch = true"
@select-stock="selectStock"
@download-csv="downloadCsv"
@estimate="estimateCashOrder"
@confirm-order="askPlaceOrder"
/>
<HistoryView
v-if="activeTab === 'history'"
:orders="orders"
:connected="connected"
:data-loading="dataLoading"
:order-history-loaded="orderHistoryLoaded"
:order-history-notice="orderHistoryNotice"
@refresh="loadTradingData"
@cancel="cancelOrder"
/>
<SettingsView
v-if="activeTab === 'settings'"
v-model:api-key-label="apiKeyLabel"
v-model:new-api-key-settings="newApiKeySettings"
v-model:new-api-token="newApiToken"
v-model:sbi-label="sbiLabel"
v-model:sbi-credential-json="sbiCredentialJson"
v-model:trade-password="tradePassword"
v-model:sbi-device-id="sbiDeviceId"
v-model:selected-passkey-id="selectedPasskeyId"
:api-keys="apiKeys"
:sbi-passkeys="sbiPasskeys"
@add-api-key="addApiKey"
@refresh="refresh"
@save-api-key-settings="saveApiKeySettings"
@revoke-api-key="revokeAndRefresh"
@add-sbi-passkey="addSbiPasskeyAndConnect"
@connect="connect"
@remove-sbi-passkey="removeSbiPasskey"
/>
</template>
</section>
<AnimatePresence>
<SearchDialog
v-if="showSearch"
key="search-dialog"
v-model:search-query="searchQuery"
v-model:country-filter="countryFilter"
v-model:market-filter="marketFilter"
:stocks="filteredStocks"
:selected-stock-code="selectedStockCode"
:countries="countries"
:markets="markets"
:loading="searchLoading"
@close="showSearch = false"
@select="(stock) => openTradeForStock(stock, () => void navigate('trade'))"
/>
</AnimatePresence>
<OrderDialogs
:estimate="lastCashEstimate"
:show-estimate="showEstimateDialog"
:show-order="showOrderDialog"
:stock-name="selectedStock.name"
:side="tradeSide"
:kind="orderKind"
:account-type="cashOrderAccountType"
:market="cashOrderMarket"
:price-condition="cashOrderPriceCondition"
:price="cashOrderPrimaryRequiresPrice ? orderPrice : 0"
:order-term="cashOrderTerm"
:order-date="cashOrderDateInput"
:order-method="cashOrderMethod"
:trigger-zone="cashOrderTriggerZone"
:trigger-price="cashOrderTriggerPrice"
:secondary-price-condition="cashOrderSecondaryPriceCondition"
:secondary-price="cashOrderSecondaryPrice"
:quantity="orderQuantity"
:amount="estimatedAmount"
@close-estimate="showEstimateDialog = false"
@proceed="askPlaceOrder"
@close-order="showOrderDialog = false"
@place="placeCashOrder"
/>
</main>
</template>
+112
View File
@@ -0,0 +1,112 @@
export type AuthStatus = {
configured: boolean
authenticated: boolean
}
export type ApiKey = {
id: string
label: string
maxTradesPerHour?: number | null
maxTradesPer6Hours?: number | null
maxTradesPerDay?: number | null
maxOrderPriceJpy?: number | null
maxOrderAmountJpy?: number | null
allowedMethods?: string[] | null
createdAt: string
lastUsedAt?: string | null
revokedAt?: string | null
token?: string
}
export type ApiKeySettings = Pick<
ApiKey,
| 'maxTradesPerHour'
| 'maxTradesPer6Hours'
| 'maxTradesPerDay'
| 'maxOrderPriceJpy'
| 'maxOrderAmountJpy'
| 'allowedMethods'
>
export type SbiPasskey = {
id: string
label: string
createdAt: string
updatedAt: string
}
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const response = await fetch(`/api${path}`, {
credentials: 'include',
headers: {
'content-type': 'application/json',
...init?.headers,
},
...init,
})
if (!response.ok) throw new Error((await response.text()) || response.statusText)
return response.json() as Promise<T>
}
export const getStatus = () => request<AuthStatus>('/auth/status')
export const createSetupOptions = (password: string) =>
request<{ options: unknown; challengeId: string }>('/auth/setup/options', {
method: 'POST',
body: JSON.stringify({ password }),
})
export const verifySetup = (challengeId: string, response: unknown) =>
request<{ ok: true }>('/auth/setup/verify', {
method: 'POST',
body: JSON.stringify({ challengeId, response }),
})
export const createLoginOptions = () =>
request<{ options: unknown; challengeId: string }>('/auth/login/options', {
method: 'POST',
})
export const verifyLogin = (challengeId: string, response: unknown) =>
request<{ ok: true }>('/auth/login/verify', {
method: 'POST',
body: JSON.stringify({ challengeId, response }),
})
export const createApiKey = (label: string, settings?: ApiKeySettings) =>
request<{ apiKey: ApiKey }>('/admin/api-keys', {
method: 'POST',
body: JSON.stringify({ label, settings }),
})
export const listApiKeys = () => request<{ apiKeys: ApiKey[] }>('/admin/api-keys')
export const updateApiKeySettings = (id: string, settings: ApiKeySettings) =>
request<{ ok: true }>(`/admin/api-keys/${id}/settings`, {
method: 'PATCH',
body: JSON.stringify(settings),
})
export const revokeApiKey = (id: string) =>
request<{ ok: true }>(`/admin/api-keys/${id}`, { method: 'DELETE' })
export const listSbiPasskeys = () => request<{ passkeys: SbiPasskey[] }>('/admin/sbi-passkeys')
export const saveSbiPasskey = (payload: {
label: string
credential: unknown
tradePassword?: string
deviceId?: string
}) =>
request<{ passkey: SbiPasskey }>('/admin/sbi-passkeys', {
method: 'POST',
body: JSON.stringify(payload),
})
export const deleteSbiPasskey = (id: string) =>
request<{ ok: true }>(`/admin/sbi-passkeys/${id}`, { method: 'DELETE' })
export const createRpcSocket = () => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
return new WebSocket(`${protocol}//${location.host}/api/ws`)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

@@ -0,0 +1,185 @@
<script setup lang="ts">
import type { ApiKeySettings } from '../api'
defineProps<{
compact?: boolean
permissionsOpen?: boolean
}>()
const settings = defineModel<ApiKeySettings>({ required: true })
const rpcMethods = [
'session.profile',
'account.profile',
'account.power.buyingPower',
'account.power.collateralRatio',
'account.positions.cash',
'account.positions.cashDetail',
'account.positions.cashForIssue',
'account.positions.margin',
'account.positions.marginDetail',
'account.positions.marginForIssue',
'account.positions.marginSummaryForIssue',
'account.positions.marginDetailsForIssue',
'account.positions.closeableMargin',
'account.positions.deliverableMargin',
'account.profitLoss.unrealized',
'market.issue.search',
'market.issue.suggest',
'market.issue.allowedPrices',
'market.issue.board',
'market.issue.chart',
'market.issue.openOrders',
'market.issue.tradingInfo',
'market.index.major',
'market.overview',
'market.ranking.market',
'market.ranking.sector',
'market.ranking.sbi',
'news.list',
'watchlist.list',
'orders.inquiry.executionsToday',
'orders.inquiry.open',
'orders.cash.estimate',
'orders.cash.place',
'orders.cash.estimateCorrection',
'orders.cash.estimateCorrectionConfirm',
'orders.cash.placeCorrection',
'orders.cash.estimateCancel',
'orders.cash.placeCancel',
'orders.margin.estimateOpen',
'orders.margin.open',
'orders.margin.estimateClose',
'orders.margin.close',
'orders.margin.estimateCloseSummary',
'orders.margin.closeSummary',
'orders.margin.estimateSummary',
'orders.margin.placeSummary',
'orders.margin.estimateActualDelivery',
'orders.margin.actualDelivery',
'orders.ifd.estimate',
'orders.ifd.place',
'orders.ifd.estimateCorrection',
'orders.ifd.placeCorrection',
'orders.ifd.estimateCancel',
'orders.ifd.placeCancel',
'orders.themeInvestment.list',
'orders.themeInvestment.estimate',
'orders.themeInvestment.place',
] as const
const tradingMethods = [
'orders.cash.place',
'orders.cash.placeCorrection',
'orders.cash.placeCancel',
'orders.margin.open',
'orders.margin.close',
'orders.margin.closeSummary',
'orders.margin.placeSummary',
'orders.margin.actualDelivery',
'orders.ifd.place',
'orders.ifd.placeCorrection',
'orders.ifd.placeCancel',
'orders.themeInvestment.place',
] as const
const tradingMethodSet = new Set<string>(tradingMethods)
const readMethods = rpcMethods.filter((method) => !tradingMethodSet.has(method))
const setMethods = (methods: readonly string[] | null) => {
settings.value.allowedMethods = methods ? [...methods] : null
}
const toggleMethod = (method: string) => {
const current = settings.value.allowedMethods ?? [...rpcMethods]
settings.value.allowedMethods = current.includes(method)
? current.filter((candidate) => candidate !== method)
: [...current, method].sort()
}
const methodAllowed = (method: string) => {
return settings.value.allowedMethods == null || settings.value.allowedMethods.includes(method)
}
const ui = {
root: 'grid gap-3',
limitGrid: 'grid grid-cols-3 gap-3',
limitGridCompact: 'grid-cols-5',
label: 'grid gap-2 text-xs font-extrabold text-[#9aa0a9]',
input:
'min-h-12 w-full rounded-[16px] border border-[#4a5058] bg-[#111418] px-4 text-[#e3e3e9] outline-none transition focus:border-[#a8c7fa]',
permissions: 'border-t border-[#33383f] pt-3',
summary: 'cursor-pointer font-black text-[#e3e3e9]',
actions: 'mt-3 flex flex-wrap gap-2',
button:
'min-h-9 rounded-full bg-[#263141] px-4 text-sm font-extrabold text-[#d3e3fd] transition hover:bg-[#303b4d]',
methodGrid: 'mt-3 grid grid-cols-2 gap-2',
methodToggle:
'flex min-h-10 items-center gap-2 rounded-[16px] bg-[#111418] p-3 text-xs text-[#c3c7cf]',
checkbox: 'h-4 min-h-4 w-4',
}
</script>
<template>
<div :class="ui.root">
<div :class="[ui.limitGrid, compact && ui.limitGridCompact]">
<label :class="ui.label">
1時間 取引上限
<input v-model.number="settings.maxTradesPerHour" :class="ui.input" type="number" min="0" />
</label>
<label :class="ui.label">
3時間 取引上限
<input
v-model.number="settings.maxTradesPer6Hours"
:class="ui.input"
type="number"
min="0"
/>
</label>
<label :class="ui.label">
1日 取引上限
<input v-model.number="settings.maxTradesPerDay" :class="ui.input" type="number" min="0" />
</label>
<label :class="ui.label">
1注文 最大価格
<input v-model.number="settings.maxOrderPriceJpy" :class="ui.input" type="number" min="0" />
</label>
<label :class="ui.label">
1注文 最大取引価格
<input
v-model.number="settings.maxOrderAmountJpy"
:class="ui.input"
type="number"
min="0"
/>
</label>
</div>
<details :class="ui.permissions" :open="permissionsOpen">
<summary :class="ui.summary">
権限:
{{
settings.allowedMethods == null ? 'すべて' : `${settings.allowedMethods.length} methods`
}}
</summary>
<div :class="ui.actions">
<button :class="ui.button" type="button" @click="setMethods(null)">すべて</button>
<button :class="ui.button" type="button" @click="setMethods(readMethods)">参照のみ</button>
<button :class="ui.button" type="button" @click="setMethods(tradingMethods)">
取引のみ
</button>
</div>
<div :class="ui.methodGrid">
<label v-for="method in rpcMethods" :key="method" :class="ui.methodToggle">
<input
:class="ui.checkbox"
type="checkbox"
:checked="methodAllowed(method)"
@change="toggleMethod(method)"
/>
<span>{{ method }}</span>
</label>
</div>
</details>
</div>
</template>
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { computed } from 'vue'
import { pageMeta } from '../../constants/nav'
import { ui } from '../../styles/ui'
import type { RouteName } from '../../router'
const props = defineProps<{
activeTab: RouteName
}>()
const meta = computed(() => pageMeta[props.activeTab])
</script>
<template>
<header :class="[ui.topbar, activeTab === 'trade' && ui.tradeTopbar]">
<div class="flex items-center gap-3">
<span
class="grid h-11 w-11 place-items-center rounded-2xl bg-[#263141] text-[#d3e3fd]"
aria-hidden="true"
>
<component :is="meta.icon" class="h-5 w-5" :stroke-width="2.4" />
</span>
<div>
<h1 class="text-3xl font-black tracking-normal text-[#e3e3e9]">
{{ meta.title }}
</h1>
</div>
</div>
</header>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { Activity } from 'lucide-vue-next'
import { sidebarItems } from '../../constants/nav'
import { ui } from '../../styles/ui'
import type { RouteName } from '../../router'
defineProps<{
activeTab: RouteName
}>()
const emit = defineEmits<{
navigate: [name: RouteName]
}>()
const items = sidebarItems
</script>
<template>
<aside :class="ui.sidebar">
<div :class="ui.brandMark" aria-label="CSBIE">
<Activity class="h-8 w-8" :stroke-width="2.75" aria-hidden="true" />
</div>
<nav :class="ui.navStack" aria-label="Main">
<button
v-for="item in items"
:key="item.name"
:class="[ui.navButton, activeTab === item.name && ui.navButtonActive]"
type="button"
@click="emit('navigate', item.name)"
>
<span :class="[ui.navIcon, activeTab === item.name && ui.navIconActive]">
<component :is="item.icon" class="h-5 w-5" :stroke-width="2.4" aria-hidden="true" />
</span>
<span>{{ item.label }}</span>
</button>
</nav>
<div class="flex-1"></div>
</aside>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { LoaderCircle } from 'lucide-vue-next'
withDefaults(
defineProps<{
label?: string
size?: 'sm' | 'md' | 'lg'
}>(),
{
label: '読み込み中',
size: 'md',
},
)
const sizeClass = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8',
}
</script>
<template>
<LoaderCircle
:class="['inline-block animate-spin text-current', sizeClass[size]]"
role="status"
:aria-label="label"
/>
</template>
@@ -0,0 +1,43 @@
<script setup lang="ts">
withDefaults(
defineProps<{
variant?: 'primary' | 'danger' | 'ghost' | 'plain'
size?: 'sm' | 'md'
type?: 'button' | 'submit' | 'reset'
}>(),
{
variant: 'primary',
size: 'md',
type: 'button',
},
)
const variantClass = {
primary:
'bg-[#a8c7fa] text-[#102033] shadow-sm shadow-black/20 hover:bg-[#d3e3fd] active:bg-[#c2d7f4]',
danger:
'bg-[#ffb4ab] text-[#690005] shadow-sm shadow-black/20 hover:bg-[#ffd8d3] active:bg-[#ffc9c2]',
ghost:
'border border-[#4a5058] bg-transparent text-[#d3e3fd] hover:bg-[#263141] active:bg-[#303b4d]',
plain: 'bg-transparent text-[#c3c7cf] hover:bg-[#22272e] hover:text-[#e3e3e9]',
}
const sizeClass = {
sm: 'min-h-8 px-4 text-xs',
md: 'min-h-10 px-5 text-sm',
}
</script>
<template>
<button
:type="type"
:class="[
'rounded-full font-extrabold outline-none transition disabled:cursor-not-allowed disabled:opacity-50',
'inline-flex items-center justify-center gap-2',
variantClass[variant],
sizeClass[size],
]"
>
<slot />
</button>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
withDefaults(
defineProps<{
padded?: boolean
}>(),
{
padded: true,
},
)
</script>
<template>
<article
:class="[
'rounded-[28px] border border-[#30343a] bg-[#1b1f24] shadow-lg shadow-black/15',
padded && 'p-6',
]"
>
<slot />
</article>
</template>
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { nextTick, onMounted, useTemplateRef } from 'vue'
const props = withDefaults(
defineProps<{
as?: 'input' | 'textarea' | 'select'
label?: string
modelValue?: string | number
type?: string
placeholder?: string
autocomplete?: string
spellcheck?: boolean
readonly?: boolean
disabled?: boolean
min?: string | number
autofocus?: boolean
}>(),
{
as: 'input',
type: 'text',
placeholder: '',
autocomplete: undefined,
spellcheck: undefined,
readonly: false,
disabled: false,
min: undefined,
autofocus: false,
},
)
const controlRef = useTemplateRef<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
'control',
)
onMounted(() => {
if (!props.autofocus) return
nextTick(() => controlRef.value?.focus())
})
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const controlClass =
'min-h-12 w-full rounded-[16px] border border-[#4a5058] bg-[#111418] px-4 text-[#e3e3e9] outline-none transition placeholder:text-[#747982] focus:border-[#a8c7fa] disabled:cursor-not-allowed disabled:opacity-60'
</script>
<template>
<label class="grid gap-2 text-xs font-extrabold text-[#9aa0a9]">
<span v-if="label">{{ label }}</span>
<textarea
v-if="as === 'textarea'"
ref="control"
:class="[controlClass, 'min-h-32 resize-y py-3']"
:value="modelValue"
:placeholder="placeholder"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
:readonly="readonly"
:disabled="disabled"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
></textarea>
<select
v-else-if="as === 'select'"
ref="control"
:class="controlClass"
:value="modelValue"
:disabled="disabled"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
>
<slot />
</select>
<input
v-else
ref="control"
:class="controlClass"
:value="modelValue"
:type="type"
:min="min"
:placeholder="placeholder"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
:readonly="readonly"
:disabled="disabled"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</label>
</template>
@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{
title: string
eyebrow?: string
}>()
</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"
>
<p v-if="eyebrow" class="text-xs font-black uppercase text-[#9aa0a9]">{{ eyebrow }}</p>
<h2 class="text-lg font-bold text-[#e3e3e9]">{{ title }}</h2>
<slot />
</section>
</div>
</template>
@@ -0,0 +1,31 @@
<script setup lang="ts" generic="T extends string">
defineProps<{
modelValue: T
options: Array<{ label: string; value: T; tone?: 'buy' | 'sell' }>
}>()
const emit = defineEmits<{
'update:modelValue': [value: T]
}>()
</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)"
>
{{ option.label }}
</button>
</div>
</template>
+21
View File
@@ -0,0 +1,21 @@
import { ArrowLeftRight, History, Settings, WalletCards } from 'lucide-vue-next'
import type { Component } from 'vue'
import type { RouteName } from '../router'
export type PageMeta = {
title: string
icon: Component
}
export const pageMeta: Record<RouteName, PageMeta> = {
portfolio: { title: 'ポートフォリオ', icon: WalletCards },
trade: { title: '取引', icon: ArrowLeftRight },
history: { title: '取引履歴', icon: History },
settings: { title: '設定', icon: Settings },
}
export const sidebarItems: Array<{ name: RouteName; label: string; icon: Component }> = [
{ name: 'portfolio', label: '資産一覧', icon: pageMeta.portfolio.icon },
{ name: 'trade', label: '取引', icon: pageMeta.trade.icon },
{ name: 'settings', label: '設定', icon: pageMeta.settings.icon },
]
+85
View File
@@ -0,0 +1,85 @@
import type {
CashOrderAccountType,
CashOrderMarket,
CashOrderMethod,
CashOrderPriceCondition,
CashOrderTerm,
CashOrderTriggerZone,
OrderKind,
TradeSide,
} from '../types/trading'
export const tradeSideOptions: Array<{ label: string; value: TradeSide; tone: 'buy' | 'sell' }> = [
{ label: '購入', value: 'buy', tone: 'buy' },
{ label: '売却', value: 'sell', tone: 'sell' },
]
export const orderKindOptions: Array<{ label: string; value: OrderKind }> = [
{ label: '通常単元', value: 'standard' },
{ label: 'S株', value: 's' },
]
export const cashOrderAccountTypeOptions: Array<{
label: string
value: CashOrderAccountType
}> = [
{ label: '特定', value: 'specific' },
{ label: '一般', value: 'general' },
]
export const cashOrderMarketOptions: Array<{
label: string
value: CashOrderMarket
}> = [
{ label: '自動', value: 'auto' },
{ label: '東証', value: 'TKY' },
{ label: 'SOR', value: 'SOR' },
{ label: 'PTS', value: 'PTS' },
{ label: 'PTS(X)', value: 'PTX' },
]
export const sKabuOrderMarketOptions: Array<{
label: string
value: CashOrderMarket
}> = [{ label: 'S株', value: 'STK' }]
export const cashOrderPriceConditionOptions: Array<{
label: string
value: CashOrderPriceCondition
}> = [
{ label: '成行', value: 'market' },
{ label: '指値', value: 'limit' },
{ label: '寄成', value: 'marketAtOpen' },
{ label: '寄指', value: 'limitAtOpen' },
{ label: '引成', value: 'marketAtClose' },
{ label: '引指', value: 'limitAtClose' },
{ label: 'IOC成', value: 'marketIoc' },
{ label: 'IOC指', value: 'limitIoc' },
{ label: '不成', value: 'funari' },
]
export const cashOrderTermOptions: Array<{
label: string
value: CashOrderTerm
}> = [
{ label: '当日中', value: 'day' },
{ label: '今週中', value: 'week' },
{ label: '日付指定', value: 'date' },
]
export const cashOrderMethodOptions: Array<{
label: string
value: CashOrderMethod
}> = [
{ label: '通常', value: 'normal' },
{ label: '逆指値', value: 'stop' },
{ label: 'OCO', value: 'oco' },
]
export const cashOrderTriggerZoneOptions: Array<{
label: string
value: CashOrderTriggerZone
}> = [
{ label: '以上', value: 'above' },
{ label: '以下', value: 'below' },
]
@@ -0,0 +1,57 @@
<script setup lang="ts">
import { LogIn, UserPlus } from 'lucide-vue-next'
import type { AuthStatus } from '../../api'
import { ui } from '../../styles/ui'
defineProps<{
status: AuthStatus
authBusy: boolean
}>()
const setupPassword = defineModel<string>('setupPassword', { required: true })
const emit = defineEmits<{
login: []
setup: []
}>()
</script>
<template>
<section :class="ui.authPanel">
<article :class="[ui.panel, ui.loginPanel]">
<div>
<p :class="ui.eyebrow">{{ status.configured ? 'Login' : 'Initial setup' }}</p>
<h2>{{ status.configured ? 'パスキーでログイン' : '初期セットアップ' }}</h2>
</div>
<label v-if="!status.configured" :class="ui.label">
セットアップパスワード
<input
v-model="setupPassword"
:class="ui.input"
type="password"
autocomplete="current-password"
/>
</label>
<button
v-if="status.configured"
:class="ui.primaryButton"
type="button"
:disabled="authBusy"
@click="emit('login')"
>
<LogIn class="h-4 w-4" aria-hidden="true" />
ログイン
</button>
<button
v-else
:class="ui.primaryButton"
type="button"
:disabled="authBusy"
@click="emit('setup')"
>
<UserPlus class="h-4 w-4" aria-hidden="true" />
登録
</button>
</article>
</section>
</template>
@@ -0,0 +1,10 @@
import type { ApiKeySettings } from '../../api'
export const defaultApiKeyPolicy = (): ApiKeySettings => ({
maxTradesPerHour: 10,
maxTradesPer6Hours: 30,
maxTradesPerDay: 80,
maxOrderPriceJpy: null,
maxOrderAmountJpy: null,
allowedMethods: null,
})
@@ -0,0 +1,131 @@
import { startAuthentication, startRegistration } from '@simplewebauthn/browser'
import { ref } from 'vue'
import {
createApiKey,
createLoginOptions,
createSetupOptions,
deleteSbiPasskey,
getStatus,
listApiKeys,
listSbiPasskeys,
saveSbiPasskey,
updateApiKeySettings,
verifyLogin,
verifySetup,
type ApiKey,
type AuthStatus,
type SbiPasskey,
} from '../../api'
import { defaultApiKeyPolicy } from './api-key-policy'
export const useAuthAdmin = () => {
const status = ref<AuthStatus>({ configured: false, authenticated: false })
const apiKeys = ref<ApiKey[]>([])
const sbiPasskeys = ref<SbiPasskey[]>([])
const selectedPasskeyId = ref('')
const setupPassword = ref('')
const authBusy = ref(false)
const apiKeyLabel = ref('Fine-grained API key')
const newApiKeySettings = ref(defaultApiKeyPolicy())
const newApiToken = ref('')
const sbiLabel = ref('Main profile')
const sbiCredentialJson = ref('')
const tradePassword = ref('')
const sbiDeviceId = ref('')
const refresh = async (options?: { autoConnect?: boolean; connect?: () => void }) => {
status.value = await getStatus()
if (status.value.authenticated) {
apiKeys.value = (await listApiKeys()).apiKeys
sbiPasskeys.value = (await listSbiPasskeys()).passkeys
selectedPasskeyId.value ||= sbiPasskeys.value[0]?.id ?? ''
if (options?.autoConnect && selectedPasskeyId.value) options.connect?.()
}
}
const addApiKey = async () => {
const { apiKey } = await createApiKey(apiKeyLabel.value, newApiKeySettings.value)
newApiToken.value = apiKey.token ?? ''
await refresh()
}
const saveApiKeySettings = async (key: ApiKey) => {
await updateApiKeySettings(key.id, {
maxTradesPerHour: key.maxTradesPerHour,
maxTradesPer6Hours: key.maxTradesPer6Hours,
maxTradesPerDay: key.maxTradesPerDay,
maxOrderPriceJpy: key.maxOrderPriceJpy,
maxOrderAmountJpy: key.maxOrderAmountJpy,
allowedMethods: key.allowedMethods,
})
await refresh()
}
const setupOwnerPasskey = async () => {
authBusy.value = true
try {
const { options, challengeId } = await createSetupOptions(setupPassword.value)
const response = await startRegistration({ optionsJSON: options as never })
await verifySetup(challengeId, response)
setupPassword.value = ''
await refresh()
} finally {
authBusy.value = false
}
}
const loginWithPasskey = async () => {
authBusy.value = true
try {
const { options, challengeId } = await createLoginOptions()
const response = await startAuthentication({ optionsJSON: options as never })
await verifyLogin(challengeId, response)
await refresh()
} finally {
authBusy.value = false
}
}
const addSbiPasskey = async () => {
const { passkey } = await saveSbiPasskey({
label: sbiLabel.value,
credential: JSON.parse(sbiCredentialJson.value),
tradePassword: tradePassword.value || undefined,
deviceId: sbiDeviceId.value || undefined,
})
selectedPasskeyId.value = passkey.id
sbiCredentialJson.value = ''
tradePassword.value = ''
sbiDeviceId.value = ''
await refresh()
}
const removeSbiPasskey = async (id: string) => {
await deleteSbiPasskey(id)
if (selectedPasskeyId.value === id) selectedPasskeyId.value = ''
await refresh()
}
return {
status,
apiKeys,
sbiPasskeys,
selectedPasskeyId,
setupPassword,
authBusy,
apiKeyLabel,
newApiKeySettings,
newApiToken,
sbiLabel,
sbiCredentialJson,
tradePassword,
sbiDeviceId,
refresh,
addApiKey,
saveApiKeySettings,
setupOwnerPasskey,
loginWithPasskey,
addSbiPasskey,
removeSbiPasskey,
}
}
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { Ban, RefreshCw } from 'lucide-vue-next'
import Spinner from '../../components/ui/Spinner.vue'
import { ui } from '../../styles/ui'
import type { OrderRow } from '../../types/trading'
import { orderAmountText, orderQuantityText } from '../trading/trading-data'
defineProps<{
orders: OrderRow[]
connected: boolean
dataLoading: boolean
orderHistoryLoaded: boolean
orderHistoryNotice: string
}>()
const emit = defineEmits<{
refresh: []
cancel: [order: OrderRow]
}>()
</script>
<template>
<section :class="ui.panel">
<div :class="ui.panelHead">
<h2>取引履歴</h2>
<button
:class="ui.ghostButton"
type="button"
:disabled="!connected || dataLoading"
@click="emit('refresh')"
>
<RefreshCw class="h-4 w-4" aria-hidden="true" />
履歴を更新
</button>
</div>
<div :class="ui.list">
<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>
</span>
<span :class="order.side === 'buy' ? ui.positive : ui.negative">
{{ order.side === 'buy' ? '購入' : '売却' }}
</span>
<span>{{ orderQuantityText(order) }}</span>
<span>{{ orderAmountText(order) }}</span>
<span :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]">
{{ order.status }}
</span>
<button
v-if="order.status === '注文中'"
:class="ui.ghostButton"
type="button"
@click="emit('cancel', order)"
>
<Ban class="h-4 w-4" aria-hidden="true" />
取消
</button>
</div>
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
<Spinner />
</div>
<p v-else-if="!orders.length" :class="[ui.muted, 'py-8 text-center']">
{{
orderHistoryLoaded
? orderHistoryNotice
? `SBI SDK は取引履歴なしを返しました (${orderHistoryNotice})`
: '該当する注文履歴はありません'
: '注文履歴はまだ取得されていません'
}}
</p>
</div>
</section>
</template>
@@ -0,0 +1,39 @@
<script setup lang="ts">
import { ShieldCheck } from 'lucide-vue-next'
import type { ApiKeySettings } from '../../api'
import ApiKeyPolicyEditor from '../../components/ApiKeyPolicyEditor.vue'
import { ui } from '../../styles/ui'
import type { OAuthApprovalState } from './useOAuthApproval'
defineProps<{
approval: OAuthApprovalState
}>()
const settings = defineModel<ApiKeySettings>('settings', { required: true })
const emit = defineEmits<{
approve: []
}>()
</script>
<template>
<section :class="ui.authPanel">
<article :class="ui.panel">
<div :class="ui.panelHead">
<div>
<p :class="ui.eyebrow">OAuth approval</p>
<h2>{{ approval.clientName }}</h2>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('approve')">
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
承認
</button>
</div>
<label :class="ui.label">
Redirect URI
<input v-model="approval.redirectUri" :class="ui.input" readonly />
</label>
<ApiKeyPolicyEditor v-model="settings" permissions-open />
</article>
</section>
</template>
@@ -0,0 +1,78 @@
import { ref } from 'vue'
import type { ApiKeySettings } from '../../api'
import { defaultApiKeyPolicy } from '../auth/api-key-policy'
export type OAuthApprovalState = {
active: boolean
clientId: string
clientName: string
redirectUri: string
codeChallenge: string
scope: string
state: string
resource: string
}
export const useOAuthApproval = () => {
const oauthApproval = ref<OAuthApprovalState>({
active: false,
clientId: '',
clientName: 'MCP client',
redirectUri: '',
codeChallenge: '',
scope: '',
state: '',
resource: '',
})
const oauthSettings = ref<ApiKeySettings>(defaultApiKeyPolicy())
const loadOAuthApproval = async () => {
const url = new URL(location.href)
if (url.pathname !== '/oauth/authorize') return
oauthApproval.value = {
active: true,
clientId: url.searchParams.get('client_id') ?? '',
clientName: url.searchParams.get('client_id') ?? 'MCP client',
redirectUri: url.searchParams.get('redirect_uri') ?? '',
codeChallenge: url.searchParams.get('code_challenge') ?? '',
scope: url.searchParams.get('scope') ?? '',
state: url.searchParams.get('state') ?? '',
resource: url.searchParams.get('resource') ?? '',
}
if (!oauthApproval.value.clientId) return
const response = await fetch(`/api/oauth/client/${oauthApproval.value.clientId}`, {
credentials: 'include',
})
if (!response.ok) return
const { client } = (await response.json()) as { client?: { client_name?: string } }
oauthApproval.value.clientName = client?.client_name ?? oauthApproval.value.clientName
}
const approveOAuth = async () => {
const response = await fetch('/api/oauth/approve', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
clientId: oauthApproval.value.clientId,
redirectUri: oauthApproval.value.redirectUri,
codeChallenge: oauthApproval.value.codeChallenge,
scope: oauthApproval.value.scope,
state: oauthApproval.value.state,
resource: oauthApproval.value.resource || undefined,
settings: oauthSettings.value,
}),
})
if (!response.ok) throw new Error(await response.text())
const { redirectTo } = (await response.json()) as { redirectTo: string }
location.href = redirectTo
}
return {
oauthApproval,
oauthSettings,
loadOAuthApproval,
approveOAuth,
}
}
@@ -0,0 +1,205 @@
<script setup lang="ts">
import { ArrowLeft, Send, ShieldCheck } from 'lucide-vue-next'
import UiButton from '../../components/ui/UiButton.vue'
import UiModal from '../../components/ui/UiModal.vue'
import { ui } from '../../styles/ui'
import type {
CashOrderAccountType,
CashOrderMarket,
CashOrderMethod,
CashOrderPriceCondition,
CashOrderTerm,
CashOrderTriggerZone,
OrderKind,
OrderPreview,
TradeSide,
} from '../../types/trading'
import { currency } from '../../utils/format'
defineProps<{
estimate: OrderPreview | null
showEstimate: boolean
showOrder: boolean
stockName: string
side: TradeSide
kind: OrderKind
accountType: CashOrderAccountType
market: CashOrderMarket
priceCondition: CashOrderPriceCondition
price: number
orderTerm: CashOrderTerm
orderDate: string
orderMethod: CashOrderMethod
triggerZone: CashOrderTriggerZone
triggerPrice: number
secondaryPriceCondition: CashOrderPriceCondition
secondaryPrice: number
quantity: number
amount: number
}>()
const emit = defineEmits<{
closeEstimate: []
proceed: []
closeOrder: []
place: []
}>()
const priceConditionLabel = (condition: CashOrderPriceCondition) =>
({
limit: '指値',
limitAtOpen: '寄指',
limitAtClose: '引指',
limitIoc: 'IOC指',
market: '成行',
marketAtOpen: '寄成',
marketAtClose: '引成',
marketIoc: 'IOC成',
funari: '不成',
})[condition]
const orderTermLabel = (term: CashOrderTerm, date: string) =>
term === 'day' ? '当日中' : term === 'week' ? '今週中' : date
const orderMethodLabel = (method: CashOrderMethod) =>
method === 'normal' ? '通常' : method === 'stop' ? '逆指値' : 'OCO'
const triggerZoneLabel = (zone: CashOrderTriggerZone) => (zone === 'above' ? '以上' : '以下')
</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>{{ accountType === 'specific' ? '特定' : '一般' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>注文市場</dt>
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
</div>
<template v-if="kind !== 's'">
<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>
</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>{{ currency(triggerPrice) }} {{ triggerZoneLabel(triggerZone) }}</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>
</template>
<div :class="ui.confirmRow">
<dt>概算</dt>
<dd>{{ currency(amount) }}</dd>
</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 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>{{ accountType === 'specific' ? '特定' : '一般' }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>注文市場</dt>
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
</div>
<template v-if="kind !== 's'">
<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>
</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>{{ currency(triggerPrice) }} {{ triggerZoneLabel(triggerZone) }}</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>
</template>
<div :class="ui.confirmRow">
<dt>概算</dt>
<dd>{{ currency(amount) }}</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>
</UiModal>
</template>
@@ -0,0 +1,191 @@
<script setup lang="ts">
import { Plug } from 'lucide-vue-next'
import Spinner from '../../components/ui/Spinner.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'
defineProps<{
showPortfolioSpinner: boolean
totalAssetValue: number
buyingPower: number
holdingsMarketValue: number
totalProfitLoss: number
totalProfitLossRate: number
stockAssetRatio: number
cashAssetRatio: number
positions: Position[]
recentOrders: OrderRow[]
dataLoading: boolean
connected: boolean
orderHistoryLoaded: boolean
orderHistoryNotice: string
}>()
const emit = defineEmits<{
connect: []
openPosition: [code: string]
}>()
</script>
<template>
<section :class="ui.dashboardGrid">
<article :class="ui.assetOverviewPanel">
<div :class="ui.assetOverviewHead">
<span :class="ui.metricLabel">総資産価値</span>
<strong :class="ui.metricValue">
<template v-if="!showPortfolioSpinner">{{ currency(totalAssetValue) }}</template>
<Spinner v-else size="lg" />
</strong>
<small :class="ui.assetOverviewSubtext">
余力:
<template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template>
<Spinner v-else size="sm" />
</small>
</div>
<div :class="ui.assetBreakdownPanel">
<span :class="ui.assetBreakdownTitle">内訳</span>
<i :class="ui.assetBreakdownBar" aria-hidden="true">
<b :class="ui.assetBreakdownStocks" :style="{ width: `${stockAssetRatio}%` }"></b>
<b :class="ui.assetBreakdownCash" :style="{ width: `${cashAssetRatio}%` }"></b>
</i>
<div :class="ui.assetBreakdownRows">
<div :class="ui.assetBreakdownRow">
<span :class="ui.assetBreakdownLabel">
<i :class="[ui.assetBreakdownSwatch, ui.assetBreakdownSwatchStocks]"></i>
株式
</span>
<span :class="ui.assetBreakdownMeta">
<strong :class="ui.assetBreakdownAmount">
<template v-if="!showPortfolioSpinner">{{
currency(holdingsMarketValue)
}}</template>
<Spinner v-else size="sm" />
</strong>
<small :class="ui.assetBreakdownRatio">
<template v-if="!showPortfolioSpinner">{{ stockAssetRatio.toFixed(1) }}%</template>
<Spinner v-else size="sm" />
</small>
</span>
</div>
<div :class="ui.assetBreakdownRow">
<span :class="ui.assetBreakdownLabel">
<i :class="[ui.assetBreakdownSwatch, ui.assetBreakdownSwatchCash]"></i>
余力
</span>
<span :class="ui.assetBreakdownMeta">
<strong :class="ui.assetBreakdownAmount">
<template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template>
<Spinner v-else size="sm" />
</strong>
<small :class="ui.assetBreakdownRatio">
<template v-if="!showPortfolioSpinner">{{ cashAssetRatio.toFixed(1) }}%</template>
<Spinner v-else size="sm" />
</small>
</span>
</div>
</div>
</div>
</article>
<article :class="ui.metricPanel">
<span :class="ui.metricLabel">通算評価損益</span>
<strong :class="[ui.metricValue, totalProfitLoss >= 0 ? ui.positive : ui.negative]">
<template v-if="!showPortfolioSpinner">
{{ signedCurrency(totalProfitLoss) }}
<small> · {{ signedPercent(totalProfitLossRate) }}</small>
</template>
<Spinner v-else size="lg" />
</strong>
</article>
<article :class="ui.holdingsPanel">
<div :class="ui.panelHead">
<h2>保有銘柄</h2>
<button
:class="ui.ghostButton"
type="button"
:disabled="dataLoading"
@click="emit('connect')"
>
<Plug class="h-4 w-4" aria-hidden="true" />
{{ connected ? '再取得' : '接続' }}
</button>
</div>
<div :class="ui.holdingsBody">
<div :class="ui.holdingsHead">
<span>銘柄</span>
<span>タイプ</span>
<span>数量</span>
<span>評価額</span>
<span>評価損益</span>
</div>
<div v-if="positions.length" :class="ui.holdingsRows">
<button
v-for="position in positions"
:key="position.code"
:class="ui.holdingRow"
type="button"
@click="emit('openPosition', position.code)"
>
<span class="grid gap-1">
<strong>{{ position.name }}</strong>
<small>{{ position.code }}</small>
</span>
<b :class="ui.typePill">{{
position.type ?? (position.quantity >= 100 ? '単元' : 'S株')
}}</b>
<span>{{ position.quantity }}</span>
<span :class="ui.muted">{{ currency(position.marketValue) }}</span>
<span
class="grid justify-items-end gap-0.5"
:class="position.profitLoss >= 0 ? ui.positive : ui.negative"
>
<strong>{{ signedCurrency(position.profitLoss) }}</strong>
<small>{{ signedPercent(position.profitLossRate) }}</small>
</span>
</button>
</div>
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
<Spinner />
</div>
<p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると保有銘柄を表示します</p>
</div>
</article>
<article :class="ui.portfolioHistory">
<h2>取引履歴</h2>
<div :class="ui.historyList">
<div v-if="recentOrders.length" :class="ui.historyRows">
<div v-for="order in recentOrders" :key="order.id" :class="ui.miniOrder">
<span class="grid gap-1">
<strong>{{ order.stock }}</strong>
<small
>{{ order.side === 'buy' ? '買付' : '売却' }}({{
order.kind === 's' ? 'S株' : '単元'
}}) ・ {{ orderQuantityText(order) }}</small
>
</span>
<span class="grid justify-items-end gap-1">
<small>{{ order.date.slice(5, 10) }}</small>
<strong>{{ orderAmountText(order) }}</strong>
</span>
</div>
</div>
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
<Spinner />
</div>
<p v-else :class="[ui.muted, ui.emptyState]">
{{
orderHistoryLoaded
? orderHistoryNotice
? `SBI SDK は取引履歴なしを返しました (${orderHistoryNotice})`
: '該当する取引履歴はありません'
: '取引履歴はまだ取得されていません'
}}
</p>
</div>
</article>
</section>
</template>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
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 { ui } from '../../styles/ui'
import type { Stock } from '../../types/trading'
import { currency } from '../../utils/format'
const overlayTransition = { duration: 0.2, ease: 'easeOut' } as const
const sheetTransition = { type: 'spring', damping: 28, stiffness: 320 } as const
defineProps<{
stocks: Stock[]
selectedStockCode: string
countries: string[]
markets: string[]
loading?: boolean
}>()
const searchQuery = defineModel<string>('searchQuery', { required: true })
const countryFilter = defineModel<string>('countryFilter', { required: true })
const marketFilter = defineModel<string>('marketFilter', { required: true })
const emit = defineEmits<{
close: []
select: [stock: Stock]
}>()
</script>
<template>
<motion.div
:class="ui.searchOverlay"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:exit="{ opacity: 0 }"
:transition="overlayTransition"
@click.self="emit('close')"
>
<motion.section
:class="ui.searchSheet"
role="dialog"
aria-modal="true"
aria-label="銘柄検索"
:initial="{ opacity: 0, y: -20, scale: 0.98 }"
:animate="{ opacity: 1, y: 0, scale: 1 }"
:exit="{ opacity: 0, y: -16, scale: 0.98 }"
:transition="sheetTransition"
>
<div :class="ui.searchInputRow">
<UiField v-model="searchQuery" autofocus placeholder="銘柄名、コード、シンボル" />
<UiButton variant="ghost" @click="emit('close')">
<X class="h-4 w-4" aria-hidden="true" />
閉じる
</UiButton>
</div>
<div :class="ui.filterRow">
<UiField v-model="countryFilter" as="select">
<option value="all">すべての国</option>
<option v-for="country in countries" :key="country" :value="country">
{{ country }}
</option>
</UiField>
<UiField v-model="marketFilter" as="select">
<option value="all">すべての市場</option>
<option v-for="marketName in markets" :key="marketName" :value="marketName">
{{ marketName }}
</option>
</UiField>
</div>
<div :class="ui.searchResults">
<div v-if="loading" :class="ui.searchLoading" role="status" aria-live="polite">
<Spinner />
<span>検索中...</span>
</div>
<template v-else>
<div
v-if="!stocks.length"
:class="[ui.muted, 'grid justify-items-center gap-3 py-8 text-center']"
>
<img
:src="searchEmptyImage"
alt=""
class="h-32 w-32 object-contain opacity-75"
loading="lazy"
aria-hidden="true"
/>
<p>ありません</p>
</div>
<template v-else>
<button
v-for="stock in stocks"
:key="stock.code"
:class="[ui.searchResult, selectedStockCode === stock.code && ui.searchResultActive]"
type="button"
:aria-current="selectedStockCode === stock.code ? 'true' : undefined"
@click="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>
<Spinner v-else size="sm" />
</span>
</button>
</template>
</template>
</div>
</motion.section>
</motion.div>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { Ban, KeyRound, Plug, RefreshCw, Save, Trash2 } from 'lucide-vue-next'
import type { ApiKey, ApiKeySettings, SbiPasskey } from '../../api'
import ApiKeyPolicyEditor from '../../components/ApiKeyPolicyEditor.vue'
import { ui } from '../../styles/ui'
defineProps<{
apiKeys: ApiKey[]
sbiPasskeys: SbiPasskey[]
}>()
const apiKeyLabel = defineModel<string>('apiKeyLabel', { required: true })
const newApiKeySettings = defineModel<ApiKeySettings>('newApiKeySettings', { required: true })
const newApiToken = defineModel<string>('newApiToken', { required: true })
const sbiLabel = defineModel<string>('sbiLabel', { required: true })
const sbiCredentialJson = defineModel<string>('sbiCredentialJson', { required: true })
const tradePassword = defineModel<string>('tradePassword', { required: true })
const sbiDeviceId = defineModel<string>('sbiDeviceId', { required: true })
const selectedPasskeyId = defineModel<string>('selectedPasskeyId', { required: true })
const emit = defineEmits<{
addApiKey: []
refresh: []
saveApiKeySettings: [key: ApiKey]
revokeApiKey: [id: string]
addSbiPasskey: []
connect: []
removeSbiPasskey: [id: string]
}>()
</script>
<template>
<section :class="ui.settingsLayout">
<div :class="ui.apiLayout">
<article :class="ui.panel">
<div :class="ui.panelHead">
<div>
<p :class="ui.eyebrow">Fine-grained</p>
<h2>API Key 発行</h2>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('addApiKey')">
<KeyRound class="h-4 w-4" aria-hidden="true" />
発行
</button>
</div>
<label :class="ui.label">
名前
<input v-model="apiKeyLabel" :class="ui.input" placeholder="例: trade-bot-readonly" />
</label>
<ApiKeyPolicyEditor v-model="newApiKeySettings" permissions-open />
<textarea v-if="newApiToken" v-model="newApiToken" :class="ui.input" readonly></textarea>
</article>
<article :class="ui.panel">
<div :class="ui.panelHead">
<h2>発行済みキー</h2>
<button :class="ui.ghostButton" type="button" @click="emit('refresh')">
<RefreshCw class="h-4 w-4" aria-hidden="true" />
更新
</button>
</div>
<div :class="ui.list">
<div v-for="key in apiKeys" :key="key.id" :class="ui.keyRow">
<div :class="ui.row">
<span>{{ key.label }}</span>
<div :class="ui.rowActions">
<button
:class="ui.primaryButton"
type="button"
title="Save settings"
@click="emit('saveApiKeySettings', key)"
>
<Save class="h-4 w-4" aria-hidden="true" />
保存
</button>
<button
:class="ui.dangerButton"
type="button"
title="Revoke"
@click="emit('revokeApiKey', key.id)"
>
<Ban class="h-4 w-4" aria-hidden="true" />
失効
</button>
</div>
</div>
<ApiKeyPolicyEditor
:model-value="key"
compact
@update:model-value="Object.assign(key, $event)"
/>
</div>
</div>
</article>
</div>
<div :class="ui.apiLayout">
<article :class="ui.panel">
<div :class="ui.panelHead">
<div>
<p :class="ui.eyebrow">Profiles</p>
<h2>パスキー設定</h2>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('addSbiPasskey')">
<Save class="h-4 w-4" aria-hidden="true" />
保存
</button>
</div>
<label :class="ui.label">
名前
<input v-model="sbiLabel" :class="ui.input" placeholder="例: 個人メイン" />
</label>
<label :class="ui.label">
パスキー JSON
<textarea
v-model="sbiCredentialJson"
:class="ui.input"
spellcheck="false"
placeholder="{ ... }"
></textarea>
</label>
<div :class="ui.ticketBox">
<label :class="ui.label">
取引パスワード
<input v-model="tradePassword" :class="ui.input" type="password" autocomplete="off" />
</label>
<label :class="ui.label">
デバイスキー
<input v-model="sbiDeviceId" :class="ui.input" autocomplete="off" spellcheck="false" />
</label>
</div>
</article>
<article :class="ui.panel">
<div :class="ui.panelHead">
<h2>プロフィール切り替え</h2>
<button :class="ui.ghostButton" type="button" @click="emit('connect')">
<Plug class="h-4 w-4" aria-hidden="true" />
接続
</button>
</div>
<div :class="ui.list">
<label v-for="passkey in sbiPasskeys" :key="passkey.id" :class="ui.profileRow">
<input
v-model="selectedPasskeyId"
type="radio"
:value="passkey.id"
@change="emit('connect')"
/>
<span>
<strong>{{ passkey.label }}</strong>
<small>{{ new Date(passkey.createdAt).toLocaleString() }}</small>
</span>
<button
:class="ui.dangerButton"
type="button"
title="Delete passkey"
@click.prevent="emit('removeSbiPasskey', passkey.id)"
>
<Trash2 class="h-4 w-4" aria-hidden="true" />
削除
</button>
</label>
</div>
</article>
</div>
</section>
</template>
@@ -0,0 +1,225 @@
<script setup lang="ts">
import Chart from 'chart.js/auto'
import { Minus, Plus, RotateCcw } from 'lucide-vue-next'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Spinner from '../../components/ui/Spinner.vue'
import type { RealtimePricePoint } from '../../types/trading'
const props = defineProps<{
points: RealtimePricePoint[]
stockName: string
active: boolean
}>()
const canvas = ref<HTMLCanvasElement | null>(null)
const scroller = ref<HTMLDivElement | null>(null)
const zoomScale = ref(1)
let chart: Chart<'line', number[], string> | undefined
let wasScrolledToEnd = true
const minChartWidth = 720
const minZoom = 0.6
const maxZoom = 3
const zoomStep = 0.2
const basePointSpacing = 10
const pointSpacing = computed(() => basePointSpacing * zoomScale.value)
const labelFor = (point: RealtimePricePoint) => {
const date = new Date(point.at)
if (date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0) {
return new Intl.DateTimeFormat('ja-JP', {
month: 'numeric',
day: 'numeric',
}).format(date)
}
return new Intl.DateTimeFormat('ja-JP', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date)
}
const labels = computed(() => props.points.map(labelFor))
const prices = computed(() => props.points.map((point) => point.price))
const chartWidth = computed(() => {
const width = Math.max(minChartWidth, props.points.length * pointSpacing.value + 72)
return `max(100%, ${width}px)`
})
const rememberScrollPosition = () => {
const element = scroller.value
if (!element) return
wasScrolledToEnd =
element.scrollLeft + element.clientWidth >= element.scrollWidth - pointSpacing.value
}
const scrollToEndIfNeeded = async () => {
if (!wasScrolledToEnd) return
await nextTick()
const element = scroller.value
if (!element) return
element.scrollLeft = element.scrollWidth
}
const updateChart = () => {
if (!chart) return
rememberScrollPosition()
chart.data.labels = labels.value
const dataset = chart.data.datasets[0]
if (dataset) dataset.data = prices.value
chart.update('none')
chart.resize()
void scrollToEndIfNeeded()
}
const setZoomScale = (value: number) => {
rememberScrollPosition()
zoomScale.value = Math.min(maxZoom, Math.max(minZoom, value))
}
const zoomOut = () => setZoomScale(zoomScale.value - zoomStep)
const zoomIn = () => setZoomScale(zoomScale.value + zoomStep)
const resetZoom = () => setZoomScale(1)
const setZoomScaleFromInput = (event: Event) => {
const value = Number((event.target as HTMLInputElement).value)
setZoomScale(value)
}
const handleWheel = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault()
setZoomScale(zoomScale.value + (event.deltaY < 0 ? zoomStep : -zoomStep))
}
onMounted(() => {
if (!canvas.value) return
chart = new Chart(canvas.value, {
type: 'line',
data: {
labels: labels.value,
datasets: [
{
data: prices.value,
borderColor: '#40dba2',
backgroundColor: 'rgba(64, 219, 162, 0.14)',
borderWidth: 2,
fill: true,
pointBackgroundColor: '#d3e3fd',
pointBorderColor: '#40dba2',
pointRadius: 2.5,
pointHoverRadius: 4,
tension: 0.25,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { display: false },
tooltip: {
displayColors: false,
callbacks: {
label: (item) => `${(item.parsed.y ?? 0).toLocaleString('ja-JP')}円`,
},
},
},
scales: {
x: {
grid: { color: 'rgba(143, 148, 157, 0.12)' },
ticks: { color: '#8f949d', maxTicksLimit: 5 },
},
y: {
grid: { color: 'rgba(143, 148, 157, 0.12)' },
ticks: {
color: '#8f949d',
callback: (value) =>
typeof value === 'number' ? value.toLocaleString('ja-JP') : value,
},
},
},
},
})
void scrollToEndIfNeeded()
})
watch(() => props.points, updateChart, { deep: true })
watch(chartWidth, () => {
chart?.resize()
void scrollToEndIfNeeded()
})
onBeforeUnmount(() => {
chart?.destroy()
chart = undefined
})
</script>
<template>
<div class="relative h-full w-full">
<div
ref="scroller"
class="h-full w-full overflow-x-auto overflow-y-hidden"
@scroll="rememberScrollPosition"
@wheel="handleWheel"
>
<div class="h-full" :style="{ width: chartWidth }">
<canvas
ref="canvas"
class="h-full w-full"
:aria-label="`${stockName} realtime price chart`"
></canvas>
</div>
</div>
<span
v-if="active && !points.length"
class="absolute inset-0 grid place-items-center text-[#8f949d]"
>
<Spinner />
</span>
<div
class="absolute right-2 top-2 flex items-center gap-1 rounded-md border border-[#2d3440] bg-[#111418]/90 px-1.5 py-1 shadow-sm"
>
<button
class="grid h-6 w-6 place-items-center rounded text-[#d3e3fd] hover:bg-[#1d232b]"
type="button"
aria-label="縮小"
@click="zoomOut"
>
<Minus class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<input
:value="zoomScale"
class="h-6 w-24 accent-[#40dba2]"
type="range"
:min="minZoom"
:max="maxZoom"
:step="zoomStep"
aria-label="グラフ縮尺"
@input="setZoomScaleFromInput"
/>
<button
class="grid h-6 w-6 place-items-center rounded text-[#d3e3fd] hover:bg-[#1d232b]"
type="button"
aria-label="拡大"
@click="zoomIn"
>
<Plus class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
class="grid h-6 w-6 place-items-center rounded text-[#8f949d] hover:bg-[#1d232b] hover:text-[#d3e3fd]"
type="button"
aria-label="縮尺を戻す"
@click="resetZoom"
>
<RotateCcw class="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
</div>
</template>
@@ -0,0 +1,546 @@
<script setup lang="ts">
import { ChevronDown, Download, FileCheck2, Search, ShieldCheck } from 'lucide-vue-next'
import { AnimatePresence, LayoutGroup, motion } from 'motion-v'
import { computed, ref } from 'vue'
import Spinner from '../../components/ui/Spinner.vue'
import UiButton from '../../components/ui/UiButton.vue'
import UiField from '../../components/ui/UiField.vue'
import UiSegmented from '../../components/ui/UiSegmented.vue'
import {
cashOrderAccountTypeOptions,
cashOrderMarketOptions,
cashOrderMethodOptions,
cashOrderPriceConditionOptions,
cashOrderTermOptions,
cashOrderTriggerZoneOptions,
orderKindOptions,
sKabuOrderMarketOptions,
tradeSideOptions,
} from '../../constants/trade'
import { ui } from '../../styles/ui'
import type {
ChartMode,
CashOrderAccountType,
CashOrderMarket,
CashOrderMethod,
CashOrderPriceCondition,
CashOrderTerm,
CashOrderTriggerZone,
OrderKind,
Position,
RealtimePricePoint,
Stock,
TradeSide,
} from '../../types/trading'
import { currency } from '../../utils/format'
import RealtimePriceChart from './RealtimePriceChart.vue'
type StockInfoTab = 'detail' | 'holding'
defineProps<{
viewedStocks: Stock[]
selectedStock: Stock
selectedPosition?: Position
connected: boolean
orderQuantity: number
estimatedAmount: number
canRequestCashEstimate: boolean
canPlaceCashOrder: boolean
realtimePricePoints: RealtimePricePoint[]
pricePolling: boolean
boxPlotStyle: {
min: string
q1: string
median: string
q3: string
max: string
}
hasQuote: (stock: Stock) => boolean
}>()
const tradeSide = defineModel<TradeSide>('tradeSide', { required: true })
const orderKind = defineModel<OrderKind>('orderKind', { required: true })
const cashOrderAccountType = defineModel<CashOrderAccountType>('cashOrderAccountType', {
required: true,
})
const cashOrderMarket = defineModel<CashOrderMarket>('cashOrderMarket', {
required: true,
})
const cashOrderPriceCondition = defineModel<CashOrderPriceCondition>('cashOrderPriceCondition', {
required: true,
})
const cashOrderTerm = defineModel<CashOrderTerm>('cashOrderTerm', { required: true })
const cashOrderDateInput = defineModel<string>('cashOrderDateInput', { required: true })
const cashOrderMethod = defineModel<CashOrderMethod>('cashOrderMethod', { required: true })
const cashOrderTriggerZone = defineModel<CashOrderTriggerZone>('cashOrderTriggerZone', {
required: true,
})
const cashOrderTriggerPriceInput = defineModel<string>('cashOrderTriggerPriceInput', {
required: true,
})
const cashOrderSecondaryPriceCondition = defineModel<CashOrderPriceCondition>(
'cashOrderSecondaryPriceCondition',
{
required: true,
},
)
const cashOrderSecondaryPriceInput = defineModel<string>('cashOrderSecondaryPriceInput', {
required: true,
})
const quantityInput = defineModel<string>('quantityInput', { required: true })
const priceInput = defineModel<string>('priceInput', { required: true })
const chartMode = defineModel<ChartMode>('chartMode', { required: true })
const activeStockInfoTab = ref<StockInfoTab>('detail')
const advancedOptionsOpen = ref(false)
const orderMarketOptions = computed(() =>
orderKind.value === 's' ? sKabuOrderMarketOptions : cashOrderMarketOptions,
)
const primaryPriceConditionRequiresPrice = computed(() =>
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
cashOrderPriceCondition.value,
),
)
const secondaryPriceConditionRequiresPrice = computed(() =>
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
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 emit = defineEmits<{
openSearch: []
selectStock: [stock: Stock]
downloadCsv: []
estimate: []
confirmOrder: []
}>()
</script>
<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">
<div :class="ui.stockTitle">
<div>
<p :class="ui.eyebrow">{{ selectedStock.symbol }}</p>
<h2>{{ selectedStock.name }}</h2>
</div>
<div :class="ui.quoteBox">
<strong>
<template v-if="hasQuote(selectedStock)">{{
currency(selectedStock.price)
}}</template>
<Spinner v-else size="sm" />
</strong>
<small
v-if="hasQuote(selectedStock)"
:class="selectedStock.change >= 0 ? ui.positive : ui.negative"
>
{{ selectedStock.change >= 0 ? '+' : '' }}{{ selectedStock.change }}%
</small>
<Spinner v-else size="sm" />
</div>
</div>
<div :class="ui.periodTabs">
<button :class="[ui.periodButton, ui.periodButtonActive]" type="button">1D</button>
<button :class="ui.periodButton" type="button">1W</button>
<button :class="ui.periodButton" type="button">1M</button>
</div>
<div :class="ui.chartActions">
<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>
<button :class="ui.ghostButton" type="button" @click="emit('downloadCsv')">
<Download class="h-4 w-4" aria-hidden="true" />
CSV
</button>
</div>
<div v-if="chartMode === 'line'" :class="ui.chartBox">
<RealtimePriceChart
v-if="hasQuote(selectedStock)"
:points="realtimePricePoints"
:stock-name="selectedStock.name"
:active="pricePolling"
/>
<span v-else class="grid h-full place-items-center text-[#8f949d]">
<Spinner />
</span>
</div>
<div v-else :class="ui.boxplot">
<div :class="ui.boxplotScale">
<span>
<template v-if="hasQuote(selectedStock)">{{
currency(selectedStock.box.min)
}}</template>
<Spinner v-else size="sm" />
</span>
<span>
<template v-if="hasQuote(selectedStock)">{{
currency(selectedStock.box.max)
}}</template>
<Spinner v-else size="sm" />
</span>
</div>
<div v-if="hasQuote(selectedStock)" :class="ui.boxplotTrack">
<i
:class="ui.whisker"
:style="{
left: boxPlotStyle.min,
width: `calc(${boxPlotStyle.max} - ${boxPlotStyle.min})`,
}"
></i>
<i
:class="ui.box"
:style="{
left: boxPlotStyle.q1,
width: `calc(${boxPlotStyle.q3} - ${boxPlotStyle.q1})`,
}"
></i>
<i :class="ui.median" :style="{ left: boxPlotStyle.median }"></i>
</div>
<div v-else class="grid min-h-20 place-items-center text-[#8f949d]">
<Spinner />
</div>
</div>
</article>
<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">
<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>
<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>
</article>
</LayoutGroup>
</div>
<div :class="ui.tradeSeparator" aria-hidden="true"></div>
<article :class="ui.ticketPanel">
<div :class="ui.ticketTop">
<UiSegmented v-model="tradeSide" :options="tradeSideOptions" />
</div>
<div :class="ui.ticketScroll">
<div :class="ui.ticketScrollInner">
<UiSegmented v-model="orderKind" :options="orderKindOptions" />
<div :class="ui.ticketBox">
<UiField
v-model="quantityInput"
label="発注株数"
type="number"
min="1"
placeholder="株数を入力"
/>
<details
:class="ui.advancedOptions"
@toggle="advancedOptionsOpen = ($event.target as HTMLDetailsElement).open"
>
<summary :class="ui.advancedSummary">
<span>Advanced Options</span>
<motion.span
:class="ui.advancedSummaryIconWrap"
:animate="{ rotate: advancedOptionsOpen ? 180 : 0 }"
:transition="advancedTransition"
>
<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>
<UiSegmented
v-model="cashOrderAccountType"
:options="cashOrderAccountTypeOptions"
/>
<UiField
v-model="cashOrderMarket"
as="select"
label="注文市場"
:disabled="orderKind === 's'"
>
<option
v-for="option in orderMarketOptions"
: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"
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'"
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"
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"
placeholder="価格を入力"
/>
</template>
</template>
</motion.div>
</details>
</div>
</div>
</div>
<div :class="ui.ticketBottom">
<div :class="ui.estimateSummary">
<span>概算金額</span>
<strong>{{ currency(estimatedAmount) }}</strong>
</div>
<div :class="ui.actions">
<UiButton type="button" :disabled="!canRequestCashEstimate" @click="emit('estimate')">
<FileCheck2 class="h-4 w-4" aria-hidden="true" />
見積
</UiButton>
<UiButton
variant="danger"
type="button"
:disabled="!canPlaceCashOrder"
@click="emit('confirmOrder')"
>
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
注文確認
</UiButton>
</div>
</div>
</article>
</section>
</template>
@@ -0,0 +1,301 @@
import type {
OrderPreview,
OrderRow,
Position,
RealtimePricePoint,
Stock,
} from '../../types/trading'
import { currency } from '../../utils/format'
export type RecordLike = Record<string, unknown>
export const emptyStock: Stock = {
code: '',
name: '未選択',
symbol: '',
country: '日本',
market: '',
sector: '',
price: 0,
change: 0,
changeAmount: 0,
volume: 0,
open: 0,
high: 0,
low: 0,
prevClose: 0,
sShare: false,
history: [0, 0],
box: { min: 0, q1: 0, median: 0, q3: 0, max: 0 },
}
export const isRecord = (value: unknown): value is RecordLike =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value)
export const asRecord = (value: unknown): RecordLike => (isRecord(value) ? value : {})
export const asArray = (value: unknown): unknown[] => (Array.isArray(value) ? value : [])
export const numberValue = (value: unknown, fallback = 0): number => {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const normalized = value.replace(/[^\d.-]/g, '')
if (!normalized) return fallback
const parsed = Number(normalized)
if (Number.isFinite(parsed)) return parsed
}
const record = asRecord(value)
if ('value' in record) return numberValue(record.value, fallback)
if ('text' in record) return numberValue(record.text, fallback)
return fallback
}
const nullableNumberValue = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
const normalized = value.replace(/[^\d.-]/g, '')
if (!normalized) return null
const parsed = Number(normalized)
return Number.isFinite(parsed) ? parsed : null
}
const record = asRecord(value)
if ('value' in record) return nullableNumberValue(record.value)
if ('text' in record) return nullableNumberValue(record.text)
return null
}
export const textValue = (value: unknown, fallback = ''): string =>
typeof value === 'string' && value ? value : fallback
export const issueFrom = (value: unknown) => {
const issue = asRecord(value)
return {
code: textValue(issue.code, textValue(issue.issueCode)),
market: textValue(issue.market, textValue(issue.marketCode, '')),
name: textValue(
issue.name,
textValue(issue.issueName, textValue(issue.stockName, textValue(issue.displayName))),
),
}
}
const quoteFrom = (value: unknown) => {
const quote = asRecord(value)
return {
price: numberValue(quote.price),
change: numberValue(quote.change),
changeRate: numberValue(quote.changeRate),
open: numberValue(quote.open),
high: numberValue(quote.high),
low: numberValue(quote.low),
prevClose: numberValue(quote.previousClose),
volume: numberValue(quote.volume),
}
}
const historyFromQuote = (quote: ReturnType<typeof quoteFrom>) => {
const base = quote.prevClose || quote.open || quote.price
const low = quote.low || Math.min(base, quote.price)
const high = quote.high || Math.max(base, quote.price)
return [base, quote.open || base, low, (low + high) / 2, high, quote.price]
}
const boxFromHistory = (history: number[]) => {
const sorted = [...history].sort((a, b) => a - b)
const at = (pct: number) => sorted[Math.floor((sorted.length - 1) * pct)] ?? 0
return { min: at(0), q1: at(0.25), median: at(0.5), q3: at(0.75), max: at(1) }
}
export const stockFromIssue = (issue: ReturnType<typeof issueFrom>): Stock => ({
code: issue.code,
name: issue.name || issue.code,
symbol: issue.code ? `${issue.code}${issue.market ? `.${issue.market}` : ''}` : '',
country: '日本',
market: issue.market,
sector: '',
price: 0,
change: 0,
changeAmount: 0,
volume: 0,
open: 0,
high: 0,
low: 0,
prevClose: 0,
sShare: true,
history: [0, 0],
box: { min: 0, q1: 0, median: 0, q3: 0, max: 0 },
})
export const stockFromPosition = (position: Position): Stock => {
const price = position.quantity ? Math.round(position.marketValue / position.quantity) : 0
return {
...stockFromIssue({
code: position.code,
market: position.market,
name: position.name,
}),
price,
history: [price, price],
box: { min: price, q1: price, median: price, q3: price, max: price },
}
}
export const stockFromBoard = (
value: unknown,
fallbackIssue?: ReturnType<typeof issueFrom>,
): Stock => {
const board = asRecord(value)
const quoteRecord = asRecord(board.quote)
const boardIssue = issueFrom(board.issue)
const quoteIssue = issueFrom(quoteRecord.issue)
const issue = {
code: quoteIssue.code || boardIssue.code || fallbackIssue?.code || '',
market: quoteIssue.market || boardIssue.market || fallbackIssue?.market || '',
name: quoteIssue.name || boardIssue.name || fallbackIssue?.name || '',
}
const quote = quoteFrom(quoteRecord)
const codeAsNumber = Number(issue.code)
const quotePrice = quote.price === codeAsNumber ? 0 : quote.price
const price = quotePrice || quote.prevClose || quote.open
const history = historyFromQuote({ ...quote, price })
const hasValidQuote = price > 0
return {
code: issue.code,
name: issue.name,
symbol: issue.code ? `${issue.code}${issue.market ? `.${issue.market}` : ''}` : '',
country: '日本',
market: issue.market,
sector: '',
price,
change: hasValidQuote ? quote.changeRate : 0,
changeAmount: hasValidQuote ? quote.change : 0,
volume: hasValidQuote ? quote.volume : 0,
open: hasValidQuote ? quote.open : 0,
high: hasValidQuote ? quote.high : 0,
low: hasValidQuote ? quote.low : 0,
prevClose: hasValidQuote ? quote.prevClose : 0,
sShare: true,
history,
box: boxFromHistory(history),
}
}
const chartDateTimeToIso = (value: string) => {
const digits = value.replace(/\D/g, '')
if (digits.length < 8) return ''
const year = Number(digits.slice(0, 4))
const month = Number(digits.slice(4, 6))
const day = Number(digits.slice(6, 8))
const hour = Number(digits.slice(8, 10) || '0')
const minute = Number(digits.slice(10, 12) || '0')
const date = new Date(year, month - 1, day, hour, minute)
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
}
export const pricePointsFromIssueChart = (value: unknown): RealtimePricePoint[] =>
asArray(asRecord(value).prices)
.map((entry) => {
const price = asRecord(entry)
const at = chartDateTimeToIso(textValue(price.dateTime))
return { at, price: numberValue(price.close) }
})
.filter((point) => point.at && point.price > 0)
export const positionFromApi = (value: unknown): Position | null => {
const item = asRecord(value)
const issue = issueFrom(item.issue)
if (!issue.code) return null
const quantity = numberValue(item.quantity)
const avgPrice = numberValue(item.averagePrice ?? item.purchasePrice)
const profitLoss = numberValue(item.profitLoss)
const costBasis = avgPrice * quantity
const profitLossRate =
numberValue(item.profitLossRate) || (costBasis ? (profitLoss / costBasis) * 100 : 0)
return {
code: issue.code,
name: issue.name,
market: issue.market,
quantity,
avgPrice,
marketValue: numberValue(item.marketValue ?? item.valuationPrice),
profitLoss,
profitLossRate,
type: textValue(item.depositTypeText) || undefined,
}
}
const orderStatusText = (status: string): OrderRow['status'] => {
if (status === 'open') return '注文中'
if (status === 'cancelled' || status === 'expired' || status === 'rejected') return '取消済'
return '約定済'
}
export const orderFromApi = (value: unknown): OrderRow | null => {
const item = asRecord(value)
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 status = orderStatusText(textValue(item.status, 'executed'))
const executedQuantity = nullableNumberValue(item.executedQuantity)
const orderedQuantity = nullableNumberValue(item.quantity)
const unexecutedQuantity = nullableNumberValue(item.unexecutedQuantity)
const quantity =
status === '約定済'
? (executedQuantity ?? orderedQuantity ?? unexecutedQuantity)
: (orderedQuantity ?? unexecutedQuantity ?? executedQuantity)
const price =
status === '約定済'
? (nullableNumberValue(item.executedPrice) ?? nullableNumberValue(item.price))
: (nullableNumberValue(item.price) ?? nullableNumberValue(item.executedPrice))
return {
id: textValue(item.id, textValue(item.orderNumber, `${issue.code}-${item.orderedAt ?? ''}`)),
date: textValue(item.orderedAt, textValue(item.expiresAt)),
stock: issue.name || issue.code,
side,
kind,
quantity,
price,
status,
orderNumber: textValue(item.orderNumber),
tradeId: textValue(item.tradeId),
}
}
export const orderHistoryKey = (order: OrderRow) =>
[order.orderNumber || order.id, order.tradeId, order.stock, order.date].filter(Boolean).join(':')
export const orderQuantityText = (order: OrderRow) =>
typeof order.quantity === 'number' ? `${order.quantity}株` : '数量不明'
export const orderAmountText = (order: OrderRow) =>
typeof order.price === 'number' && typeof order.quantity === 'number'
? currency(order.price * order.quantity)
: '約定代金不明'
export const orderHistoryResultNotice = (value: unknown) => {
const error = asRecord(asRecord(value).error)
const code = textValue(error.code, textValue(error.status))
if (!code) return ''
const message = textValue(error.message)
return message ? `${code}: ${message}` : code
}
export const isOrderPreview = (value: unknown): value is OrderPreview => {
if (!value || typeof value !== 'object') return false
const preview = value as Partial<OrderPreview>
return (
typeof preview.issue === 'object' &&
preview.issue !== null &&
typeof preview.issue.code === 'string' &&
typeof preview.issue.market === 'string' &&
typeof preview.side === 'string' &&
typeof preview.quantity === 'number' &&
Array.isArray(preview.warnings)
)
}
export const fulfilledValues = <T>(results: Array<PromiseSettledResult<T>>): T[] =>
results
.filter((result): result is PromiseFulfilledResult<T> => result.status === 'fulfilled')
.map((result) => result.value)
@@ -0,0 +1,899 @@
import { computed, ref, watch, type Ref } from 'vue'
import { createRpcSocket } from '../../api'
import type {
ChartMode,
CashOrderAccountType,
CashOrderMarket,
CashOrderMethod,
CashOrderPriceCondition,
CashOrderTerm,
CashOrderTriggerZone,
JsonRpcResponse,
OrderKind,
OrderPreview,
OrderRow,
Position,
RealtimePricePoint,
RpcMessage,
Stock,
TradeSide,
} from '../../types/trading'
import {
asArray,
asRecord,
emptyStock,
fulfilledValues,
isOrderPreview,
issueFrom,
numberValue,
orderFromApi,
orderHistoryKey,
orderHistoryResultNotice,
pricePointsFromIssueChart,
positionFromApi,
stockFromBoard,
stockFromIssue,
stockFromPosition,
textValue,
type RecordLike,
} from './trading-data'
type RpcResolver = {
resolve: (value: unknown) => void
reject: (reason: Error) => void
}
const priceBasedCashOrderConditions = new Set<CashOrderPriceCondition>([
'limit',
'limitAtOpen',
'limitAtClose',
'limitIoc',
'funari',
])
const cashOrderPriceConditionRequiresPrice = (condition: CashOrderPriceCondition) =>
priceBasedCashOrderConditions.has(condition)
export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const selectedStockCode = ref('')
const viewedStockCodes = ref<string[]>([])
const tradeSide = ref<TradeSide>('buy')
const orderKind = ref<OrderKind>('s')
const cashOrderAccountType = ref<CashOrderAccountType>('specific')
const cashOrderMarket = ref<CashOrderMarket>('STK')
const cashOrderPriceCondition = ref<CashOrderPriceCondition>('market')
const cashOrderTerm = ref<CashOrderTerm>('day')
const cashOrderDateInput = ref('')
const cashOrderMethod = ref<CashOrderMethod>('normal')
const cashOrderTriggerZone = ref<CashOrderTriggerZone>('above')
const cashOrderTriggerPriceInput = ref('')
const cashOrderSecondaryPriceCondition = ref<CashOrderPriceCondition>('limit')
const cashOrderSecondaryPriceInput = ref('')
const quantityInput = ref('')
const priceInput = ref('')
const chartMode = ref<ChartMode>('line')
const showSearch = ref(false)
const searchQuery = ref('')
const countryFilter = ref('all')
const marketFilter = ref('all')
const showEstimateDialog = ref(false)
const showOrderDialog = ref(false)
const pendingCashEstimateId = ref<number | null>(null)
const lastCashEstimate = ref<OrderPreview | null>(null)
const lastCashEstimateKey = ref('')
const ws = ref<WebSocket | null>(null)
const rpcPending = new Map<number, RpcResolver>()
const sbiConnected = ref(false)
const dataLoading = ref(false)
const searchLoading = ref(false)
const buyingPower = ref(0)
const holdingsMarketValue = ref(0)
const totalProfitLoss = ref(0)
const totalProfitLossRate = ref(0)
const orders = ref<OrderRow[]>([])
const orderHistoryLoaded = ref(false)
const orderHistoryNotice = ref('')
const positions = ref<Position[]>([])
const stocks = ref<Stock[]>([])
const historicalPricePoints = ref<RealtimePricePoint[]>([])
const realtimePricePoints = ref<RealtimePricePoint[]>([])
const pricePolling = ref(false)
let rpcId = 0
let boardPollingSubscriptionId = ''
let boardPollingRequestId = 0
let chartHistoryRequestId = 0
const maxRealtimePricePoints = 120
const maxChartPricePoints = 240
const errorMessage = (cause: unknown, fallback: string) =>
cause instanceof Error ? cause.message : fallback
const reportDataError = (message: string, cause?: unknown) => {
if (cause) {
console.error(`[csbie-ui] データ取得エラー: ${message}`, cause)
return
}
console.error(`[csbie-ui] データ取得エラー: ${message}`)
}
const selectedStock = computed(
() =>
stocks.value.find((stock) => stock.code === selectedStockCode.value) ??
stocks.value[0] ??
emptyStock,
)
const socketReady = computed(() => ws.value?.readyState === WebSocket.OPEN)
const connected = computed(() => sbiConnected.value && socketReady.value)
const orderQuantity = computed(() => Number(quantityInput.value || 0))
const orderPrice = computed(() => Number(priceInput.value || selectedStock.value.price))
const cashOrderPrimaryRequiresPrice = computed(() =>
cashOrderPriceConditionRequiresPrice(cashOrderPriceCondition.value),
)
const cashOrderSecondaryRequiresPrice = computed(() =>
cashOrderPriceConditionRequiresPrice(cashOrderSecondaryPriceCondition.value),
)
const cashOrderTriggerPrice = computed(() => Number(cashOrderTriggerPriceInput.value || 0))
const cashOrderSecondaryPrice = computed(() => Number(cashOrderSecondaryPriceInput.value || 0))
const resolvedCashOrderMarket = computed(() => {
if (orderKind.value === 's') return 'STK'
return cashOrderMarket.value === 'auto' ? selectedStock.value.market : cashOrderMarket.value
})
const estimatedAmount = computed(() => Math.max(0, orderQuantity.value * orderPrice.value))
const hasQuote = (stock: Stock) => stock.price > 0
const hasAccountSummary = computed(
() =>
connected.value ||
Boolean(positions.value.length) ||
orderHistoryLoaded.value ||
holdingsMarketValue.value > 0 ||
buyingPower.value > 0,
)
const showPortfolioSpinner = computed(() => dataLoading.value || !hasAccountSummary.value)
const cashOrderKey = computed(() =>
JSON.stringify({
issueCode: selectedStock.value.code,
market: resolvedCashOrderMarket.value,
side: tradeSide.value,
quantity: orderQuantity.value,
kind: orderKind.value,
accountType: 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,
secondaryPriceCondition:
orderKind.value !== 's' && cashOrderMethod.value === 'oco'
? cashOrderSecondaryPriceCondition.value
: undefined,
secondaryPrice:
orderKind.value !== 's' &&
cashOrderMethod.value === 'oco' &&
cashOrderSecondaryRequiresPrice.value
? cashOrderSecondaryPrice.value
: undefined,
}),
)
const canRequestCashEstimate = computed(() => {
if (!connected.value || !selectedStock.value.code || orderQuantity.value <= 0) return false
if (orderKind.value === 's') return true
if (cashOrderPrimaryRequiresPrice.value && orderPrice.value <= 0) return false
if (cashOrderTerm.value === 'date' && !cashOrderDateInput.value) return false
if (cashOrderMethod.value !== 'normal' && cashOrderTriggerPrice.value <= 0) return false
if (
cashOrderMethod.value === 'oco' &&
cashOrderSecondaryRequiresPrice.value &&
cashOrderSecondaryPrice.value <= 0
) {
return false
}
return true
})
const canPlaceCashOrder = computed(
() =>
canRequestCashEstimate.value &&
Boolean(lastCashEstimate.value) &&
lastCashEstimateKey.value === cashOrderKey.value,
)
const countries = computed(() => [...new Set(stocks.value.map((stock) => stock.country))])
const markets = computed(() => [
...new Set(stocks.value.map((stock) => stock.market).filter(Boolean)),
])
const stockByCode = computed(() => new Map(stocks.value.map((stock) => [stock.code, stock])))
const viewedStocks = computed(() =>
viewedStockCodes.value
.map((code) => stockByCode.value.get(code))
.filter((stock): stock is Stock => Boolean(stock)),
)
const filteredStocks = computed(() => {
const query = searchQuery.value.trim().toLowerCase()
const matchesFilters = (stock: Stock) => {
const matchesCountry = countryFilter.value === 'all' || stock.country === countryFilter.value
const matchesMarket = marketFilter.value === 'all' || stock.market === marketFilter.value
return matchesCountry && matchesMarket
}
if (!query) return viewedStocks.value.filter(matchesFilters)
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 selectedPosition = computed(() =>
positions.value.find((position) => position.code === selectedStock.value.code),
)
const recentOrders = computed(() => orders.value.slice(0, 2))
const totalAssetValue = computed(() => holdingsMarketValue.value + buyingPower.value)
const stockAssetRatio = computed(() => {
if (!totalAssetValue.value) return 0
return (holdingsMarketValue.value / totalAssetValue.value) * 100
})
const cashAssetRatio = computed(() => {
if (!totalAssetValue.value) return 0
return (buyingPower.value / totalAssetValue.value) * 100
})
const chartPricePoints = computed(() =>
[...historicalPricePoints.value, ...realtimePricePoints.value].slice(-maxChartPricePoints),
)
const boxPlotStyle = computed(() => {
const box = selectedStock.value.box
const range = Math.max(1, box.max - box.min)
const toPct = (value: number) => ((value - box.min) / range) * 100
return {
min: `${toPct(box.min)}%`,
q1: `${toPct(box.q1)}%`,
median: `${toPct(box.median)}%`,
q3: `${toPct(box.q3)}%`,
max: `${toPct(box.max)}%`,
}
})
const recordViewedStock = (code: string) => {
if (!code) return
viewedStockCodes.value = [code, ...viewedStockCodes.value.filter((entry) => entry !== code)]
}
const selectStock = (stock: Stock) => {
selectedStockCode.value = stock.code
recordViewedStock(stock.code)
showSearch.value = false
lastCashEstimate.value = null
lastCashEstimateKey.value = ''
}
const rejectPendingRpc = (reason: Error) => {
for (const pending of rpcPending.values()) pending.reject(reason)
rpcPending.clear()
}
const appendRealtimePricePoint = (price: number, at = new Date()) => {
if (!Number.isFinite(price) || price <= 0) return
realtimePricePoints.value = [
...realtimePricePoints.value,
{ at: at.toISOString(), price },
].slice(-maxRealtimePricePoints)
}
const stopBoardPolling = () => {
const subscriptionId = boardPollingSubscriptionId
boardPollingSubscriptionId = ''
boardPollingRequestId += 1
pricePolling.value = false
if (subscriptionId) {
call('market.issue.pollBoard.unsubscribe', { subscriptionId })
}
}
const handleBoardPollingUpdate = (params: unknown) => {
const payload = asRecord(params)
const subscriptionId = textValue(payload.subscriptionId)
if (!subscriptionId || subscriptionId !== boardPollingSubscriptionId) return
const stock = stockFromBoard(payload.board, {
code: selectedStock.value.code,
market: selectedStock.value.market,
name: selectedStock.value.name,
})
if (stock.code && stock.code !== selectedStock.value.code) return
mergeStocks([stock])
appendRealtimePricePoint(stock.price)
}
const handleRpcMessage = (data: string) => {
let response: JsonRpcResponse
try {
response = JSON.parse(data) as JsonRpcResponse
} catch {
return
}
if (response.method === 'market.issue.pollBoard.update') {
handleBoardPollingUpdate(response.params)
return
}
if (response.method === 'market.issue.pollBoard.error') {
const payload = asRecord(response.params)
if (textValue(payload.subscriptionId) === boardPollingSubscriptionId) {
pricePolling.value = false
reportDataError(textValue(payload.message, '価格ポーリングに失敗しました'))
}
return
}
if (typeof response.id !== 'number') return
const pending = rpcPending.get(response.id)
if (!pending) return
rpcPending.delete(response.id)
if (response.error) {
pending.reject(new Error(response.error.message || 'RPC request failed'))
} else {
pending.resolve(response.result)
}
}
const call = (method: string, params?: unknown) => {
const socket = ws.value
if (!socket || socket.readyState !== WebSocket.OPEN) return undefined
const payload: RpcMessage = { id: ++rpcId, method, params }
socket.send(JSON.stringify({ jsonrpc: '2.0', ...payload }))
return payload.id
}
const rpcCall = async <T>(method: string, params?: unknown): Promise<T> => {
const id = call(method, params)
if (!id) throw new Error('SBI session is not connected')
return new Promise<T>((resolve, reject) => {
rpcPending.set(id, {
resolve: (value) => resolve(value as T),
reject,
})
})
}
const rpcCallOptional = async <T>(
method: string,
params?: unknown,
timeoutMs = 8_000,
): Promise<T> => {
const id = call(method, params)
if (!id) throw new Error('SBI session is not connected')
return new Promise<T>((resolve, reject) => {
const timeout = window.setTimeout(() => {
rpcPending.delete(id)
reject(new Error(`${method} timed out`))
}, timeoutMs)
rpcPending.set(id, {
resolve: (value) => {
window.clearTimeout(timeout)
resolve(value as T)
},
reject: (reason) => {
window.clearTimeout(timeout)
reject(reason)
},
})
})
}
const mergeStocks = (nextStocks: Stock[]) => {
const merged = new Map(stocks.value.map((stock) => [stock.code, stock]))
for (const stock of nextStocks) {
if (!stock.code) continue
const current = merged.get(stock.code)
merged.set(stock.code, {
...current,
...stock,
name: stock.name || current?.name || stock.code,
price: stock.price || current?.price || 0,
change: stock.change || current?.change || 0,
changeAmount: stock.changeAmount || current?.changeAmount || 0,
history: stock.price ? stock.history : (current?.history ?? stock.history),
box: stock.price ? stock.box : (current?.box ?? stock.box),
})
}
stocks.value = [...merged.values()]
if (!selectedStockCode.value) {
const nextCode = stocks.value[0]?.code ?? ''
if (nextCode) {
selectedStockCode.value = nextCode
recordViewedStock(nextCode)
}
}
}
const loadOrderHistoryFromSdk = async () => {
orderHistoryLoaded.value = false
orderHistoryNotice.value = ''
const [openOrdersResult, executionsTodayResult] = await Promise.allSettled([
rpcCallOptional<RecordLike>('orders.inquiry.open'),
rpcCallOptional<RecordLike>('orders.inquiry.executionsToday'),
])
if (openOrdersResult.status === 'rejected' && executionsTodayResult.status === 'rejected') {
throw openOrdersResult.reason
}
const nextOrders = fulfilledValues([openOrdersResult, executionsTodayResult])
.flatMap((orderList) => asArray(orderList.orders))
.map(orderFromApi)
.filter((order): order is OrderRow => Boolean(order))
const deduped = new Map<string, OrderRow>()
for (const order of nextOrders) deduped.set(orderHistoryKey(order), order)
orders.value = [...deduped.values()]
orderHistoryLoaded.value = true
if (!orders.value.length) {
const notices = fulfilledValues([openOrdersResult, executionsTodayResult])
.map(orderHistoryResultNotice)
.filter(Boolean)
orderHistoryNotice.value = [...new Set(notices)].join(' / ')
}
}
const loadTradingData = async () => {
dataLoading.value = true
try {
const cashPositions = await rpcCallOptional<RecordLike>(
'account.positions.cash',
undefined,
15_000,
)
const nextPositions = asArray(cashPositions.positions)
.map(positionFromApi)
.filter((position): position is Position => Boolean(position))
positions.value = nextPositions
mergeStocks(nextPositions.map(stockFromPosition))
const nextHoldingsMarketValue = numberValue(
cashPositions.totalMarketValue,
nextPositions.reduce((sum, position) => sum + position.marketValue, 0),
)
holdingsMarketValue.value = nextHoldingsMarketValue
totalProfitLoss.value = numberValue(cashPositions.totalProfitLoss)
totalProfitLossRate.value = numberValue(cashPositions.totalProfitLossRate)
const [orderHistoryResult, powerResult] = await Promise.allSettled([
loadOrderHistoryFromSdk(),
rpcCallOptional<RecordLike>('account.power.buyingPower'),
])
if (powerResult.status === 'fulfilled') {
buyingPower.value = numberValue(
powerResult.value.cashBuyingPower ?? powerResult.value.withdrawableAmount,
)
}
if (orderHistoryResult.status === 'rejected') {
reportDataError(
errorMessage(orderHistoryResult.reason, '取引履歴の取得に失敗しました'),
orderHistoryResult.reason,
)
}
const boards = await Promise.allSettled(
nextPositions.slice(0, 20).map((position) =>
rpcCallOptional<RecordLike>(
'market.issue.board',
{
issueCode: position.code,
},
8_000,
),
),
)
mergeStocks(
boards.flatMap((result, index) =>
result.status === 'fulfilled'
? [
stockFromBoard(result.value, {
code: nextPositions[index]?.code ?? '',
market: nextPositions[index]?.market ?? '',
name: nextPositions[index]?.name ?? '',
}),
]
: [],
),
)
} finally {
dataLoading.value = false
}
}
const connect = () => {
const previousSocket = ws.value
rejectPendingRpc(new Error('RPC socket reconnecting'))
stopBoardPolling()
previousSocket?.close()
sbiConnected.value = false
dataLoading.value = true
if (!selectedPasskeyId.value) {
dataLoading.value = false
reportDataError('SBIパスキーを選択してください')
return
}
const socket = createRpcSocket()
socket.addEventListener('open', async () => {
try {
await rpcCall('sbi.connect', { passkeyId: selectedPasskeyId.value })
sbiConnected.value = true
await loadTradingData()
} catch (cause) {
sbiConnected.value = false
reportDataError(errorMessage(cause, '接続に失敗しました'), cause)
socket.close()
} finally {
dataLoading.value = false
}
})
socket.addEventListener('message', (event) => handleRpcMessage(String(event.data)))
socket.addEventListener('error', () => {
if (ws.value !== socket) return
reportDataError('SBI接続に失敗しました')
})
socket.addEventListener('close', () => {
if (ws.value !== socket) return
rejectPendingRpc(new Error('RPC socket closed'))
boardPollingSubscriptionId = ''
chartHistoryRequestId += 1
historicalPricePoints.value = []
realtimePricePoints.value = []
pricePolling.value = false
sbiConnected.value = false
dataLoading.value = false
})
ws.value = socket
}
const startBoardPolling = async () => {
stopBoardPolling()
realtimePricePoints.value = []
const stock = selectedStock.value
if (!connected.value || !stock.code) return
appendRealtimePricePoint(stock.price)
const requestId = ++boardPollingRequestId
try {
const subscribed = await rpcCall<RecordLike>('market.issue.pollBoard.subscribe', {
issueCode: stock.code,
market: stock.market || undefined,
})
if (requestId !== boardPollingRequestId) {
const staleSubscriptionId = textValue(subscribed.subscriptionId)
if (staleSubscriptionId) {
call('market.issue.pollBoard.unsubscribe', { subscriptionId: staleSubscriptionId })
}
return
}
boardPollingSubscriptionId = textValue(subscribed.subscriptionId)
pricePolling.value = Boolean(boardPollingSubscriptionId)
} catch (cause) {
if (requestId === boardPollingRequestId) {
pricePolling.value = false
reportDataError(errorMessage(cause, '価格ポーリングの開始に失敗しました'), cause)
}
}
}
const loadSelectedStockChart = async () => {
historicalPricePoints.value = []
const stock = selectedStock.value
if (!connected.value || !stock.code) return
const requestId = ++chartHistoryRequestId
try {
const chart = await rpcCall<RecordLike>('market.issue.chart', {
issueCode: stock.code,
market: stock.market || undefined,
period: 'day',
count: 120,
})
if (requestId !== chartHistoryRequestId) return
historicalPricePoints.value = pricePointsFromIssueChart(chart)
} catch (cause) {
if (requestId === chartHistoryRequestId) {
reportDataError(errorMessage(cause, '価格履歴の取得に失敗しました'), cause)
}
}
}
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)
.map(issueFrom)
.filter((issue) => issue.code)
mergeStocks(issues.map(stockFromIssue))
const boards = await Promise.allSettled(
issues.map((issue) =>
rpcCall<RecordLike>('market.issue.board', {
issueCode: issue.code,
market: issue.market || undefined,
}).then((board) => stockFromBoard(board, issue)),
),
)
mergeStocks(
boards
.filter((result): result is PromiseFulfilledResult<Stock> => result.status === 'fulfilled')
.map((result) => result.value),
)
}
const estimateCashOrder = async () => {
if (!canRequestCashEstimate.value) return
lastCashEstimate.value = null
lastCashEstimateKey.value = ''
pendingCashEstimateId.value = null
const preview = await rpcCall<unknown>('orders.cash.estimate', cashOrderParams())
if (isOrderPreview(preview)) {
lastCashEstimate.value = preview
lastCashEstimateKey.value = cashOrderKey.value
showEstimateDialog.value = true
}
}
const cashOrderParams = () => ({
issueCode: selectedStock.value.code,
market: resolvedCashOrderMarket.value || undefined,
side: tradeSide.value,
quantity: orderQuantity.value,
kind: orderKind.value === 's' ? 's' : undefined,
accountType: 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,
secondaryPriceCondition:
orderKind.value !== 's' && cashOrderMethod.value === 'oco'
? cashOrderSecondaryPriceCondition.value
: undefined,
secondaryPrice:
orderKind.value !== 's' &&
cashOrderMethod.value === 'oco' &&
cashOrderSecondaryRequiresPrice.value
? cashOrderSecondaryPrice.value
: undefined,
})
const askPlaceOrder = () => {
if (!canPlaceCashOrder.value) return
showEstimateDialog.value = false
showOrderDialog.value = true
}
const placeCashOrder = async () => {
if (!canPlaceCashOrder.value || !lastCashEstimate.value) return
const receipt = await rpcCall<RecordLike>('orders.cash.place', {
...cashOrderParams(),
confirmationId: lastCashEstimate.value.confirmationId,
allowTrading: true,
})
orders.value = [
{
id: textValue(receipt.orderId, `ord-${Date.now()}`),
date: textValue(receipt.acceptedAt, new Date().toLocaleString('ja-JP')),
stock: selectedStock.value.name,
side: tradeSide.value,
kind: orderKind.value,
quantity: orderQuantity.value,
price: orderPrice.value,
status: '注文中',
},
...orders.value,
]
showOrderDialog.value = false
await loadTradingData()
}
const cancelOrder = async (order: OrderRow) => {
await rpcCall('orders.cash.placeCancel', {
orderNumber: order.orderNumber || order.id,
orderId: order.id,
tradeId: order.tradeId || undefined,
allowTrading: true,
})
order.status = '取消済'
await loadTradingData()
}
const downloadCsv = () => {
const header = ['code', 'name', 'symbol', 'market', 'price'].join(',')
const rows = selectedStock.value.history.map((price) =>
[
selectedStock.value.code,
selectedStock.value.name,
selectedStock.value.symbol,
selectedStock.value.market,
price,
]
.map((value) => `"${String(value).replaceAll('"', '""')}"`)
.join(','),
)
const blob = new Blob([[header, ...rows].join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${selectedStock.value.code}-history.csv`
anchor.click()
URL.revokeObjectURL(url)
}
const openTradeForStock = (stock: Stock, navigate: () => void) => {
selectStock(stock)
navigate()
}
const openTradeForPosition = (code: string, navigate: () => void) => {
const stock = stocks.value.find((candidate) => candidate.code === code)
openTradeForStock(stock ?? selectedStock.value, navigate)
}
let searchTimer: ReturnType<typeof setTimeout> | undefined
let searchRequestId = 0
watch(orderKind, (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(cashOrderPriceCondition, (condition) => {
if (!cashOrderPriceConditionRequiresPrice(condition)) priceInput.value = ''
})
watch(cashOrderSecondaryPriceCondition, (condition) => {
if (!cashOrderPriceConditionRequiresPrice(condition)) cashOrderSecondaryPriceInput.value = ''
})
watch(cashOrderTerm, (term) => {
if (term !== 'date') cashOrderDateInput.value = ''
})
watch(cashOrderMethod, (method) => {
if (method === 'normal') {
cashOrderTriggerPriceInput.value = ''
cashOrderSecondaryPriceInput.value = ''
return
}
if (method === 'stop') cashOrderSecondaryPriceInput.value = ''
})
watch(searchQuery, (query) => {
clearTimeout(searchTimer)
const trimmed = query.trim()
if (trimmed.length < 2 || !connected.value) {
searchLoading.value = false
return
}
searchLoading.value = true
searchTimer = setTimeout(async () => {
const requestId = ++searchRequestId
try {
await searchIssues(query)
} catch (cause) {
reportDataError(errorMessage(cause, '銘柄検索に失敗しました'), cause)
} finally {
if (requestId === searchRequestId) {
searchLoading.value = false
}
}
}, 350)
})
watch(
[connected, () => selectedStock.value.code, () => selectedStock.value.market],
() => {
void loadSelectedStockChart()
void startBoardPolling()
},
{ immediate: true },
)
return {
selectedStockCode,
tradeSide,
orderKind,
cashOrderAccountType,
cashOrderMarket,
cashOrderPriceCondition,
cashOrderTerm,
cashOrderDateInput,
cashOrderMethod,
cashOrderTriggerZone,
cashOrderTriggerPriceInput,
cashOrderSecondaryPriceCondition,
cashOrderSecondaryPriceInput,
quantityInput,
priceInput,
chartMode,
showSearch,
searchQuery,
countryFilter,
marketFilter,
showEstimateDialog,
showOrderDialog,
lastCashEstimate,
connected,
dataLoading,
searchLoading,
buyingPower,
holdingsMarketValue,
totalProfitLoss,
totalProfitLossRate,
orders,
orderHistoryLoaded,
orderHistoryNotice,
positions,
realtimePricePoints,
chartPricePoints,
pricePolling,
selectedStock,
orderQuantity,
orderPrice,
cashOrderPrimaryRequiresPrice,
cashOrderTriggerPrice,
cashOrderSecondaryPrice,
estimatedAmount,
showPortfolioSpinner,
canRequestCashEstimate,
canPlaceCashOrder,
countries,
markets,
viewedStocks,
filteredStocks,
selectedPosition,
recentOrders,
totalAssetValue,
stockAssetRatio,
cashAssetRatio,
boxPlotStyle,
hasQuote,
selectStock,
connect,
loadTradingData,
estimateCashOrder,
askPlaceOrder,
placeCashOrder,
cancelOrder,
downloadCsv,
openTradeForStock,
openTradeForPosition,
}
}
+6
View File
@@ -0,0 +1,6 @@
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { router } from './router'
createApp(App).use(router).mount('#app')
+20
View File
@@ -0,0 +1,20 @@
import { createRouter, createWebHistory } from 'vue-router'
const RouteStub = { template: '<span />' }
export const routeNames = ['portfolio', 'trade', 'history', 'settings'] as const
export type RouteName = (typeof routeNames)[number]
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', redirect: '/portfolio' },
{ path: '/portfolio', name: 'portfolio', component: RouteStub },
{ path: '/trade', name: 'trade', component: RouteStub },
{ path: '/history', name: 'history', component: RouteStub },
{ path: '/api-keys', redirect: '/settings' },
{ path: '/settings', name: 'settings', component: RouteStub },
{ path: '/oauth/authorize', name: 'oauthAuthorize', component: RouteStub },
{ path: '/:pathMatch(.*)*', redirect: '/portfolio' },
],
})
+14
View File
@@ -0,0 +1,14 @@
@import 'tailwindcss';
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
min-width: 320px;
overflow: hidden;
background: #101418;
}
+162
View File
@@ -0,0 +1,162 @@
export const ui = {
appShell: 'grid h-dvh overflow-hidden grid-cols-[7rem_minmax(0,1fr)] bg-[#101418] text-[#e3e3e9]',
sidebar: 'flex h-full min-h-0 flex-col items-center border-r border-[#2f3338] bg-[#191c20] py-4',
brandMark:
'grid h-14 w-14 place-items-center rounded-[18px] bg-[#a8c7fa] text-3xl font-black text-[#0b305f] shadow-lg shadow-black/20',
navStack: 'mt-10 grid w-full gap-4 px-2',
navButton:
'grid min-h-16 place-items-center gap-1 rounded-[20px] bg-transparent p-1 text-xs font-semibold text-[#c3c7cf] transition hover:bg-[#22272e]',
navButtonActive: 'text-[#d3e3fd]',
navIcon: 'grid h-8 w-16 place-items-center rounded-full text-[#c3c7cf]',
navIconActive: 'bg-[#263141] text-[#d3e3fd]',
workspace: 'flex min-h-0 min-w-0 flex-col gap-7 overflow-y-auto px-8 pt-8 pb-8',
topbar: 'flex shrink-0 items-center justify-between gap-4',
tradeTopbar: 'hidden',
authPanel: 'grid justify-center pt-12',
panel:
'grid content-start gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-lg shadow-black/15',
loginPanel: 'w-[28rem]',
panelHead: 'flex items-center justify-between gap-3',
eyebrow: 'mb-1 text-xs font-black uppercase text-[#9aa0a9]',
dashboardGrid:
'grid min-h-0 flex-1 grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)] grid-rows-[auto_minmax(0,1fr)] items-stretch gap-6 overflow-hidden',
metricPanel:
'grid min-h-40 content-center gap-3 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15',
assetOverviewPanel:
'grid min-h-40 grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] items-center gap-x-7 gap-y-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15',
assetOverviewHead: 'grid gap-2',
assetOverviewSubtext: 'text-sm font-semibold text-[#c3c7cf]',
assetBreakdownPanel: 'grid gap-3',
assetBreakdownTitle: 'text-xs font-extrabold text-[#9aa0a9]',
metricLabel: 'text-xs font-extrabold text-[#9aa0a9]',
metricValue: 'text-3xl font-black',
positive: 'text-[#40dba2]',
negative: 'text-[#ffb4ab]',
miniProgress: 'block h-2 w-full overflow-hidden rounded-full bg-[#33383f]',
miniProgressBar: 'block h-full w-[12%] rounded-full bg-[#a8c7fa]',
assetBreakdownBar: 'flex h-3 w-full overflow-hidden rounded-full bg-[#33383f]',
assetBreakdownStocks: 'block h-full bg-[#a8c7fa]',
assetBreakdownCash: 'block h-full bg-[#40dba2]',
assetBreakdownRows: 'grid gap-2',
assetBreakdownRow: 'flex items-center justify-between gap-4',
assetBreakdownLabel: 'inline-flex items-center gap-2 text-sm font-semibold text-[#c3c7cf]',
assetBreakdownSwatch: 'block h-2.5 w-2.5 shrink-0 rounded-full',
assetBreakdownSwatchStocks: 'bg-[#a8c7fa]',
assetBreakdownSwatchCash: 'bg-[#40dba2]',
assetBreakdownMeta: 'grid justify-items-end gap-0.5 text-right',
assetBreakdownAmount: 'text-sm font-black text-[#e3e3e9]',
assetBreakdownRatio: 'text-xs font-semibold text-[#8f949d]',
holdingsPanel:
'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15',
holdingsBody: 'flex min-h-0 flex-1 flex-col overflow-hidden',
holdingsRows: 'grid min-h-0 flex-1 content-start overflow-y-auto',
holdingsHead:
'grid grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 border-b border-[#33383f] py-3 text-xs font-extrabold text-[#8f949d]',
holdingRow:
'grid min-h-16 grid-cols-[1.7fr_0.8fr_0.7fr_1fr_1fr] items-center gap-4 rounded-2xl bg-transparent px-3 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930]',
typePill: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs text-[#d3e3fd]',
muted: 'text-[#8f949d]',
portfolioHistory:
'flex min-h-0 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-7 shadow-lg shadow-black/15',
historyList: 'flex min-h-0 flex-1 flex-col overflow-hidden',
historyRows: 'grid min-h-0 flex-1 content-start gap-7 overflow-y-auto',
emptyState: 'flex flex-1 items-center justify-center py-8 text-center',
miniOrder: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3',
tradeLayout: 'trade-layout grid w-full min-h-[calc(100dvh-6rem)] grid-cols-5 items-stretch gap-5',
watchlist:
'order-3 pl-2 col-span-1 grid h-full min-h-[calc(100dvh-6rem)] content-start overflow-y-auto border-l border-[#30343a] bg-[#101418] shadow-none',
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',
tradeSeparator: 'order-3 hidden',
stockPanel:
'grid min-h-72 content-start gap-4 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none',
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]',
quoteBox: 'grid justify-items-end gap-1',
periodTabs: 'flex justify-end gap-3',
periodButton: 'min-h-7 rounded-full bg-transparent px-3 text-xs font-bold text-[#8f949d]',
periodButtonActive: 'bg-[#d3e3fd] text-[#102033]',
chartActions: 'flex items-center justify-between gap-4',
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]',
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]',
boxplotTrack:
'relative h-20 before:absolute before:inset-x-0 before:top-10 before:h-0.5 before:bg-[#4a5058]',
whisker: 'absolute top-9 h-3 border-x-2 border-[#c3c7cf]',
box: 'absolute top-6 h-9 rounded-xl border-2 border-[#a8c7fa] bg-[#a8c7fa]/25',
median: 'absolute top-4 h-12 border-l-4 border-[#fdd663]',
infoTabPanel: 'grid gap-0 rounded-[24px] border-0 bg-[#101418] p-6 shadow-none',
infoTabList: 'flex gap-6 border-b border-[#33383f]',
infoTabButton:
'relative min-h-10 px-1 text-sm font-black text-[#8f949d] transition hover:text-[#d3e3fd] focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[#d3e3fd]',
infoTabButtonActive: 'text-[#d3e3fd]',
infoTabIndicator: 'absolute inset-x-0 bottom-[-1px] block h-0.5 rounded-full bg-[#d3e3fd]',
infoTabBody: 'overflow-hidden pt-4',
detailGrid: 'grid grid-cols-3 gap-3',
detailItem: 'grid min-h-16 gap-1 rounded-[14px] bg-[#111418] p-3',
holdingEmpty:
'rounded-[16px] border border-[#2f343b] bg-[#111418] px-4 py-3 text-sm font-semibold text-[#8f949d]',
ticketPanel:
'order-2 col-span-1 flex h-full min-h-0 flex-col gap-4 overflow-hidden border-0 bg-[#101418] p-6 shadow-none',
ticketTop: 'shrink-0',
ticketScroll: 'min-h-0 flex-1 overflow-y-auto pr-1',
ticketScrollInner: 'grid content-start gap-3',
ticketBottom: 'grid shrink-0 gap-3',
ticketBox: 'grid grid-cols-1 gap-3',
advancedOptions: 'group bg-transparent p-0',
advancedSummary:
'flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 rounded-[16px] bg-[#111418] px-4 text-sm font-extrabold text-[#d3e3fd] transition hover:bg-[#182029] marker:hidden [&::-webkit-details-marker]:hidden',
advancedSummaryIconWrap: 'grid h-4 w-4 place-items-center',
advancedSummaryIcon: 'h-4 w-4',
advancedBody: 'grid gap-2 pt-3',
advancedLabel: 'text-xs font-extrabold text-[#9aa0a9]',
label: 'grid gap-2 text-xs font-extrabold text-[#9aa0a9]',
input:
'min-h-12 w-full rounded-[16px] border border-[#4a5058] bg-[#111418] px-4 text-[#e3e3e9] outline-none transition placeholder:text-[#747982] focus:border-[#a8c7fa]',
estimateSummary:
'flex items-center justify-between rounded-[18px] border border-[#2f343b] bg-[#111418] px-5 py-4',
holdingNote:
'rounded-[16px] border border-[#2f343b] bg-[#111418] px-4 py-3 text-sm font-semibold text-[#c3c7cf]',
actions: 'grid gap-3',
primaryButton:
'inline-flex min-h-10 items-center justify-center gap-2 rounded-full bg-[#a8c7fa] px-5 font-extrabold text-[#102033] shadow-sm shadow-black/20 transition hover:bg-[#d3e3fd] disabled:opacity-50',
dangerButton:
'inline-flex min-h-10 items-center justify-center gap-2 rounded-full bg-[#ffb4ab] px-5 font-extrabold text-[#690005] shadow-sm shadow-black/20 transition hover:bg-[#ffd8d3] disabled:opacity-50',
ghostButton:
'inline-flex min-h-10 items-center justify-center gap-2 rounded-full border border-[#4a5058] bg-transparent px-5 font-extrabold text-[#d3e3fd] transition hover:bg-[#263141]',
list: 'grid gap-2',
orderRow:
'grid grid-cols-[minmax(180px,1fr)_auto_auto_auto_auto_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4',
statusBadge: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs font-black text-[#d3e3fd]',
pendingBadge: 'bg-[#4a3720] text-[#ffddb3]',
apiLayout: 'grid grid-cols-[minmax(0,1fr)_minmax(20rem,0.9fr)] gap-7',
settingsLayout: 'grid gap-7',
row: 'grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4',
rowActions: 'flex gap-2',
keyRow: 'grid gap-3 rounded-[24px] bg-[#111418] p-4',
profileRow:
'grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-[20px] bg-[#111418] p-4',
searchOverlay: 'fixed inset-0 z-20 bg-[#101418]/75 backdrop-blur-sm',
searchSheet:
'mx-auto mt-4 grid max-h-[85vh] w-[48rem] gap-3 overflow-auto rounded-[28px] border border-[#30343a] bg-[#1b1f24]/85 p-4 shadow-2xl shadow-black/35',
searchInputRow: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3',
filterRow: 'grid grid-cols-2 gap-3',
searchResults: 'grid gap-2',
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]',
confirmList: 'grid gap-2',
confirmRow: 'flex justify-between gap-3 border-b border-[#33383f] pb-2',
dialogNote: 'leading-7 text-[#c3c7cf]',
} as const
+102
View File
@@ -0,0 +1,102 @@
export type TradeSide = 'buy' | 'sell'
export type OrderKind = 'standard' | 's'
export type CashOrderAccountType = 'specific' | 'general'
export type CashOrderMarket = 'auto' | 'TKY' | 'SOR' | 'PTS' | 'PTX' | 'STK'
export type CashOrderPriceCondition =
| 'limit'
| 'limitAtOpen'
| 'limitAtClose'
| 'limitIoc'
| 'market'
| 'marketAtOpen'
| 'marketAtClose'
| 'marketIoc'
| 'funari'
export type CashOrderTerm = 'day' | 'week' | 'date'
export type CashOrderMethod = 'normal' | 'stop' | 'oco'
export type CashOrderTriggerZone = 'above' | 'below'
export type ChartMode = 'line' | 'box'
export type RpcMessage = {
id: number
method: string
params?: unknown
}
export type OrderPreview = {
issue: {
code: string
market: string
}
side: string
quantity: number
warnings: string[]
confirmationId?: string
message?: string
}
export type JsonRpcResponse = {
id?: number
method?: string
params?: unknown
result?: unknown
error?: {
message?: string
}
}
export type RealtimePricePoint = {
at: string
price: number
}
export type Stock = {
code: string
name: string
symbol: string
country: string
market: string
sector: string
price: number
change: number
changeAmount: number
volume: number
open: number
high: number
low: number
prevClose: number
sShare: boolean
history: number[]
box: {
min: number
q1: number
median: number
q3: number
max: number
}
}
export type OrderRow = {
id: string
date: string
stock: string
side: TradeSide
kind: OrderKind
quantity: number | null
price: number | null
status: '注文中' | '約定済' | '取消済'
orderNumber?: string
tradeId?: string
}
export type Position = {
code: string
name: string
market: string
quantity: number
avgPrice: number
marketValue: number
profitLoss: number
profitLossRate: number
type?: string
}
+14
View File
@@ -0,0 +1,14 @@
export const currency = (value: number) =>
new Intl.NumberFormat('ja-JP', {
style: 'currency',
currency: 'JPY',
maximumFractionDigits: 0,
}).format(value)
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 signedPercent = (value: number, fractionDigits = 2) =>
`${value >= 0 ? '+' : ''}${value.toFixed(fractionDigits)}%`
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"jsx": "preserve"
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
const serverPort = Number(process.env.PORT ?? process.env.CSBIE_SERVER_PORT ?? 8787)
const serverOrigin = process.env.CSBIE_SERVER_ORIGIN ?? `http://127.0.0.1:${serverPort}`
const proxyToServer = {
target: serverOrigin,
changeOrigin: true,
ws: true,
}
export default defineConfig({
plugins: [vue(), tailwindcss()],
server: {
port: Number(process.env.CSBIE_UI_DEV_PORT ?? 5173),
strictPort: true,
hmr: {
host: '127.0.0.1',
clientPort: Number(process.env.CSBIE_UI_DEV_PORT ?? 5173),
},
proxy: {
'/api': {
...proxyToServer,
rewrite: (path) => path.replace(/^\/api/, ''),
},
'/.well-known': proxyToServer,
'/authorize': proxyToServer,
'/token': proxyToServer,
'/register': proxyToServer,
'/revoke': proxyToServer,
'/oauth': proxyToServer,
},
},
})