feat: introduce mnie
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
<script setup lang="ts">
|
||||
import { AnimatePresence } from 'motion-v'
|
||||
import { computed, onMounted, watch } 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 { stockIdFromTradeRouteId, tradeRouteIdFromStockId } from './constants/trade'
|
||||
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 isOAuthRoute = computed(() => route.name === 'oauthAuthorize')
|
||||
const showAuthGate = computed(() => true)
|
||||
const decodedRouteParam = (value: string) => {
|
||||
try {
|
||||
return decodeURIComponent(value)
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
const tradePath = (id: string) => `/trade/${encodeURIComponent(id)}`
|
||||
const tradeId = computed(() =>
|
||||
typeof route.params.id === 'string'
|
||||
? stockIdFromTradeRouteId(decodedRouteParam(route.params.id))
|
||||
: '',
|
||||
)
|
||||
const navigateTrade = (id = selectedStock.value.symbol || selectedStockCode.value) => {
|
||||
if (!id) return
|
||||
void router.push(tradePath(tradeRouteIdFromStockId(id)))
|
||||
}
|
||||
const navigate = (name: RouteName) => {
|
||||
if (name === 'settings') return router.push('/settings/api-keys')
|
||||
if (name === 'trade') return navigateTrade()
|
||||
return router.push({ name })
|
||||
}
|
||||
|
||||
const {
|
||||
status,
|
||||
apiKeys,
|
||||
sbiPasskeys,
|
||||
selectedPasskeyId,
|
||||
setupPassword,
|
||||
authBusy,
|
||||
apiKeyLabel,
|
||||
newApiKeySettings,
|
||||
newApiToken,
|
||||
sbiLabel,
|
||||
sbiCredentialJson,
|
||||
tradePassword,
|
||||
sbiDeviceId,
|
||||
refresh,
|
||||
addApiKey,
|
||||
saveApiKeySettings,
|
||||
setupOwnerPasskey,
|
||||
loginWithPasskey,
|
||||
addSbiPasskey,
|
||||
removeSbiPasskey,
|
||||
} = useAuthAdmin()
|
||||
|
||||
const {
|
||||
selectedStockCode,
|
||||
selectedStockId,
|
||||
tradeSide,
|
||||
orderKind,
|
||||
cashOrderAccountType,
|
||||
cashOrderMarket,
|
||||
cashOrderPriceCondition,
|
||||
cashOrderTerm,
|
||||
cashOrderDateInput,
|
||||
cashOrderMethod,
|
||||
cashOrderTriggerZone,
|
||||
cashOrderTriggerPriceInput,
|
||||
cashOrderSecondaryPriceCondition,
|
||||
cashOrderSecondaryPriceInput,
|
||||
quantityInput,
|
||||
priceInput,
|
||||
chartMode,
|
||||
chartRange,
|
||||
showSearch,
|
||||
searchQuery,
|
||||
countryFilter,
|
||||
marketFilter,
|
||||
showEstimateDialog,
|
||||
showOrderDialog,
|
||||
lastCashEstimate,
|
||||
connected,
|
||||
dataLoading,
|
||||
searchLoading,
|
||||
buyingPower,
|
||||
holdingsMarketValue,
|
||||
totalProfitLoss,
|
||||
totalProfitLossRate,
|
||||
marketIndexes,
|
||||
orders,
|
||||
cancelingOrderKey,
|
||||
orderHistoryLoaded,
|
||||
orderHistoryNotice,
|
||||
positions,
|
||||
chartPricePoints,
|
||||
chartNotice,
|
||||
pricePolling,
|
||||
selectedStock,
|
||||
orderQuantity,
|
||||
orderPrice,
|
||||
cashOrderPrimaryRequiresPrice,
|
||||
cashOrderTriggerPrice,
|
||||
cashOrderSecondaryPrice,
|
||||
cashOrderAccountTypeOptions,
|
||||
cashOrderMarketOptions,
|
||||
cashOrderTermOptions,
|
||||
cashOrderDateOptions,
|
||||
cashOrderPriceStep,
|
||||
estimatedAmount,
|
||||
showPortfolioSpinner,
|
||||
canRequestCashEstimate,
|
||||
canPlaceCashOrder,
|
||||
countries,
|
||||
markets,
|
||||
viewedStocks,
|
||||
filteredStocks,
|
||||
selectedPosition,
|
||||
recentOrders,
|
||||
totalAssetValue,
|
||||
stockAssetRatio,
|
||||
cashAssetRatio,
|
||||
hasQuote,
|
||||
selectStock,
|
||||
selectStockByCode,
|
||||
connect,
|
||||
loadTradingData,
|
||||
estimateCashOrder,
|
||||
askPlaceOrder,
|
||||
placeCashOrder,
|
||||
cancelOrder,
|
||||
loadOrderDetail,
|
||||
loadTradeRecords,
|
||||
loadPositionDetail,
|
||||
estimateOrderCorrection,
|
||||
placeOrderCorrection,
|
||||
downloadCsv,
|
||||
openTradeForStock,
|
||||
openTradeForPosition,
|
||||
} = 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()
|
||||
}
|
||||
|
||||
watch(
|
||||
tradeId,
|
||||
(id) => {
|
||||
if (activeTab.value !== 'trade' || !id) return
|
||||
selectStockByCode(id)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
[activeTab, () => selectedStock.value.symbol, selectedStockId, selectedStockCode],
|
||||
([tab]) => {
|
||||
const id = selectedStock.value.symbol || selectedStockId.value || selectedStockCode.value
|
||||
if (tab !== 'trade' || !id || tradeId.value === id) return
|
||||
void router.replace(tradePath(tradeRouteIdFromStockId(id)))
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await refreshAndMaybeConnect()
|
||||
} catch {
|
||||
status.value = { configured: false, authenticated: false }
|
||||
}
|
||||
await loadOAuthApproval()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main :class="ui.appShell">
|
||||
<AppSidebar v-if="!isOAuthRoute" :active-tab="activeTab" @navigate="navigate" />
|
||||
|
||||
<section :class="ui.workspace">
|
||||
<AppHeader v-if="!isOAuthRoute && activeTab !== 'settings'" :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"
|
||||
:market-indexes="marketIndexes"
|
||||
:stock-asset-ratio="stockAssetRatio"
|
||||
:cash-asset-ratio="cashAssetRatio"
|
||||
:positions="positions"
|
||||
:recent-orders="recentOrders"
|
||||
:canceling-order-key="cancelingOrderKey"
|
||||
:data-loading="dataLoading"
|
||||
:connected="connected"
|
||||
:order-history-loaded="orderHistoryLoaded"
|
||||
:order-history-notice="orderHistoryNotice"
|
||||
:load-position-detail="loadPositionDetail"
|
||||
@connect="connect"
|
||||
@open-position="
|
||||
(code) =>
|
||||
openTradeForPosition(code, () => void navigateTrade(selectedStock.symbol || code))
|
||||
"
|
||||
@cancel-order="cancelOrder"
|
||||
/>
|
||||
|
||||
<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"
|
||||
v-model:chart-range="chartRange"
|
||||
:viewed-stocks="viewedStocks"
|
||||
:selected-stock="selectedStock"
|
||||
:selected-position="selectedPosition"
|
||||
:connected="connected"
|
||||
:order-quantity="orderQuantity"
|
||||
:estimated-amount="estimatedAmount"
|
||||
:cash-order-account-type-options="cashOrderAccountTypeOptions"
|
||||
:cash-order-market-options="cashOrderMarketOptions"
|
||||
:cash-order-term-options="cashOrderTermOptions"
|
||||
:cash-order-date-options="cashOrderDateOptions"
|
||||
:cash-order-price-step="cashOrderPriceStep"
|
||||
:can-request-cash-estimate="canRequestCashEstimate"
|
||||
:can-place-cash-order="canPlaceCashOrder"
|
||||
:realtime-price-points="chartPricePoints"
|
||||
:chart-notice="chartNotice"
|
||||
:price-polling="pricePolling"
|
||||
:has-quote="hasQuote"
|
||||
@open-search="showSearch = true"
|
||||
@select-stock="
|
||||
(stock) => {
|
||||
selectStock(stock)
|
||||
navigateTrade(stock.symbol || stock.code)
|
||||
}
|
||||
"
|
||||
@download-csv="downloadCsv"
|
||||
@estimate="estimateCashOrder"
|
||||
@confirm-order="askPlaceOrder"
|
||||
/>
|
||||
|
||||
<HistoryView
|
||||
v-if="activeTab === 'history'"
|
||||
:orders="orders"
|
||||
:connected="connected"
|
||||
:data-loading="dataLoading"
|
||||
:canceling-order-key="cancelingOrderKey"
|
||||
:order-history-loaded="orderHistoryLoaded"
|
||||
:order-history-notice="orderHistoryNotice"
|
||||
:load-order-detail="loadOrderDetail"
|
||||
:load-trade-records="loadTradeRecords"
|
||||
:estimate-order-correction="estimateOrderCorrection"
|
||||
:place-order-correction="placeOrderCorrection"
|
||||
@refresh="loadTradingData"
|
||||
@cancel="cancelOrder"
|
||||
/>
|
||||
|
||||
<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 navigateTrade(stock.symbol || stock.code))
|
||||
"
|
||||
/>
|
||||
</AnimatePresence>
|
||||
|
||||
<OrderDialogs
|
||||
:estimate="lastCashEstimate"
|
||||
:show-estimate="showEstimateDialog"
|
||||
:show-order="showOrderDialog"
|
||||
:stock-name="selectedStock.name"
|
||||
:stock-market="selectedStock.market"
|
||||
: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>
|
||||
@@ -0,0 +1,114 @@
|
||||
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
|
||||
scopes?: string[] | null
|
||||
createdAt: string
|
||||
lastUsedAt?: string | null
|
||||
revokedAt?: string | null
|
||||
token?: string
|
||||
}
|
||||
|
||||
export type ApiKeySettings = Pick<
|
||||
ApiKey,
|
||||
| 'maxTradesPerHour'
|
||||
| 'maxTradesPer6Hours'
|
||||
| 'maxTradesPerDay'
|
||||
| 'maxOrderPriceJpy'
|
||||
| 'maxOrderAmountJpy'
|
||||
| 'allowedMethods'
|
||||
| 'scopes'
|
||||
>
|
||||
|
||||
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,213 @@
|
||||
<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.assets.current',
|
||||
'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.detail',
|
||||
'orders.inquiry.executionsToday',
|
||||
'orders.inquiry.open',
|
||||
'orders.inquiry.tradeRecords',
|
||||
'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',
|
||||
'orders.exchange.rate',
|
||||
'orders.exchange.estimate',
|
||||
'orders.exchange.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',
|
||||
'orders.exchange.place',
|
||||
] as const
|
||||
|
||||
const tradingMethodSet = new Set<string>(tradingMethods)
|
||||
const readMethods = rpcMethods.filter((method) => !tradingMethodSet.has(method))
|
||||
const scopes = ['read', 'write', 'trade', 'mcp'] as const
|
||||
|
||||
const toggleScope = (scope: string) => {
|
||||
const current = settings.value.scopes ?? []
|
||||
settings.value.scopes = current.includes(scope)
|
||||
? current.filter((candidate) => candidate !== scope)
|
||||
: [...current, scope].sort()
|
||||
}
|
||||
|
||||
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-1 gap-3 md:grid-cols-2 xl:grid-cols-3',
|
||||
limitGridCompact: 'xl: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',
|
||||
scopeGrid: 'grid grid-cols-2 gap-2 md:grid-cols-4',
|
||||
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-1 gap-2 xl:grid-cols-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.scopeGrid">
|
||||
<label v-for="scope in scopes" :key="scope" :class="ui.methodToggle">
|
||||
<input
|
||||
:class="ui.checkbox"
|
||||
type="checkbox"
|
||||
:checked="settings.scopes?.includes(scope)"
|
||||
@change="toggleScope(scope)"
|
||||
/>
|
||||
<span>{{ scope }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
6時間 取引上限
|
||||
<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="MNIE">
|
||||
<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,91 @@
|
||||
<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
|
||||
step?: string | number
|
||||
autofocus?: boolean
|
||||
}>(),
|
||||
{
|
||||
as: 'input',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
autocomplete: undefined,
|
||||
spellcheck: undefined,
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
min: undefined,
|
||||
step: 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"
|
||||
:step="step"
|
||||
:placeholder="placeholder"
|
||||
:autocomplete="autocomplete"
|
||||
:spellcheck="spellcheck"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { motion } from 'motion-v'
|
||||
import { uiMotion } from '../../constants/motion'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
eyebrow?: string
|
||||
size?: 'sm' | 'lg'
|
||||
closeOnBackdrop?: boolean
|
||||
closeOnEscape?: boolean
|
||||
}>(),
|
||||
{
|
||||
size: 'sm',
|
||||
closeOnBackdrop: true,
|
||||
closeOnEscape: true,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const overlayTransition = uiMotion.modal.openExpressiveSpatial
|
||||
const sheetTransition = uiMotion.modal.sheetOpenExpressive
|
||||
|
||||
const sheetClass = computed(() => [
|
||||
'grid max-h-[calc(100dvh-2rem)] w-full gap-5 overflow-y-auto rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-2xl shadow-black/35 outline-none',
|
||||
props.size === 'lg' ? 'max-w-5xl' : 'max-w-[28rem]',
|
||||
])
|
||||
|
||||
const closeFromBackdrop = () => {
|
||||
if (props.closeOnBackdrop) emit('close')
|
||||
}
|
||||
|
||||
const closeFromEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && props.closeOnEscape) emit('close')
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('keydown', closeFromEscape))
|
||||
onUnmounted(() => window.removeEventListener('keydown', closeFromEscape))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<motion.div
|
||||
class="fixed inset-0 z-30 grid place-items-center overflow-y-auto bg-[#101418]/75 p-4 backdrop-blur-sm"
|
||||
:initial="{ opacity: 0 }"
|
||||
:animate="{ opacity: 1 }"
|
||||
:exit="{ opacity: 0 }"
|
||||
:transition="overlayTransition"
|
||||
@click.self="closeFromBackdrop"
|
||||
>
|
||||
<motion.section
|
||||
:class="sheetClass"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
:initial="{ opacity: 0, y: 18, scale: 0.96 }"
|
||||
:animate="{ opacity: 1, y: 0, scale: 1 }"
|
||||
:exit="{ opacity: 0, y: 12, scale: 0.97 }"
|
||||
:transition="sheetTransition"
|
||||
>
|
||||
<header
|
||||
v-if="title || eyebrow || $slots.actions"
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-[#33383f] pb-4"
|
||||
>
|
||||
<div class="grid min-w-0 gap-1">
|
||||
<p v-if="eyebrow" class="text-xs font-black uppercase text-[#9aa0a9]">{{ eyebrow }}</p>
|
||||
<h2 class="truncate text-xl font-black text-[#e3e3e9]">{{ title }}</h2>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="flex flex-wrap gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<slot />
|
||||
</motion.section>
|
||||
</motion.div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts" generic="T extends string">
|
||||
import { LayoutGroup, motion } from 'motion-v'
|
||||
import { uiMotion } from '../../constants/motion'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: T
|
||||
options: Array<{ label: string; value: T; tone?: 'buy' | 'sell' }>
|
||||
}>()
|
||||
|
||||
const groupId = `segmented-indicator-${Math.random().toString(36).slice(2)}`
|
||||
const activeOption = computed(() =>
|
||||
props.options.find((option) => option.value === props.modelValue),
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: T]
|
||||
}>()
|
||||
|
||||
const toneClass = (tone?: 'buy' | 'sell') => {
|
||||
if (tone === 'buy') return 'text-[#003824]'
|
||||
if (tone === 'sell') return 'text-[#690005]'
|
||||
return 'text-[#d3e3fd]'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LayoutGroup :id="groupId">
|
||||
<div
|
||||
class="relative grid min-h-12 overflow-hidden rounded-full bg-[#111418] p-1 outline outline-1 outline-[#33383f]"
|
||||
:style="{ gridTemplateColumns: `repeat(${props.options.length}, minmax(0, 1fr))` }"
|
||||
>
|
||||
<button
|
||||
v-for="option in props.options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
:class="[
|
||||
'relative min-h-10 rounded-full text-sm font-bold transition hover:bg-[#22272e]',
|
||||
'text-[#9aa0a9]',
|
||||
props.modelValue === option.value && toneClass(option.tone),
|
||||
]"
|
||||
@click="emit('update:modelValue', option.value)"
|
||||
>
|
||||
<motion.span
|
||||
v-if="activeOption?.value === option.value"
|
||||
:key="`${option.value}:active-indicator`"
|
||||
:layout-id="`${groupId}-indicator`"
|
||||
:animate="{ scaleX: [1, 1.1, 1], scaleY: [1, 0.92, 1] }"
|
||||
:transition="uiMotion.trade.segmentedIndicator"
|
||||
class="pointer-events-none absolute inset-0 rounded-full"
|
||||
:class="[
|
||||
option.tone === 'buy' && 'bg-[#40dba2]',
|
||||
option.tone === 'sell' && 'bg-[#ffb4ab]',
|
||||
!option.tone && 'bg-[#263141]',
|
||||
]"
|
||||
/>
|
||||
<span class="relative z-10">{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
export type MarketSession = readonly [openMinutes: number, closeMinutes: number]
|
||||
|
||||
const minutes = (hour: number, minute = 0) => hour * 60 + minute
|
||||
|
||||
export const marketTimeZones: Record<string, string> = {
|
||||
ARCX: 'America/New_York',
|
||||
XNAS: 'America/New_York',
|
||||
XNYS: 'America/New_York',
|
||||
XTKS: 'Asia/Tokyo',
|
||||
}
|
||||
|
||||
export const countryTimeZones: Record<string, string> = {
|
||||
アメリカ: 'America/New_York',
|
||||
日本: 'Asia/Tokyo',
|
||||
米国: 'America/New_York',
|
||||
US: 'America/New_York',
|
||||
USA: 'America/New_York',
|
||||
}
|
||||
|
||||
export const marketSessions: Record<string, readonly MarketSession[]> = {
|
||||
ARCX: [[minutes(9, 30), minutes(16)]],
|
||||
XNAS: [[minutes(9, 30), minutes(16)]],
|
||||
XNYS: [[minutes(9, 30), minutes(16)]],
|
||||
XTKS: [
|
||||
[minutes(9), minutes(11, 30)],
|
||||
[minutes(12, 30), minutes(15, 30)],
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const motionProfiles = {
|
||||
expressive: {
|
||||
spatial: {
|
||||
fast: { duration: 0.35, ease: [0.42, 1.67, 0.21, 0.9] } as const,
|
||||
default: { duration: 0.5, ease: [0.38, 1.21, 0.22, 1] } as const,
|
||||
slow: { duration: 0.65, ease: [0.39, 1.29, 0.35, 0.98] } as const,
|
||||
},
|
||||
effects: {
|
||||
fast: { duration: 0.15, ease: [0.31, 0.94, 0.34, 1] } as const,
|
||||
default: { duration: 0.2, ease: [0.34, 0.8, 0.34, 1] } as const,
|
||||
slow: { duration: 0.3, ease: [0.34, 0.88, 0.34, 1] } as const,
|
||||
},
|
||||
},
|
||||
standard: {
|
||||
spatial: {
|
||||
fast: { duration: 0.35, ease: [0.27, 1.06, 0.18, 1] } as const,
|
||||
default: { duration: 0.5, ease: [0.27, 1.06, 0.18, 1] } as const,
|
||||
slow: { duration: 0.75, ease: [0.27, 1.06, 0.18, 1] } as const,
|
||||
},
|
||||
effects: {
|
||||
fast: { duration: 0.15, ease: [0.31, 0.94, 0.34, 1] } as const,
|
||||
default: { duration: 0.2, ease: [0.34, 0.8, 0.34, 1] } as const,
|
||||
slow: { duration: 0.3, ease: [0.34, 0.88, 0.34, 1] } as const,
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
export const uiMotion = {
|
||||
overlay: {
|
||||
fadeDefault: motionProfiles.standard.effects.default,
|
||||
},
|
||||
modal: {
|
||||
sheetOpen: motionProfiles.standard.spatial.default,
|
||||
sheetOpenExpressive: motionProfiles.expressive.spatial.fast,
|
||||
openExpressiveSpatial: motionProfiles.expressive.spatial.fast,
|
||||
},
|
||||
trade: {
|
||||
tabIndicator: motionProfiles.standard.spatial.default,
|
||||
segmentedIndicator: motionProfiles.expressive.spatial.fast,
|
||||
tabContentFade: motionProfiles.standard.effects.default,
|
||||
advancedPanel: motionProfiles.expressive.effects.default,
|
||||
advancedOptions: motionProfiles.expressive.spatial.fast,
|
||||
searchSheet: motionProfiles.expressive.spatial.fast,
|
||||
},
|
||||
} as const
|
||||
@@ -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 },
|
||||
]
|
||||
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
CashOrderAccountType,
|
||||
CashOrderMarket,
|
||||
CashOrderMethod,
|
||||
CashOrderPriceCondition,
|
||||
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' },
|
||||
{ label: 'NISA成長投資枠', value: 'growthInvestment' },
|
||||
{ label: 'NISA', value: 'nisa' },
|
||||
]
|
||||
|
||||
export const cashOrderMarketOptions: Array<{
|
||||
label: string
|
||||
value: CashOrderMarket
|
||||
}> = [
|
||||
{ label: '自動', value: 'auto' },
|
||||
{ label: '東証', value: 'XTKS' },
|
||||
{ label: '名証', value: 'XNGO' },
|
||||
{ label: '福証', value: 'XFKA' },
|
||||
{ label: '札証', value: 'XSAP' },
|
||||
{ label: 'NASDAQ', value: 'XNAS' },
|
||||
{ label: 'NYSE', value: 'XNYS' },
|
||||
{ label: 'NYSE Arca', value: 'ARCX' },
|
||||
]
|
||||
|
||||
export const searchableMarkets: CashOrderMarket[] = [
|
||||
'XTKS',
|
||||
'XNGO',
|
||||
'XFKA',
|
||||
'XSAP',
|
||||
'XNAS',
|
||||
'XNYS',
|
||||
'ARCX',
|
||||
]
|
||||
|
||||
export const tradeRouteIdFromStockId = (id: string) => {
|
||||
const separator = id.indexOf(':')
|
||||
if (separator <= 0) return id
|
||||
|
||||
const market = id.slice(0, separator).toUpperCase()
|
||||
if (!searchableMarkets.includes(market as CashOrderMarket)) return id
|
||||
return `${market}.${id.slice(separator + 1)}`
|
||||
}
|
||||
|
||||
export const stockIdFromTradeRouteId = (id: string) => {
|
||||
const separator = id.indexOf('.')
|
||||
if (separator <= 0) return id
|
||||
|
||||
const market = id.slice(0, separator).toUpperCase()
|
||||
if (!searchableMarkets.includes(market as CashOrderMarket)) return id
|
||||
return `${market}:${id.slice(separator + 1)}`
|
||||
}
|
||||
|
||||
export const sKabuOrderMarketOptions: Array<{
|
||||
label: string
|
||||
value: CashOrderMarket
|
||||
}> = []
|
||||
|
||||
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 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,11 @@
|
||||
import type { ApiKeySettings } from '../../api'
|
||||
|
||||
export const defaultApiKeyPolicy = (): ApiKeySettings => ({
|
||||
maxTradesPerHour: 10,
|
||||
maxTradesPer6Hours: 30,
|
||||
maxTradesPerDay: 80,
|
||||
maxOrderPriceJpy: null,
|
||||
maxOrderAmountJpy: null,
|
||||
allowedMethods: null,
|
||||
scopes: ['read'],
|
||||
})
|
||||
@@ -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.filter((key) => !key.revokedAt)
|
||||
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,494 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { AnimatePresence } from 'motion-v'
|
||||
import {
|
||||
ArrowLeft,
|
||||
Ban,
|
||||
FileText,
|
||||
Pencil,
|
||||
ReceiptText,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from 'lucide-vue-next'
|
||||
import Spinner from '../../components/ui/Spinner.vue'
|
||||
import UiButton from '../../components/ui/UiButton.vue'
|
||||
import UiModal from '../../components/ui/UiModal.vue'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type { OrderDetail, OrderPreview, OrderRow, TradeRecordRow } from '../../types/trading'
|
||||
import { currencyForMarket } from '../../utils/format'
|
||||
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
|
||||
|
||||
const props = defineProps<{
|
||||
orders: OrderRow[]
|
||||
connected: boolean
|
||||
dataLoading: boolean
|
||||
cancelingOrderKey: string
|
||||
orderHistoryLoaded: boolean
|
||||
orderHistoryNotice: string
|
||||
loadOrderDetail: (order: OrderRow) => Promise<OrderDetail>
|
||||
loadTradeRecords: () => Promise<TradeRecordRow[]>
|
||||
estimateOrderCorrection: (
|
||||
order: OrderRow,
|
||||
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
|
||||
) => Promise<OrderPreview>
|
||||
placeOrderCorrection: (
|
||||
order: OrderRow,
|
||||
draft: { quantity: number; priceCondition: 'market' | 'limit'; price?: number },
|
||||
) => Promise<void>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: []
|
||||
cancel: [order: OrderRow]
|
||||
}>()
|
||||
|
||||
const cancelCandidate = ref<OrderRow | null>(null)
|
||||
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
|
||||
const isUsMarket = (market: string) => usMarkets.has(market)
|
||||
const canCancel = (order: OrderRow) =>
|
||||
order.status === '注文中' && Boolean(order.orderNumber) && order.cancelable !== false
|
||||
const canShowUsDetail = (order: OrderRow) => isUsMarket(order.market)
|
||||
const canCorrect = (order: OrderRow) =>
|
||||
order.status === '注文中' &&
|
||||
Boolean(order.orderNumber) &&
|
||||
isUsMarket(order.market) &&
|
||||
order.correctable !== false
|
||||
const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
|
||||
orderHistoryKey(order) === cancelingOrderKey
|
||||
const cancelTitle = computed(() => cancelCandidate.value?.stock ?? '')
|
||||
const orderDetail = ref<OrderDetail | null>(null)
|
||||
const orderDetailLoadingKey = ref('')
|
||||
const orderActionError = ref('')
|
||||
const tradeRecords = ref<TradeRecordRow[]>([])
|
||||
const showTradeRecords = ref(false)
|
||||
const tradeRecordsLoading = ref(false)
|
||||
const correctionCandidate = ref<OrderRow | null>(null)
|
||||
const correctionQuantityInput = ref('')
|
||||
const correctionPriceCondition = ref<'market' | 'limit'>('limit')
|
||||
const correctionPriceInput = ref('')
|
||||
const correctionPreview = ref<OrderPreview | null>(null)
|
||||
const correctionLoading = ref(false)
|
||||
const correctionSubmitting = ref(false)
|
||||
|
||||
const orderActionKey = (order: OrderRow) => orderHistoryKey(order)
|
||||
const numericInput = (value: string) => {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
}
|
||||
const correctionDraft = () => {
|
||||
const quantity = numericInput(correctionQuantityInput.value)
|
||||
if (!quantity || quantity <= 0) throw new Error('数量を入力してください')
|
||||
if (correctionPriceCondition.value === 'market') {
|
||||
return { quantity, priceCondition: 'market' as const }
|
||||
}
|
||||
const price = numericInput(correctionPriceInput.value)
|
||||
if (!price || price <= 0) throw new Error('指値を入力してください')
|
||||
return { quantity, priceCondition: 'limit' as const, price }
|
||||
}
|
||||
const priceText = (value: number | null | undefined, market: string) =>
|
||||
value == null ? '-' : currencyForMarket(value, market)
|
||||
const tradeAmountText = (record: TradeRecordRow) =>
|
||||
record.amount == null ? '-' : currencyForMarket(record.amount, record.market)
|
||||
|
||||
const askCancel = (order: OrderRow) => {
|
||||
if (!canCancel(order)) return
|
||||
cancelCandidate.value = order
|
||||
}
|
||||
|
||||
const confirmCancel = () => {
|
||||
if (!cancelCandidate.value) return
|
||||
emit('cancel', cancelCandidate.value)
|
||||
cancelCandidate.value = null
|
||||
}
|
||||
|
||||
const showOrderDetail = async (order: OrderRow) => {
|
||||
if (!canShowUsDetail(order) || orderDetailLoadingKey.value) return
|
||||
orderActionError.value = ''
|
||||
orderDetailLoadingKey.value = orderActionKey(order)
|
||||
try {
|
||||
orderDetail.value = await props.loadOrderDetail(order)
|
||||
} catch (cause) {
|
||||
orderActionError.value = cause instanceof Error ? cause.message : '注文詳細の取得に失敗しました'
|
||||
} finally {
|
||||
orderDetailLoadingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const loadTradeRecords = async () => {
|
||||
if (tradeRecordsLoading.value) return
|
||||
orderActionError.value = ''
|
||||
tradeRecordsLoading.value = true
|
||||
try {
|
||||
tradeRecords.value = await props.loadTradeRecords()
|
||||
showTradeRecords.value = true
|
||||
} catch (cause) {
|
||||
orderActionError.value = cause instanceof Error ? cause.message : '約定履歴の取得に失敗しました'
|
||||
} finally {
|
||||
tradeRecordsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const askCorrection = (order: OrderRow) => {
|
||||
if (!canCorrect(order)) return
|
||||
correctionCandidate.value = order
|
||||
correctionQuantityInput.value = String(order.unexecutedQuantity ?? order.quantity ?? '')
|
||||
correctionPriceCondition.value = order.price == null ? 'market' : 'limit'
|
||||
correctionPriceInput.value = order.price == null ? '' : String(order.price)
|
||||
correctionPreview.value = null
|
||||
orderActionError.value = ''
|
||||
}
|
||||
|
||||
const estimateCorrection = async () => {
|
||||
if (!correctionCandidate.value || correctionLoading.value) return
|
||||
orderActionError.value = ''
|
||||
correctionLoading.value = true
|
||||
try {
|
||||
correctionPreview.value = await props.estimateOrderCorrection(
|
||||
correctionCandidate.value,
|
||||
correctionDraft(),
|
||||
)
|
||||
} catch (cause) {
|
||||
orderActionError.value = cause instanceof Error ? cause.message : '注文訂正の見積に失敗しました'
|
||||
} finally {
|
||||
correctionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmCorrection = async () => {
|
||||
if (!correctionCandidate.value || correctionSubmitting.value) return
|
||||
orderActionError.value = ''
|
||||
correctionSubmitting.value = true
|
||||
try {
|
||||
await props.placeOrderCorrection(correctionCandidate.value, correctionDraft())
|
||||
correctionCandidate.value = null
|
||||
correctionPreview.value = null
|
||||
} catch (cause) {
|
||||
orderActionError.value = cause instanceof Error ? cause.message : '注文訂正に失敗しました'
|
||||
} finally {
|
||||
correctionSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="ui.panel">
|
||||
<div :class="ui.panelHead">
|
||||
<h2>取引履歴</h2>
|
||||
<span :class="ui.rowActions">
|
||||
<button
|
||||
:class="ui.ghostButton"
|
||||
type="button"
|
||||
:disabled="!connected || tradeRecordsLoading"
|
||||
@click="loadTradeRecords"
|
||||
>
|
||||
<Spinner v-if="tradeRecordsLoading" size="sm" />
|
||||
<ReceiptText v-else class="h-4 w-4" aria-hidden="true" />
|
||||
約定履歴
|
||||
</button>
|
||||
<button
|
||||
:class="ui.ghostButton"
|
||||
type="button"
|
||||
:disabled="!connected || dataLoading"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" aria-hidden="true" />
|
||||
更新
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="orderActionError" :class="ui.dialogNote">{{ orderActionError }}</p>
|
||||
<div :class="ui.list">
|
||||
<div v-for="order in orders" :key="order.id" :class="ui.orderRow">
|
||||
<span>
|
||||
<strong>{{ order.stock }}</strong>
|
||||
<small>{{ order.code }} / {{ order.market }} / {{ order.date }}</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>
|
||||
<span :class="ui.rowActions">
|
||||
<button
|
||||
v-if="canShowUsDetail(order)"
|
||||
:class="ui.ghostButton"
|
||||
class="min-h-8 px-3 text-xs"
|
||||
type="button"
|
||||
:disabled="Boolean(orderDetailLoadingKey)"
|
||||
@click="showOrderDetail(order)"
|
||||
>
|
||||
<Spinner v-if="orderDetailLoadingKey === orderActionKey(order)" size="sm" />
|
||||
<FileText v-else class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
詳細
|
||||
</button>
|
||||
<button
|
||||
v-if="canCorrect(order)"
|
||||
:class="ui.ghostButton"
|
||||
class="min-h-8 px-3 text-xs"
|
||||
type="button"
|
||||
:disabled="Boolean(cancelingOrderKey)"
|
||||
@click="askCorrection(order)"
|
||||
>
|
||||
<Pencil class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
訂正
|
||||
</button>
|
||||
<button
|
||||
v-if="canCancel(order)"
|
||||
:class="ui.ghostButton"
|
||||
class="min-h-8 px-3 text-xs"
|
||||
type="button"
|
||||
:disabled="Boolean(cancelingOrderKey)"
|
||||
@click="askCancel(order)"
|
||||
>
|
||||
<Spinner v-if="isCanceling(order, cancelingOrderKey)" size="sm" />
|
||||
<Ban v-else class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{{ isCanceling(order, cancelingOrderKey) ? '取消中' : '取消' }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="dataLoading && !orders.length" :class="[ui.muted, 'grid py-8 place-items-center']">
|
||||
<Spinner />
|
||||
</div>
|
||||
<p v-else-if="!orders.length" :class="[ui.muted, 'py-8 text-center']">
|
||||
{{
|
||||
orderHistoryLoaded
|
||||
? orderHistoryNotice
|
||||
? `SBI SDK は取引履歴なしを返しました (${orderHistoryNotice})`
|
||||
: '該当する注文履歴はありません'
|
||||
: '注文履歴はまだ取得されていません'
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AnimatePresence>
|
||||
<UiModal
|
||||
v-if="orderDetail"
|
||||
key="order-detail-dialog"
|
||||
eyebrow="注文詳細"
|
||||
:title="orderDetail.stock"
|
||||
@close="orderDetail = null"
|
||||
>
|
||||
<dl :class="ui.confirmList">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>注文番号</dt>
|
||||
<dd>{{ orderDetail.orderNumber ?? '-' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>枝番</dt>
|
||||
<dd>{{ orderDetail.orderSubNo ?? '-' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>銘柄</dt>
|
||||
<dd>{{ orderDetail.code }} / {{ orderDetail.market }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>売買</dt>
|
||||
<dd>{{ orderDetail.side === 'buy' ? '購入' : '売却' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>数量</dt>
|
||||
<dd>{{ orderQuantityText(orderDetail) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>未約定数量</dt>
|
||||
<dd>{{ orderDetail.unexecutedQuantity ?? '-' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>価格</dt>
|
||||
<dd>{{ priceText(orderDetail.price, orderDetail.market) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>状態</dt>
|
||||
<dd>{{ orderDetail.statusText ?? orderDetail.status }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>有効期限</dt>
|
||||
<dd>{{ orderDetail.expiresAt ?? '-' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>取消/訂正</dt>
|
||||
<dd>
|
||||
{{ orderDetail.cancelable === false ? '取消不可' : '取消可' }} /
|
||||
{{ orderDetail.correctable === false ? '訂正不可' : '訂正可' }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div :class="ui.actions">
|
||||
<UiButton variant="ghost" @click="orderDetail = null">
|
||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||
閉じる
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
<UiModal
|
||||
v-if="showTradeRecords"
|
||||
key="trade-records-dialog"
|
||||
eyebrow="約定履歴"
|
||||
title="米国株"
|
||||
@close="showTradeRecords = false"
|
||||
>
|
||||
<div v-if="tradeRecords.length" :class="ui.confirmList">
|
||||
<div v-for="record in tradeRecords" :key="record.id" :class="ui.row">
|
||||
<span class="grid gap-1">
|
||||
<strong>{{ record.stock }}</strong>
|
||||
<small>{{ record.code }} / {{ record.market }} / {{ record.type }}</small>
|
||||
<small :class="ui.muted">
|
||||
{{ record.tradeDate ?? '-' }} 約定 / {{ record.valueDate ?? '-' }} 受渡
|
||||
</small>
|
||||
</span>
|
||||
<span class="grid justify-items-end gap-1 text-right">
|
||||
<strong>{{ tradeAmountText(record) }}</strong>
|
||||
<small :class="ui.muted">
|
||||
{{ record.quantity ?? '-' }}株 @ {{ priceText(record.price, record.market) }}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else :class="ui.dialogNote">該当する約定履歴はありません</p>
|
||||
<div :class="ui.actions">
|
||||
<UiButton variant="ghost" @click="showTradeRecords = false">
|
||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||
閉じる
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
<UiModal
|
||||
v-if="correctionCandidate"
|
||||
key="correction-dialog"
|
||||
eyebrow="注文訂正"
|
||||
:title="correctionCandidate.stock"
|
||||
@close="correctionCandidate = null"
|
||||
>
|
||||
<dl :class="ui.confirmList">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>注文番号</dt>
|
||||
<dd>{{ correctionCandidate.orderNumber }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>現在数量</dt>
|
||||
<dd>{{ orderQuantityText(correctionCandidate) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>現在価格</dt>
|
||||
<dd>{{ priceText(correctionCandidate.price, correctionCandidate.market) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="grid gap-3">
|
||||
<label :class="ui.label">
|
||||
数量
|
||||
<input
|
||||
v-model="correctionQuantityInput"
|
||||
:class="ui.input"
|
||||
inputmode="decimal"
|
||||
@input="correctionPreview = null"
|
||||
/>
|
||||
</label>
|
||||
<label :class="ui.label">
|
||||
執行条件
|
||||
<select
|
||||
v-model="correctionPriceCondition"
|
||||
:class="ui.input"
|
||||
@change="correctionPreview = null"
|
||||
>
|
||||
<option value="limit">指値</option>
|
||||
<option value="market">成行</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="correctionPriceCondition === 'limit'" :class="ui.label">
|
||||
指値
|
||||
<input
|
||||
v-model="correctionPriceInput"
|
||||
:class="ui.input"
|
||||
inputmode="decimal"
|
||||
@input="correctionPreview = null"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<dl v-if="correctionPreview" :class="ui.confirmList">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>見積数量</dt>
|
||||
<dd>{{ correctionPreview.quantity }}株</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>見積金額</dt>
|
||||
<dd>
|
||||
{{
|
||||
correctionPreview.price
|
||||
? priceText(correctionPreview.price.value, correctionCandidate.market)
|
||||
: '-'
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div :class="ui.actions">
|
||||
<UiButton variant="ghost" @click="correctionCandidate = null">
|
||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||
戻る
|
||||
</UiButton>
|
||||
<UiButton :disabled="correctionLoading" @click="estimateCorrection">
|
||||
<Spinner v-if="correctionLoading" size="sm" />
|
||||
<ShieldCheck v-else class="h-4 w-4" aria-hidden="true" />
|
||||
見積
|
||||
</UiButton>
|
||||
<UiButton
|
||||
variant="danger"
|
||||
:disabled="!correctionPreview || correctionSubmitting"
|
||||
@click="confirmCorrection"
|
||||
>
|
||||
<Spinner v-if="correctionSubmitting" size="sm" />
|
||||
<Pencil v-else class="h-4 w-4" aria-hidden="true" />
|
||||
訂正
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
<UiModal
|
||||
v-if="cancelCandidate"
|
||||
key="cancel-order-dialog"
|
||||
eyebrow="注文取消"
|
||||
:title="cancelTitle"
|
||||
@close="cancelCandidate = null"
|
||||
>
|
||||
<dl :class="ui.confirmList">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>注文番号</dt>
|
||||
<dd>{{ cancelCandidate.orderNumber }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>売買</dt>
|
||||
<dd>{{ cancelCandidate.side === 'buy' ? '購入' : '売却' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>数量</dt>
|
||||
<dd>{{ orderQuantityText(cancelCandidate) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>注文金額</dt>
|
||||
<dd>{{ orderAmountText(cancelCandidate) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div :class="ui.actions">
|
||||
<UiButton variant="ghost" @click="cancelCandidate = null">
|
||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||
戻る
|
||||
</UiButton>
|
||||
<UiButton variant="danger" @click="confirmCancel">
|
||||
<Ban class="h-4 w-4" aria-hidden="true" />
|
||||
注文取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
</AnimatePresence>
|
||||
</template>
|
||||
@@ -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,80 @@
|
||||
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') ?? '',
|
||||
}
|
||||
const requestedScopes = oauthApproval.value.scope.split(' ').filter(Boolean)
|
||||
oauthSettings.value.scopes = requestedScopes.length ? requestedScopes : ['read']
|
||||
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,232 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowLeft, Send, ShieldCheck } from 'lucide-vue-next'
|
||||
import { AnimatePresence } from 'motion-v'
|
||||
import UiButton from '../../components/ui/UiButton.vue'
|
||||
import UiModal from '../../components/ui/UiModal.vue'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type {
|
||||
CashOrderAccountType,
|
||||
CashOrderMarket,
|
||||
CashOrderMethod,
|
||||
CashOrderPriceCondition,
|
||||
CashOrderTerm,
|
||||
CashOrderTriggerZone,
|
||||
OrderKind,
|
||||
OrderPreview,
|
||||
TradeSide,
|
||||
} from '../../types/trading'
|
||||
import { currencyForMarket } from '../../utils/format'
|
||||
|
||||
defineProps<{
|
||||
estimate: OrderPreview | null
|
||||
showEstimate: boolean
|
||||
showOrder: boolean
|
||||
stockName: string
|
||||
stockMarket: 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' ? '以上' : '以下')
|
||||
|
||||
const accountTypeLabel = (type: CashOrderAccountType) =>
|
||||
({
|
||||
specific: '特定',
|
||||
general: '一般',
|
||||
growthInvestment: 'NISA成長投資枠',
|
||||
nisa: 'NISA',
|
||||
})[type]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AnimatePresence>
|
||||
<UiModal
|
||||
v-if="showEstimate && estimate"
|
||||
key="estimate-dialog"
|
||||
eyebrow="見積"
|
||||
:title="stockName"
|
||||
>
|
||||
<dl :class="ui.confirmList">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>売買</dt>
|
||||
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>数量</dt>
|
||||
<dd>{{ quantity }}株</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>預り区分</dt>
|
||||
<dd>{{ accountTypeLabel(accountType) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>注文市場</dt>
|
||||
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
|
||||
</div>
|
||||
<template>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>執行条件</dt>
|
||||
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
|
||||
</div>
|
||||
<div v-if="price > 0" :class="ui.confirmRow">
|
||||
<dt>注文価格</dt>
|
||||
<dd>{{ currencyForMarket(price, stockMarket) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>有効期限</dt>
|
||||
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>特殊注文</dt>
|
||||
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
|
||||
</div>
|
||||
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
|
||||
<dt>逆指値</dt>
|
||||
<dd>
|
||||
{{ currencyForMarket(triggerPrice, stockMarket) }} {{ triggerZoneLabel(triggerZone) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
|
||||
<dt>OCO条件</dt>
|
||||
<dd>
|
||||
{{ priceConditionLabel(secondaryPriceCondition) }}
|
||||
<template v-if="secondaryPrice > 0">
|
||||
{{ currencyForMarket(secondaryPrice, stockMarket) }}</template
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>概算</dt>
|
||||
<dd>{{ currencyForMarket(amount, stockMarket) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<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>
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
<UiModal v-if="showOrder" key="order-dialog" eyebrow="注文確認" :title="stockName">
|
||||
<dl :class="ui.confirmList">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>区分</dt>
|
||||
<dd>通常単元</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>売買</dt>
|
||||
<dd>{{ side === 'buy' ? '購入' : '売却' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>数量</dt>
|
||||
<dd>{{ quantity }}株</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>預り区分</dt>
|
||||
<dd>{{ accountTypeLabel(accountType) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>注文市場</dt>
|
||||
<dd>{{ market === 'auto' ? '自動' : market }}</dd>
|
||||
</div>
|
||||
<template>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>執行条件</dt>
|
||||
<dd>{{ priceConditionLabel(priceCondition) }}</dd>
|
||||
</div>
|
||||
<div v-if="price > 0" :class="ui.confirmRow">
|
||||
<dt>注文価格</dt>
|
||||
<dd>{{ currencyForMarket(price, stockMarket) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>有効期限</dt>
|
||||
<dd>{{ orderTermLabel(orderTerm, orderDate) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>特殊注文</dt>
|
||||
<dd>{{ orderMethodLabel(orderMethod) }}</dd>
|
||||
</div>
|
||||
<div v-if="orderMethod !== 'normal'" :class="ui.confirmRow">
|
||||
<dt>逆指値</dt>
|
||||
<dd>
|
||||
{{ currencyForMarket(triggerPrice, stockMarket) }} {{ triggerZoneLabel(triggerZone) }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="orderMethod === 'oco'" :class="ui.confirmRow">
|
||||
<dt>OCO条件</dt>
|
||||
<dd>
|
||||
{{ priceConditionLabel(secondaryPriceCondition) }}
|
||||
<template v-if="secondaryPrice > 0">
|
||||
{{ currencyForMarket(secondaryPrice, stockMarket) }}</template
|
||||
>
|
||||
</dd>
|
||||
</div>
|
||||
</template>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>概算</dt>
|
||||
<dd>{{ currencyForMarket(amount, stockMarket) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div :class="ui.actions">
|
||||
<UiButton variant="ghost" @click="emit('closeOrder')">
|
||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||
戻る
|
||||
</UiButton>
|
||||
<UiButton variant="danger" @click="emit('place')">
|
||||
<Send class="h-4 w-4" aria-hidden="true" />
|
||||
発注
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
</AnimatePresence>
|
||||
</template>
|
||||
@@ -0,0 +1,402 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { AnimatePresence } from 'motion-v'
|
||||
import { ArrowLeft, Ban, FileText, Plug } from 'lucide-vue-next'
|
||||
import Spinner from '../../components/ui/Spinner.vue'
|
||||
import UiButton from '../../components/ui/UiButton.vue'
|
||||
import UiModal from '../../components/ui/UiModal.vue'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type { MarketIndex, OrderRow, Position } from '../../types/trading'
|
||||
import {
|
||||
currency,
|
||||
currencyForMarket,
|
||||
number as formatNumber,
|
||||
signedCurrency,
|
||||
signedCurrencyForMarket,
|
||||
signedPercent,
|
||||
} from '../../utils/format'
|
||||
import { orderAmountText, orderHistoryKey, orderQuantityText } from '../trading/trading-data'
|
||||
|
||||
const props = defineProps<{
|
||||
showPortfolioSpinner: boolean
|
||||
totalAssetValue: number
|
||||
buyingPower: number
|
||||
holdingsMarketValue: number
|
||||
totalProfitLoss: number
|
||||
totalProfitLossRate: number
|
||||
marketIndexes: MarketIndex[]
|
||||
stockAssetRatio: number
|
||||
cashAssetRatio: number
|
||||
positions: Position[]
|
||||
recentOrders: OrderRow[]
|
||||
cancelingOrderKey: string
|
||||
dataLoading: boolean
|
||||
connected: boolean
|
||||
orderHistoryLoaded: boolean
|
||||
orderHistoryNotice: string
|
||||
loadPositionDetail: (position: Position) => Promise<Position>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
connect: []
|
||||
openPosition: [code: string]
|
||||
cancelOrder: [order: OrderRow]
|
||||
}>()
|
||||
|
||||
const usMarkets = new Set(['XNAS', 'XNYS', 'ARCX'])
|
||||
const isUsMarket = (market: string) => usMarkets.has(market)
|
||||
const canCancel = (order: OrderRow) =>
|
||||
order.status === '注文中' && Boolean(order.orderNumber) && order.cancelable !== false
|
||||
const isCanceling = (order: OrderRow, cancelingOrderKey: string) =>
|
||||
orderHistoryKey(order) === cancelingOrderKey
|
||||
const cancelCandidate = ref<OrderRow | null>(null)
|
||||
const positionDetail = ref<Position | null>(null)
|
||||
const positionDetailLoadingKey = ref('')
|
||||
const positionDetailError = ref('')
|
||||
const cancelTitle = computed(() => cancelCandidate.value?.stock ?? '')
|
||||
const indexValueText = (index: MarketIndex) =>
|
||||
index.valueText || (index.value == null ? '-' : formatNumber(index.value))
|
||||
const indexChangeText = (index: MarketIndex) => {
|
||||
const change = index.changeText || (index.change == null ? '' : formatNumber(index.change))
|
||||
const rate =
|
||||
index.changeRateText || (index.changeRate == null ? '' : signedPercent(index.changeRate))
|
||||
return [change, rate].filter(Boolean).join(' / ') || '-'
|
||||
}
|
||||
const indexTone = (index: MarketIndex) => {
|
||||
if (index.sign === 'positive') return ui.positive
|
||||
if (index.sign === 'negative') return ui.negative
|
||||
return ui.muted
|
||||
}
|
||||
const positionKey = (position: Position) =>
|
||||
[position.market, position.code, position.accountType].filter(Boolean).join(':')
|
||||
const canLoadPositionDetail = (position: Position) => isUsMarket(position.market)
|
||||
|
||||
const showPositionDetail = async (position: Position) => {
|
||||
if (!canLoadPositionDetail(position) || positionDetailLoadingKey.value) return
|
||||
const key = positionKey(position)
|
||||
positionDetailLoadingKey.value = key
|
||||
positionDetailError.value = ''
|
||||
try {
|
||||
positionDetail.value = await props.loadPositionDetail(position)
|
||||
} catch (cause) {
|
||||
positionDetailError.value =
|
||||
cause instanceof Error ? cause.message : '保有詳細の取得に失敗しました'
|
||||
} finally {
|
||||
positionDetailLoadingKey.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const askCancel = (order: OrderRow) => {
|
||||
if (!canCancel(order)) return
|
||||
cancelCandidate.value = order
|
||||
}
|
||||
|
||||
const confirmCancel = () => {
|
||||
if (!cancelCandidate.value) return
|
||||
emit('cancelOrder', cancelCandidate.value)
|
||||
cancelCandidate.value = null
|
||||
}
|
||||
</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, totalProfitLoss >= 0 ? ui.positive : ui.negative]">
|
||||
通算評価損益:
|
||||
<template v-if="!showPortfolioSpinner">
|
||||
{{ signedCurrency(totalProfitLoss) }} · {{ signedPercent(totalProfitLossRate) }}
|
||||
</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</small>
|
||||
<small :class="ui.assetOverviewSubtext">
|
||||
余力:
|
||||
<template v-if="!showPortfolioSpinner">{{ currency(buyingPower) }}</template>
|
||||
<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.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>
|
||||
<span>操作</span>
|
||||
</div>
|
||||
<div v-if="positions.length" :class="ui.holdingsRows">
|
||||
<div v-for="position in positions" :key="positionKey(position)" :class="ui.holdingRow">
|
||||
<button
|
||||
class="grid gap-1 text-left text-[#e3e3e9]"
|
||||
type="button"
|
||||
@click="emit('openPosition', position.code)"
|
||||
>
|
||||
<strong>{{ position.name }}</strong>
|
||||
<small>{{ position.code }}</small>
|
||||
</button>
|
||||
<b :class="ui.typePill">{{
|
||||
position.type ?? (position.quantity >= 100 ? '単元' : 'S株')
|
||||
}}</b>
|
||||
<span>{{ position.quantity }}</span>
|
||||
<span :class="ui.muted">{{
|
||||
currencyForMarket(position.marketValue, position.market)
|
||||
}}</span>
|
||||
<span
|
||||
class="grid justify-items-end gap-0.5"
|
||||
:class="position.profitLoss >= 0 ? ui.positive : ui.negative"
|
||||
>
|
||||
<strong>{{ signedCurrencyForMarket(position.profitLoss, position.market) }}</strong>
|
||||
<small>{{ signedPercent(position.profitLossRate) }}</small>
|
||||
</span>
|
||||
<span :class="ui.rowActions">
|
||||
<button
|
||||
v-if="canLoadPositionDetail(position)"
|
||||
:class="ui.ghostButton"
|
||||
class="min-h-8 px-3 text-xs"
|
||||
type="button"
|
||||
:disabled="Boolean(positionDetailLoadingKey)"
|
||||
@click="showPositionDetail(position)"
|
||||
>
|
||||
<Spinner v-if="positionDetailLoadingKey === positionKey(position)" size="sm" />
|
||||
<FileText v-else class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
詳細
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
|
||||
<Spinner />
|
||||
</div>
|
||||
<p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると保有銘柄を表示します</p>
|
||||
<p v-if="positionDetailError" :class="ui.dialogNote">{{ positionDetailError }}</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<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' ? '買付' : '売却' }}(単元) ・
|
||||
{{ orderQuantityText(order) }}</small
|
||||
>
|
||||
<small :class="[ui.statusBadge, order.status === '注文中' && ui.pendingBadge]">
|
||||
{{ order.status }}
|
||||
</small>
|
||||
</span>
|
||||
<span class="grid justify-items-end gap-1">
|
||||
<small>{{ order.date.slice(5, 10) }}</small>
|
||||
<strong>{{ orderAmountText(order) }}</strong>
|
||||
<button
|
||||
v-if="canCancel(order)"
|
||||
:class="ui.ghostButton"
|
||||
class="min-h-8 px-3 text-xs"
|
||||
type="button"
|
||||
:disabled="Boolean(cancelingOrderKey)"
|
||||
@click="askCancel(order)"
|
||||
>
|
||||
<Spinner v-if="isCanceling(order, cancelingOrderKey)" size="sm" />
|
||||
<Ban v-else class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{{ isCanceling(order, cancelingOrderKey) ? '取消中' : '取消' }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<article :class="ui.marketIndexPanel">
|
||||
<div :class="ui.panelHead">
|
||||
<h2>指数</h2>
|
||||
</div>
|
||||
<div v-if="marketIndexes.length" :class="ui.marketIndexGrid">
|
||||
<div
|
||||
v-for="index in marketIndexes"
|
||||
:key="index.code ?? index.name"
|
||||
:class="ui.marketIndexCard"
|
||||
>
|
||||
<span :class="ui.metricLabel">{{ index.name }}</span>
|
||||
<strong :class="ui.marketIndexValue">{{ indexValueText(index) }}</strong>
|
||||
<small :class="indexTone(index)">{{ indexChangeText(index) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="dataLoading" :class="[ui.muted, ui.emptyState]">
|
||||
<Spinner />
|
||||
</div>
|
||||
<p v-else :class="[ui.muted, ui.emptyState]">SBIに接続すると指数を表示します</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<AnimatePresence>
|
||||
<UiModal
|
||||
v-if="positionDetail"
|
||||
key="position-detail-dialog"
|
||||
eyebrow="保有詳細"
|
||||
:title="positionDetail.name"
|
||||
@close="positionDetail = null"
|
||||
>
|
||||
<dl :class="ui.confirmList">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>銘柄</dt>
|
||||
<dd>{{ positionDetail.code }} / {{ positionDetail.market }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>預り区分</dt>
|
||||
<dd>{{ positionDetail.type ?? positionDetail.accountType ?? '-' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>数量</dt>
|
||||
<dd>{{ positionDetail.quantity }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>現在値</dt>
|
||||
<dd>
|
||||
{{
|
||||
positionDetail.currentPrice == null
|
||||
? '-'
|
||||
: currencyForMarket(positionDetail.currentPrice, positionDetail.market)
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>平均取得単価</dt>
|
||||
<dd>{{ currencyForMarket(positionDetail.avgPrice, positionDetail.market) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>評価額</dt>
|
||||
<dd>{{ currencyForMarket(positionDetail.marketValue, positionDetail.market) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>評価損益</dt>
|
||||
<dd :class="positionDetail.profitLoss >= 0 ? ui.positive : ui.negative">
|
||||
{{ signedCurrencyForMarket(positionDetail.profitLoss, positionDetail.market) }} /
|
||||
{{ signedPercent(positionDetail.profitLossRate) }}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div :class="ui.actions">
|
||||
<UiButton variant="ghost" @click="positionDetail = null">
|
||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||
閉じる
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
<UiModal
|
||||
v-if="cancelCandidate"
|
||||
key="portfolio-cancel-order-dialog"
|
||||
eyebrow="注文取消"
|
||||
:title="cancelTitle"
|
||||
@close="cancelCandidate = null"
|
||||
>
|
||||
<dl :class="ui.confirmList">
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>注文番号</dt>
|
||||
<dd>{{ cancelCandidate.orderNumber }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>売買</dt>
|
||||
<dd>{{ cancelCandidate.side === 'buy' ? '購入' : '売却' }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>数量</dt>
|
||||
<dd>{{ orderQuantityText(cancelCandidate) }}</dd>
|
||||
</div>
|
||||
<div :class="ui.confirmRow">
|
||||
<dt>注文金額</dt>
|
||||
<dd>{{ orderAmountText(cancelCandidate) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div :class="ui.actions">
|
||||
<UiButton variant="ghost" @click="cancelCandidate = null">
|
||||
<ArrowLeft class="h-4 w-4" aria-hidden="true" />
|
||||
戻る
|
||||
</UiButton>
|
||||
<UiButton variant="danger" @click="confirmCancel">
|
||||
<Ban class="h-4 w-4" aria-hidden="true" />
|
||||
注文取消
|
||||
</UiButton>
|
||||
</div>
|
||||
</UiModal>
|
||||
</AnimatePresence>
|
||||
</template>
|
||||
@@ -0,0 +1,155 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import { type ComponentPublicInstance, nextTick, ref, watch } from 'vue'
|
||||
import { motion } from 'motion-v'
|
||||
import Spinner from '../../components/ui/Spinner.vue'
|
||||
import UiButton from '../../components/ui/UiButton.vue'
|
||||
import UiField from '../../components/ui/UiField.vue'
|
||||
import searchEmptyImage from '../../assets/search-empty.png'
|
||||
import { uiMotion } from '../../constants/motion'
|
||||
import { ui } from '../../styles/ui'
|
||||
import type { Stock } from '../../types/trading'
|
||||
import { currencyForMarket } from '../../utils/format'
|
||||
|
||||
const overlayTransition = uiMotion.overlay.fadeDefault
|
||||
const sheetTransition = uiMotion.trade.searchSheet
|
||||
|
||||
const props = defineProps<{
|
||||
stocks: Stock[]
|
||||
selectedStockCode: string
|
||||
countries: string[]
|
||||
markets: string[]
|
||||
loading?: boolean
|
||||
}>()
|
||||
const selectedButtonRefs = ref(new Map<string, Element | null>())
|
||||
const setSelectedButtonRef =
|
||||
(code: string) => (element: Element | ComponentPublicInstance | null) => {
|
||||
if (element instanceof Element) {
|
||||
selectedButtonRefs.value.set(code, element)
|
||||
} else {
|
||||
selectedButtonRefs.value.delete(code)
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToSelectedStock = async () => {
|
||||
await nextTick()
|
||||
const element = selectedButtonRefs.value.get(props.selectedStockCode)
|
||||
if (!(element instanceof HTMLButtonElement)) return
|
||||
element.scrollIntoView({
|
||||
block: 'nearest',
|
||||
inline: 'nearest',
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.stocks, props.selectedStockCode],
|
||||
() => {
|
||||
void scrollToSelectedStock()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const searchQuery = defineModel<string>('searchQuery', { required: true })
|
||||
const countryFilter = defineModel<string>('countryFilter', { required: true })
|
||||
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"
|
||||
:ref="setSelectedButtonRef(stock.code)"
|
||||
:class="[
|
||||
ui.searchResult,
|
||||
selectedStockCode === stock.code && ui.searchResultActive,
|
||||
selectedStockCode === stock.code && ui.searchResultFocus,
|
||||
]"
|
||||
type="button"
|
||||
:aria-current="selectedStockCode === stock.code ? 'true' : undefined"
|
||||
:aria-selected="selectedStockCode === stock.code"
|
||||
:tabindex="selectedStockCode === stock.code ? 0 : -1"
|
||||
@click="emit('select', stock)"
|
||||
@keyup.enter="emit('select', stock)"
|
||||
>
|
||||
<span class="grid gap-1">
|
||||
<strong>{{ stock.name }}</strong>
|
||||
<small>{{ stock.code }} / {{ stock.country }} / {{ stock.market }}</small>
|
||||
</span>
|
||||
<span>
|
||||
<template v-if="stock.price > 0">{{
|
||||
currencyForMarket(stock.price, stock.market)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</motion.section>
|
||||
</motion.div>
|
||||
</template>
|
||||
@@ -0,0 +1,364 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Ban,
|
||||
ChevronRight,
|
||||
KeyRound,
|
||||
Pencil,
|
||||
Plug,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
UserRoundCog,
|
||||
} from 'lucide-vue-next'
|
||||
import { AnimatePresence } from 'motion-v'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import type { ApiKey, ApiKeySettings, SbiPasskey } from '../../api'
|
||||
import ApiKeyPolicyEditor from '../../components/ApiKeyPolicyEditor.vue'
|
||||
import UiModal from '../../components/ui/UiModal.vue'
|
||||
import { ui } from '../../styles/ui'
|
||||
|
||||
defineProps<{
|
||||
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]
|
||||
}>()
|
||||
|
||||
type SettingsSection = 'api-keys' | 'passkeys'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const editingApiKey = ref<ApiKey | null>(null)
|
||||
|
||||
const activeSection = computed<SettingsSection>(() => {
|
||||
return route.params.section === 'passkeys' ? 'passkeys' : 'api-keys'
|
||||
})
|
||||
|
||||
const isNewApiKey = computed(
|
||||
() => activeSection.value === 'api-keys' && route.params.mode === 'new',
|
||||
)
|
||||
|
||||
const settingsGroups = [
|
||||
{
|
||||
label: 'アクセス',
|
||||
items: [
|
||||
{
|
||||
id: 'api-keys',
|
||||
label: 'API キー',
|
||||
description: '権限と取引上限を管理',
|
||||
icon: KeyRound,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '証券口座',
|
||||
items: [
|
||||
{
|
||||
id: 'passkeys',
|
||||
label: 'SBI パスキー',
|
||||
description: '接続プロフィールを管理',
|
||||
icon: UserRoundCog,
|
||||
},
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
const openSection = (section: SettingsSection) => {
|
||||
router.push(`/settings/${section}`)
|
||||
}
|
||||
|
||||
const openNewApiKey = () => {
|
||||
router.push('/settings/api-keys/new')
|
||||
}
|
||||
|
||||
const sectionTitle = computed(() =>
|
||||
isNewApiKey.value
|
||||
? 'API Key 発行'
|
||||
: activeSection.value === 'api-keys'
|
||||
? 'API キー'
|
||||
: 'SBI パスキー',
|
||||
)
|
||||
|
||||
const openApiKeyEditor = (key: ApiKey) => {
|
||||
editingApiKey.value = {
|
||||
...key,
|
||||
allowedMethods: key.allowedMethods ? [...key.allowedMethods] : key.allowedMethods,
|
||||
}
|
||||
}
|
||||
|
||||
const closeApiKeyEditor = () => {
|
||||
editingApiKey.value = null
|
||||
}
|
||||
|
||||
const saveEditingApiKey = () => {
|
||||
if (!editingApiKey.value) return
|
||||
emit('saveApiKeySettings', editingApiKey.value)
|
||||
closeApiKeyEditor()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid min-h-[calc(100dvh-9rem)] min-w-0 max-w-full gap-0 lg:block">
|
||||
<aside
|
||||
class="min-w-0 border-b border-[#30343a] pb-4 lg:fixed lg:top-8 lg:left-[9rem] lg:z-10 lg:h-fit lg:max-h-[calc(100dvh-4rem)] lg:w-72 lg:overflow-y-auto lg:border-r lg:border-b-0 lg:pr-5 lg:pb-0"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<div class="grid gap-5">
|
||||
<nav class="grid gap-5">
|
||||
<section v-for="group in settingsGroups" :key="group.label" class="grid gap-2">
|
||||
<p class="px-2 text-xs font-black text-[#747982]">{{ group.label }}</p>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
:class="[
|
||||
'grid min-h-16 grid-cols-[2.5rem_minmax(0,1fr)_auto] items-center gap-3 rounded-[18px] px-3 text-left transition hover:bg-[#22272e] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]',
|
||||
activeSection === item.id ? 'bg-[#263141] text-[#d3e3fd]' : 'text-[#e3e3e9]',
|
||||
]"
|
||||
type="button"
|
||||
@click="openSection(item.id)"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'grid h-10 w-10 place-items-center rounded-full',
|
||||
activeSection === item.id
|
||||
? 'bg-[#a8c7fa] text-[#102033]'
|
||||
: 'bg-[#111418] text-[#c3c7cf]',
|
||||
]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<component :is="item.icon" class="h-5 w-5" :stroke-width="2.4" />
|
||||
</span>
|
||||
<span class="grid min-w-0 gap-0.5">
|
||||
<strong class="truncate text-sm font-black">{{ item.label }}</strong>
|
||||
<small class="truncate text-xs font-semibold text-[#9aa0a9]">
|
||||
{{ item.description }}
|
||||
</small>
|
||||
</span>
|
||||
<ChevronRight class="h-4 w-4 text-[#8f949d]" aria-hidden="true" />
|
||||
</button>
|
||||
</section>
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="grid min-w-0 content-start gap-8 pt-5 lg:ml-72 lg:pl-7 lg:pt-0">
|
||||
<header
|
||||
class="flex flex-wrap items-center justify-between gap-3 border-b border-[#30343a] pb-5"
|
||||
>
|
||||
<div class="grid gap-1">
|
||||
<h2 class="text-2xl font-black text-[#e3e3e9]">{{ sectionTitle }}</h2>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
v-if="isNewApiKey"
|
||||
:class="ui.primaryButton"
|
||||
type="button"
|
||||
@click="emit('addApiKey')"
|
||||
>
|
||||
<KeyRound class="h-4 w-4" aria-hidden="true" />
|
||||
発行
|
||||
</button>
|
||||
<template v-else>
|
||||
<button :class="ui.ghostButton" type="button" @click="emit('refresh')">
|
||||
<RefreshCw class="h-4 w-4" aria-hidden="true" />
|
||||
更新
|
||||
</button>
|
||||
<button
|
||||
v-if="activeSection === 'api-keys'"
|
||||
class="inline-grid h-10 w-10 place-items-center rounded-full bg-[#a8c7fa] text-[#102033] shadow-sm shadow-black/20 transition hover:bg-[#d3e3fd] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]"
|
||||
type="button"
|
||||
title="API Key 発行"
|
||||
@click="openNewApiKey"
|
||||
>
|
||||
<Plus class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<template v-if="isNewApiKey">
|
||||
<article class="grid min-w-0 content-start gap-4 overflow-hidden bg-transparent">
|
||||
<label :class="ui.label">
|
||||
名前
|
||||
<input v-model="apiKeyLabel" :class="ui.input" placeholder="例: trade-bot-readonly" />
|
||||
</label>
|
||||
<ApiKeyPolicyEditor v-model="newApiKeySettings" permissions-open />
|
||||
<label v-if="newApiToken" :class="ui.label">
|
||||
新しい API キー
|
||||
<textarea
|
||||
v-model="newApiToken"
|
||||
:class="[ui.input, 'min-h-28 py-3']"
|
||||
readonly
|
||||
></textarea>
|
||||
</label>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeSection === 'api-keys'">
|
||||
<article class="grid min-w-0 content-start gap-4 overflow-hidden bg-transparent">
|
||||
<div v-if="apiKeys.length" :class="ui.list">
|
||||
<div v-for="key in apiKeys" :key="key.id" :class="ui.keyRow">
|
||||
<div :class="ui.row">
|
||||
<span class="min-w-0 truncate font-bold text-slate-100">{{ key.label }}</span>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
:class="ui.ghostButton"
|
||||
type="button"
|
||||
title="Edit settings"
|
||||
@click="openApiKeyEditor(key)"
|
||||
>
|
||||
<Pencil 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>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="rounded-[18px] border border-[#30343a] bg-[#111418] p-5 text-sm font-semibold text-[#9aa0a9]"
|
||||
>
|
||||
発行済みの API キーはありません。
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<article class="grid min-w-0 content-start gap-4 overflow-hidden bg-transparent">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-black">パスキー追加</h3>
|
||||
</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, 'min-h-48 py-3 font-mono text-xs']"
|
||||
spellcheck="false"
|
||||
placeholder="{ ... }"
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="grid grid-cols-1 gap-3 xl:grid-cols-2">
|
||||
<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="grid min-w-0 content-start gap-4 overflow-hidden bg-transparent">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="text-lg font-black">プロフィール切り替え</h3>
|
||||
</div>
|
||||
<button :class="ui.ghostButton" type="button" @click="emit('connect')">
|
||||
<Plug class="h-4 w-4" aria-hidden="true" />
|
||||
接続
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="sbiPasskeys.length" :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 class="grid min-w-0 gap-1">
|
||||
<strong class="truncate">{{ passkey.label }}</strong>
|
||||
<small class="truncate text-[#8f949d]">
|
||||
{{ 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>
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-2 rounded-[18px] border border-[#30343a] bg-[#111418] p-5 text-sm font-semibold text-[#9aa0a9]"
|
||||
>
|
||||
<span class="inline-flex items-center gap-2 text-[#c3c7cf]">
|
||||
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
|
||||
保存済みプロフィールはありません。
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
<UiModal
|
||||
v-if="editingApiKey"
|
||||
:title="editingApiKey.label"
|
||||
size="lg"
|
||||
@close="closeApiKeyEditor"
|
||||
>
|
||||
<template #actions>
|
||||
<button :class="ui.ghostButton" type="button" @click="closeApiKeyEditor">
|
||||
キャンセル
|
||||
</button>
|
||||
<button :class="ui.primaryButton" type="button" @click="saveEditingApiKey">
|
||||
<Save class="h-4 w-4" aria-hidden="true" />
|
||||
保存
|
||||
</button>
|
||||
</template>
|
||||
<ApiKeyPolicyEditor v-model="editingApiKey" permissions-open />
|
||||
</UiModal>
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,769 @@
|
||||
<script setup lang="ts">
|
||||
import * as d3 from 'd3'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import Spinner from '../../components/ui/Spinner.vue'
|
||||
import { marketTimeZones } from '../../constants/market'
|
||||
import type { ChartMode, ChartNotice, ChartRange, RealtimePricePoint } from '../../types/trading'
|
||||
import { currencyForMarket } from '../../utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
points: RealtimePricePoint[]
|
||||
stockName: string
|
||||
active: boolean
|
||||
mode: ChartMode
|
||||
range: ChartRange
|
||||
market?: string
|
||||
previousClose?: number
|
||||
notice?: ChartNotice | null
|
||||
}>()
|
||||
|
||||
type ChartPoint = RealtimePricePoint & {
|
||||
index: number
|
||||
date: Date
|
||||
}
|
||||
|
||||
const root = ref<HTMLDivElement | null>(null)
|
||||
const svg = ref<SVGSVGElement | null>(null)
|
||||
const gridLayer = ref<SVGGElement | null>(null)
|
||||
const xAxisLayer = ref<SVGGElement | null>(null)
|
||||
const yAxisLayer = ref<SVGGElement | null>(null)
|
||||
const areaPath = ref<SVGPathElement | null>(null)
|
||||
const linePath = ref<SVGPathElement | null>(null)
|
||||
const previousCloseLine = ref<SVGLineElement | null>(null)
|
||||
const dotsLayer = ref<SVGGElement | null>(null)
|
||||
const candlesLayer = ref<SVGGElement | null>(null)
|
||||
const hoverLayer = ref<SVGGElement | null>(null)
|
||||
const hoverLine = ref<SVGLineElement | null>(null)
|
||||
const hoverDot = ref<SVGCircleElement | null>(null)
|
||||
const clipRect = ref<SVGRectElement | null>(null)
|
||||
const zoomScale = ref(1)
|
||||
const size = ref({ width: 0, height: 0 })
|
||||
const tooltip = ref({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
price: '',
|
||||
jstDate: '',
|
||||
localDate: '',
|
||||
detail: '',
|
||||
})
|
||||
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
let zoomBehavior: d3.ZoomBehavior<SVGSVGElement, unknown> | undefined
|
||||
let zoomTransform = d3.zoomIdentity
|
||||
let syncingZoom = false
|
||||
let followsLatest = true
|
||||
let latestXScale: d3.ScaleTime<number, number> | undefined
|
||||
let latestYScale: d3.ScaleLinear<number, number> | undefined
|
||||
|
||||
const clipId = `realtime-price-clip-${Math.random().toString(36).slice(2)}`
|
||||
const margin = { top: 12, right: 18, bottom: 28, left: 48 }
|
||||
const minZoom = 0.6
|
||||
const maxZoom = 6
|
||||
const zoomStep = 0.2
|
||||
const basePointSpacing = 10
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
const tolerancePx = 10
|
||||
const offscreenPointBuffer = 4
|
||||
const minXAxisTickSpacing = 72
|
||||
const chartPoints = computed<ChartPoint[]>(() =>
|
||||
props.points
|
||||
.map((point, index) => ({ ...point, index, date: new Date(point.at) }))
|
||||
.filter((point) => Number.isFinite(point.price) && !Number.isNaN(point.date.getTime())),
|
||||
)
|
||||
|
||||
const plotWidth = computed(() => Math.max(1, size.value.width - margin.left - margin.right))
|
||||
const plotHeight = computed(() => Math.max(1, size.value.height - margin.top - margin.bottom))
|
||||
|
||||
const labelFor = (point: ChartPoint) => {
|
||||
if (props.range === '1D') {
|
||||
return new Intl.DateTimeFormat('ja-JP', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(point.date)
|
||||
}
|
||||
|
||||
if (props.range === '3D') {
|
||||
if (
|
||||
point.date.getHours() === 0 &&
|
||||
point.date.getMinutes() === 0 &&
|
||||
point.date.getSeconds() === 0
|
||||
) {
|
||||
return new Intl.DateTimeFormat('ja-JP', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).format(point.date)
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('ja-JP', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
}).format(point.date)
|
||||
}
|
||||
|
||||
if (props.range === 'ALL') {
|
||||
return new Intl.DateTimeFormat('ja-JP', {
|
||||
year: 'numeric',
|
||||
}).format(point.date)
|
||||
}
|
||||
|
||||
if (
|
||||
point.date.getHours() === 0 &&
|
||||
point.date.getMinutes() === 0 &&
|
||||
point.date.getSeconds() === 0
|
||||
) {
|
||||
return new Intl.DateTimeFormat('ja-JP', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).format(point.date)
|
||||
}
|
||||
return new Intl.DateTimeFormat('ja-JP', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
}).format(point.date)
|
||||
}
|
||||
|
||||
const closePrice = (point: ChartPoint) => point.close ?? point.price
|
||||
const marketTimeZone = computed(
|
||||
() => marketTimeZones[props.market?.toUpperCase() ?? ''] ?? 'Asia/Tokyo',
|
||||
)
|
||||
|
||||
const previousCloseValue = computed(() =>
|
||||
props.range === '1D' && Number.isFinite(props.previousClose) && props.previousClose
|
||||
? props.previousClose
|
||||
: null,
|
||||
)
|
||||
|
||||
const ohlcFor = (point: ChartPoint) => {
|
||||
const close = closePrice(point)
|
||||
const open = point.open ?? close
|
||||
const high = Math.max(point.high ?? close, open, close)
|
||||
const low = Math.min(point.low ?? close, open, close)
|
||||
return { open, high, low, close }
|
||||
}
|
||||
|
||||
const zonedClockParts = (date: Date, timeZone: string) => {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
.formatToParts(date)
|
||||
.filter((part) => part.type !== 'literal')
|
||||
.map((part) => [part.type, Number(part.value)]),
|
||||
) as Partial<Record<'hour' | 'minute' | 'second', number>>
|
||||
return {
|
||||
hour: parts.hour ?? 0,
|
||||
minute: parts.minute ?? 0,
|
||||
second: parts.second ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
const tooltipDateFor = (point: ChartPoint, timeZone: string) => {
|
||||
const parts = zonedClockParts(point.date, timeZone)
|
||||
const hasTime = parts.hour !== 0 || parts.minute !== 0 || parts.second !== 0
|
||||
return new Intl.DateTimeFormat('ja-JP', {
|
||||
timeZone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
...(hasTime
|
||||
? {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZoneName: 'short',
|
||||
}
|
||||
: {}),
|
||||
}).format(point.date)
|
||||
}
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
const width = 220
|
||||
const height = 92
|
||||
const pad = 8
|
||||
const left = Math.min(
|
||||
Math.max(pad, tooltip.value.x + 12),
|
||||
Math.max(pad, size.value.width - width - pad),
|
||||
)
|
||||
const top =
|
||||
tooltip.value.y > height + pad
|
||||
? Math.max(pad, tooltip.value.y - height)
|
||||
: Math.min(size.value.height - height - pad, tooltip.value.y + 14)
|
||||
return {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const timeDomainMs = (): [number, number] => {
|
||||
const points = chartPoints.value
|
||||
const start = d3.min(points, (point) => point.date.getTime()) ?? 0
|
||||
const end = d3.max(points, (point) => point.date.getTime()) ?? start + dayMs
|
||||
return end === start ? [start, start + dayMs] : [start, end]
|
||||
}
|
||||
|
||||
const baseContentWidth = () => {
|
||||
const points = chartPoints.value
|
||||
return Math.max(plotWidth.value, Math.max(1, points.length - 1) * basePointSpacing)
|
||||
}
|
||||
|
||||
const basePixelsPerMs = () => {
|
||||
const [start, end] = timeDomainMs()
|
||||
const duration = end - start
|
||||
return duration > 0 ? baseContentWidth() / duration : basePointSpacing / dayMs
|
||||
}
|
||||
|
||||
const contentWidth = (k = zoomTransform.k) => {
|
||||
return Math.max(plotWidth.value, baseContentWidth() * k)
|
||||
}
|
||||
|
||||
const minTranslateX = (k = zoomTransform.k) => Math.min(0, plotWidth.value - contentWidth(k))
|
||||
|
||||
const zoomTransformWith = (k: number, x: number) => d3.zoomIdentity.translate(x, 0).scale(k)
|
||||
|
||||
const clampTransform = (transform: d3.ZoomTransform) => {
|
||||
const k = Math.min(maxZoom, Math.max(minZoom, transform.k))
|
||||
const minX = minTranslateX(k)
|
||||
const x = Math.min(0, Math.max(minX, transform.x))
|
||||
return zoomTransformWith(k, x)
|
||||
}
|
||||
|
||||
const isLatestVisible = (transform = zoomTransform) =>
|
||||
contentWidth(transform.k) + transform.x <= plotWidth.value + tolerancePx
|
||||
|
||||
const syncZoomBehavior = (transform: d3.ZoomTransform) => {
|
||||
if (!svg.value || !zoomBehavior) return
|
||||
syncingZoom = true
|
||||
d3.select(svg.value).call(zoomBehavior.transform, transform)
|
||||
syncingZoom = false
|
||||
}
|
||||
|
||||
const applyTransform = (transform: d3.ZoomTransform, sync = true) => {
|
||||
zoomTransform = clampTransform(transform)
|
||||
zoomScale.value = Number(zoomTransform.k.toFixed(2))
|
||||
followsLatest = isLatestVisible(zoomTransform)
|
||||
renderChart()
|
||||
if (sync) syncZoomBehavior(zoomTransform)
|
||||
}
|
||||
|
||||
const alignLatest = (k = zoomTransform.k, sync = true) => {
|
||||
followsLatest = true
|
||||
applyTransform(zoomTransformWith(k, minTranslateX(k)), sync)
|
||||
}
|
||||
|
||||
const visibleTickIndexes = () => {
|
||||
if (!latestXScale) return [] as Date[]
|
||||
const maxTicks = Math.max(2, Math.floor(plotWidth.value / minXAxisTickSpacing))
|
||||
const tickDates = latestXScale.ticks(maxTicks).filter((date) => {
|
||||
const x = latestXScale?.(date) ?? 0
|
||||
return x >= margin.left && x <= margin.left + plotWidth.value
|
||||
})
|
||||
|
||||
const visibleTicks: Date[] = []
|
||||
let previousX = Number.NEGATIVE_INFINITY
|
||||
for (const date of tickDates) {
|
||||
const x = latestXScale(date)
|
||||
if (x - previousX < minXAxisTickSpacing) continue
|
||||
visibleTicks.push(date)
|
||||
previousX = x
|
||||
}
|
||||
return visibleTicks
|
||||
}
|
||||
|
||||
const visiblePointRange = (points: ChartPoint[]) => {
|
||||
const [domainStart] = timeDomainMs()
|
||||
const pixelsPerMs = basePixelsPerMs() * zoomTransform.k
|
||||
const rawStartMs = domainStart + -zoomTransform.x / pixelsPerMs
|
||||
const rawEndMs = domainStart + (plotWidth.value - zoomTransform.x) / pixelsPerMs
|
||||
const rawStart = d3
|
||||
.bisector<ChartPoint, number>((point) => point.date.getTime())
|
||||
.left(points, rawStartMs)
|
||||
const rawEnd = d3
|
||||
.bisector<ChartPoint, number>((point) => point.date.getTime())
|
||||
.right(points, rawEndMs)
|
||||
const start = Math.max(0, Math.min(points.length - 1, rawStart - offscreenPointBuffer))
|
||||
const end = Math.max(start, Math.min(points.length - 1, rawEnd + offscreenPointBuffer))
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
const renderChart = () => {
|
||||
const points = chartPoints.value
|
||||
const width = size.value.width
|
||||
const height = size.value.height
|
||||
if (
|
||||
!svg.value ||
|
||||
!gridLayer.value ||
|
||||
!xAxisLayer.value ||
|
||||
!yAxisLayer.value ||
|
||||
!areaPath.value ||
|
||||
!linePath.value ||
|
||||
!previousCloseLine.value ||
|
||||
!dotsLayer.value ||
|
||||
!candlesLayer.value ||
|
||||
!hoverLayer.value ||
|
||||
!clipRect.value ||
|
||||
width <= 0 ||
|
||||
height <= 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
d3.select(clipRect.value)
|
||||
.attr('x', margin.left)
|
||||
.attr('y', margin.top)
|
||||
.attr('width', plotWidth.value)
|
||||
.attr('height', plotHeight.value)
|
||||
|
||||
if (!points.length) {
|
||||
d3.select(areaPath.value).attr('d', '')
|
||||
d3.select(linePath.value).attr('d', '')
|
||||
d3.select(previousCloseLine.value).attr('display', 'none')
|
||||
d3.select(dotsLayer.value).selectAll('circle').remove()
|
||||
d3.select(candlesLayer.value).selectAll('*').remove()
|
||||
hideHover()
|
||||
d3.select(gridLayer.value).selectAll('*').remove()
|
||||
d3.select(xAxisLayer.value).selectAll('*').remove()
|
||||
d3.select(yAxisLayer.value).selectAll('*').remove()
|
||||
return
|
||||
}
|
||||
|
||||
const prices =
|
||||
props.mode === 'box'
|
||||
? points.flatMap((point) => {
|
||||
const ohlc = ohlcFor(point)
|
||||
return [ohlc.open, ohlc.high, ohlc.low, ohlc.close]
|
||||
})
|
||||
: points.map(closePrice)
|
||||
if (previousCloseValue.value) prices.push(previousCloseValue.value)
|
||||
const minPrice = d3.min(prices) ?? 0
|
||||
const maxPrice = d3.max(prices) ?? 1
|
||||
const padding = Math.max(1, (maxPrice - minPrice) * 0.08)
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([minPrice - padding, maxPrice + padding])
|
||||
.nice()
|
||||
.range([margin.top + plotHeight.value, margin.top])
|
||||
|
||||
const [domainStart, domainEnd] = timeDomainMs()
|
||||
const xScale = d3
|
||||
.scaleTime()
|
||||
.domain([new Date(domainStart), new Date(domainEnd)])
|
||||
.range([margin.left + zoomTransform.x, margin.left + contentWidth() + zoomTransform.x])
|
||||
latestXScale = xScale
|
||||
latestYScale = yScale
|
||||
|
||||
const line = d3
|
||||
.line<ChartPoint>()
|
||||
.defined((point) => Number.isFinite(closePrice(point)))
|
||||
.x((point) => xScale(point.date))
|
||||
.y((point) => yScale(closePrice(point)))
|
||||
.curve(d3.curveMonotoneX)
|
||||
|
||||
const area = d3
|
||||
.area<ChartPoint>()
|
||||
.defined((point) => Number.isFinite(closePrice(point)))
|
||||
.x((point) => xScale(point.date))
|
||||
.y0(margin.top + plotHeight.value)
|
||||
.y1((point) => yScale(closePrice(point)))
|
||||
.curve(d3.curveMonotoneX)
|
||||
|
||||
const { start, end } = visiblePointRange(points)
|
||||
const visiblePoints = points.slice(start, end + 1)
|
||||
|
||||
d3.select(areaPath.value).attr('d', props.mode === 'line' ? (area(visiblePoints) ?? '') : '')
|
||||
d3.select(linePath.value).attr('d', props.mode === 'line' ? (line(visiblePoints) ?? '') : '')
|
||||
|
||||
d3.select(dotsLayer.value)
|
||||
.selectAll<SVGCircleElement, ChartPoint>('circle')
|
||||
.data(props.mode === 'line' ? visiblePoints : [], (point) => point.at)
|
||||
.join(
|
||||
(enter) =>
|
||||
enter
|
||||
.append('circle')
|
||||
.attr('r', 2.5)
|
||||
.attr('fill', '#d3e3fd')
|
||||
.attr('stroke', '#40dba2')
|
||||
.attr('stroke-width', 1.6),
|
||||
(update) => update,
|
||||
(exit) => exit.remove(),
|
||||
)
|
||||
.attr('cx', (point) => xScale(point.date))
|
||||
.attr('cy', (point) => yScale(closePrice(point)))
|
||||
|
||||
const previousCloseY = previousCloseValue.value ? yScale(previousCloseValue.value) : null
|
||||
d3.select(previousCloseLine.value)
|
||||
.attr('display', previousCloseY === null ? 'none' : null)
|
||||
.attr('x1', margin.left)
|
||||
.attr('x2', margin.left + plotWidth.value)
|
||||
.attr('y1', previousCloseY ?? 0)
|
||||
.attr('y2', previousCloseY ?? 0)
|
||||
|
||||
const candleGroups = d3
|
||||
.select(candlesLayer.value)
|
||||
.selectAll<SVGGElement, ChartPoint>('g')
|
||||
.data(props.mode === 'box' ? visiblePoints : [], (point) => point.at)
|
||||
.join(
|
||||
(enter) => {
|
||||
const group = enter.append('g')
|
||||
group
|
||||
.append('line')
|
||||
.attr('class', 'wick')
|
||||
.attr('stroke-width', 1.4)
|
||||
.attr('stroke-linecap', 'round')
|
||||
group.append('rect').attr('class', 'body').attr('rx', 1.5)
|
||||
return group
|
||||
},
|
||||
(update) => update,
|
||||
(exit) => exit.remove(),
|
||||
)
|
||||
|
||||
candleGroups.each(function (point) {
|
||||
const ohlc = ohlcFor(point)
|
||||
const x = xScale(point.date)
|
||||
const pointIndex = points.indexOf(point)
|
||||
const previous = points[pointIndex - 1]
|
||||
const next = points[pointIndex + 1]
|
||||
const previousGap = previous ? point.date.getTime() - previous.date.getTime() : undefined
|
||||
const nextGap = next ? next.date.getTime() - point.date.getTime() : undefined
|
||||
const gap = Math.min(
|
||||
...(previousGap && nextGap ? [previousGap, nextGap] : [previousGap ?? nextGap ?? dayMs]),
|
||||
)
|
||||
const candleWidth = Math.max(2, Math.min(14, gap * basePixelsPerMs() * zoomTransform.k * 0.62))
|
||||
const yOpen = yScale(ohlc.open)
|
||||
const yClose = yScale(ohlc.close)
|
||||
const yHigh = yScale(ohlc.high)
|
||||
const yLow = yScale(ohlc.low)
|
||||
const rising = ohlc.close >= ohlc.open
|
||||
const color = rising ? '#40dba2' : '#ffb4ab'
|
||||
const bodyY = Math.min(yOpen, yClose)
|
||||
const bodyHeight = Math.max(2, Math.abs(yClose - yOpen))
|
||||
|
||||
d3.select(this)
|
||||
.select<SVGLineElement>('line.wick')
|
||||
.attr('x1', x)
|
||||
.attr('x2', x)
|
||||
.attr('y1', yHigh)
|
||||
.attr('y2', yLow)
|
||||
.attr('stroke', color)
|
||||
|
||||
d3.select(this)
|
||||
.select<SVGRectElement>('rect.body')
|
||||
.attr('x', x - candleWidth / 2)
|
||||
.attr('y', bodyY)
|
||||
.attr('width', candleWidth)
|
||||
.attr('height', bodyHeight)
|
||||
.attr('fill', rising ? 'rgba(64, 219, 162, 0.28)' : 'rgba(255, 180, 171, 0.28)')
|
||||
.attr('stroke', color)
|
||||
.attr('stroke-width', 1.4)
|
||||
})
|
||||
|
||||
const yGridAxis = d3
|
||||
.axisLeft(yScale)
|
||||
.ticks(5)
|
||||
.tickSize(-plotWidth.value)
|
||||
.tickFormat(() => '')
|
||||
d3.select(gridLayer.value)
|
||||
.attr('transform', `translate(${margin.left}, 0)`)
|
||||
.call(yGridAxis)
|
||||
.call((selection) => {
|
||||
selection.select('.domain').remove()
|
||||
selection.selectAll('line').attr('stroke', 'rgba(143, 148, 157, 0.12)')
|
||||
})
|
||||
|
||||
const yAxis = d3
|
||||
.axisLeft(yScale)
|
||||
.ticks(5)
|
||||
.tickSize(0)
|
||||
.tickPadding(8)
|
||||
.tickFormat((value) => Number(value).toLocaleString('ja-JP'))
|
||||
d3.select(yAxisLayer.value)
|
||||
.attr('transform', `translate(${margin.left}, 0)`)
|
||||
.call(yAxis)
|
||||
.call((selection) => {
|
||||
selection.select('.domain').remove()
|
||||
selection.selectAll('text').attr('fill', '#8f949d').attr('font-size', 12)
|
||||
})
|
||||
|
||||
const tickDates = visibleTickIndexes()
|
||||
const xAxis = d3
|
||||
.axisBottom(xScale)
|
||||
.tickValues(tickDates)
|
||||
.tickSize(0)
|
||||
.tickPadding(8)
|
||||
.tickFormat((value) => {
|
||||
const date = value instanceof Date ? value : new Date(Number(value))
|
||||
return labelFor({
|
||||
at: date.toISOString(),
|
||||
date,
|
||||
index: 0,
|
||||
price: 0,
|
||||
})
|
||||
})
|
||||
d3.select(xAxisLayer.value)
|
||||
.attr('transform', `translate(0, ${margin.top + plotHeight.value})`)
|
||||
.call(xAxis)
|
||||
.call((selection) => {
|
||||
selection.select('.domain').remove()
|
||||
selection.selectAll('text').attr('fill', '#8f949d').attr('font-size', 12)
|
||||
})
|
||||
|
||||
if (tooltip.value.visible) {
|
||||
const hovered = nearestPointAt(tooltip.value.x)
|
||||
if (hovered) showHover(hovered)
|
||||
else hideHover()
|
||||
}
|
||||
}
|
||||
|
||||
const nearestPointAt = (x: number) => {
|
||||
if (!latestXScale) return undefined
|
||||
const time = latestXScale.invert(x).getTime()
|
||||
return d3.least(chartPoints.value, (point) => Math.abs(point.date.getTime() - time))
|
||||
}
|
||||
|
||||
const showHover = (point: ChartPoint) => {
|
||||
if (!latestXScale || !latestYScale || !hoverLayer.value || !hoverLine.value || !hoverDot.value)
|
||||
return
|
||||
|
||||
const x = latestXScale(point.date)
|
||||
const ohlc = ohlcFor(point)
|
||||
const y = latestYScale(props.mode === 'box' ? ohlc.close : closePrice(point))
|
||||
if (x < margin.left || x > margin.left + plotWidth.value) {
|
||||
hideHover()
|
||||
return
|
||||
}
|
||||
|
||||
tooltip.value = {
|
||||
visible: true,
|
||||
x,
|
||||
y,
|
||||
price: currencyForMarket(closePrice(point), props.market),
|
||||
jstDate: tooltipDateFor(point, 'Asia/Tokyo'),
|
||||
localDate: tooltipDateFor(point, marketTimeZone.value),
|
||||
detail:
|
||||
props.mode === 'box'
|
||||
? `始 ${currencyForMarket(ohlc.open, props.market)} / 高 ${currencyForMarket(ohlc.high, props.market)} / 安 ${currencyForMarket(ohlc.low, props.market)}`
|
||||
: '',
|
||||
}
|
||||
|
||||
d3.select(hoverLayer.value).style('opacity', 1)
|
||||
d3.select(hoverLine.value)
|
||||
.transition()
|
||||
.duration(120)
|
||||
.ease(d3.easeCubicOut)
|
||||
.attr('x1', x)
|
||||
.attr('x2', x)
|
||||
.attr('y1', margin.top)
|
||||
.attr('y2', margin.top + plotHeight.value)
|
||||
d3.select(hoverDot.value)
|
||||
.transition()
|
||||
.duration(120)
|
||||
.ease(d3.easeCubicOut)
|
||||
.attr('cx', x)
|
||||
.attr('cy', y)
|
||||
.attr('r', 5)
|
||||
}
|
||||
|
||||
const hideHover = () => {
|
||||
tooltip.value = { ...tooltip.value, visible: false }
|
||||
if (!hoverLayer.value || !hoverDot.value) return
|
||||
d3.select(hoverLayer.value).transition().duration(120).style('opacity', 0)
|
||||
d3.select(hoverDot.value).transition().duration(120).attr('r', 0)
|
||||
}
|
||||
|
||||
const setZoomScale = (value: number) => {
|
||||
const k = Math.min(maxZoom, Math.max(minZoom, value))
|
||||
const center = plotWidth.value / 2
|
||||
const contentCenter = (center - zoomTransform.x) / zoomTransform.k
|
||||
applyTransform(zoomTransformWith(k, center - contentCenter * k))
|
||||
}
|
||||
|
||||
const zoomOut = () => setZoomScale(zoomScale.value - zoomStep)
|
||||
|
||||
const zoomIn = () => setZoomScale(zoomScale.value + zoomStep)
|
||||
|
||||
const resetZoom = () => alignLatest(1)
|
||||
|
||||
defineExpose({
|
||||
resetZoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
})
|
||||
|
||||
const normalizedWheelDelta = (value: number, event: WheelEvent) => {
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_LINE) return value * 16
|
||||
if (event.deltaMode === WheelEvent.DOM_DELTA_PAGE) return value * plotWidth.value
|
||||
return value
|
||||
}
|
||||
|
||||
const zoomAround = (k: number, localX: number) => {
|
||||
const nextK = Math.min(maxZoom, Math.max(minZoom, k))
|
||||
const center = Math.min(plotWidth.value, Math.max(0, localX - margin.left))
|
||||
const contentCenter = (center - zoomTransform.x) / zoomTransform.k
|
||||
applyTransform(zoomTransformWith(nextK, center - contentCenter * nextK))
|
||||
}
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
event.preventDefault()
|
||||
hideHover()
|
||||
|
||||
const deltaX = normalizedWheelDelta(event.deltaX, event)
|
||||
const deltaY = normalizedWheelDelta(event.deltaY, event)
|
||||
const panDelta = event.shiftKey && !deltaX ? deltaY : deltaX
|
||||
|
||||
if ((event.shiftKey && panDelta) || Math.abs(panDelta) > Math.abs(deltaY)) {
|
||||
applyTransform(zoomTransformWith(zoomTransform.k, zoomTransform.x - panDelta))
|
||||
return
|
||||
}
|
||||
|
||||
if (!deltaY) return
|
||||
const direction = deltaY < 0 ? 1 : -1
|
||||
const [x] = svg.value ? d3.pointer(event, svg.value) : [event.offsetX]
|
||||
zoomAround(zoomTransform.k + direction * zoomStep, x)
|
||||
}
|
||||
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!svg.value || event.buttons) {
|
||||
hideHover()
|
||||
return
|
||||
}
|
||||
const [x, y] = d3.pointer(event, svg.value)
|
||||
const insidePlot =
|
||||
x >= margin.left &&
|
||||
x <= margin.left + plotWidth.value &&
|
||||
y >= margin.top &&
|
||||
y <= margin.top + plotHeight.value
|
||||
if (!insidePlot) {
|
||||
hideHover()
|
||||
return
|
||||
}
|
||||
const point = nearestPointAt(x)
|
||||
if (point) showHover(point)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (!root.value || !svg.value) return
|
||||
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
const rect = entries[0]?.contentRect
|
||||
if (!rect) return
|
||||
size.value = { width: rect.width, height: rect.height }
|
||||
if (followsLatest) alignLatest(zoomTransform.k)
|
||||
else applyTransform(zoomTransform)
|
||||
})
|
||||
resizeObserver.observe(root.value)
|
||||
|
||||
zoomBehavior = d3
|
||||
.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([minZoom, maxZoom])
|
||||
.filter((event) => event.type !== 'wheel' && !event.button && event.type !== 'dblclick')
|
||||
.on('zoom', (event) => {
|
||||
if (syncingZoom) return
|
||||
applyTransform(event.transform, false)
|
||||
syncZoomBehavior(zoomTransform)
|
||||
})
|
||||
|
||||
d3.select(svg.value).call(zoomBehavior).on('dblclick.zoom', null)
|
||||
alignLatest(1)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.points,
|
||||
() => {
|
||||
if (followsLatest) alignLatest(zoomTransform.k)
|
||||
else applyTransform(zoomTransform)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.mode, props.range, props.previousClose],
|
||||
() => {
|
||||
hideHover()
|
||||
renderChart()
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resizeObserver?.disconnect()
|
||||
if (svg.value) d3.select(svg.value).on('.zoom', null)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="relative h-full w-full">
|
||||
<svg
|
||||
ref="svg"
|
||||
class="h-full w-full cursor-grab touch-none select-none active:cursor-grabbing"
|
||||
:aria-label="`${stockName} realtime price chart`"
|
||||
role="img"
|
||||
@wheel="handleWheel"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerleave="hideHover"
|
||||
>
|
||||
<defs>
|
||||
<clipPath :id="clipId">
|
||||
<rect ref="clipRect"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<g ref="gridLayer"></g>
|
||||
<g ref="yAxisLayer"></g>
|
||||
<g ref="xAxisLayer"></g>
|
||||
<path :clip-path="`url(#${clipId})`" ref="areaPath" fill="rgba(64, 219, 162, 0.14)"></path>
|
||||
<path
|
||||
:clip-path="`url(#${clipId})`"
|
||||
ref="linePath"
|
||||
fill="none"
|
||||
stroke="#40dba2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2.2"
|
||||
></path>
|
||||
<line
|
||||
ref="previousCloseLine"
|
||||
stroke="#ffcf6e"
|
||||
stroke-dasharray="5 5"
|
||||
stroke-linecap="round"
|
||||
stroke-width="1.2"
|
||||
></line>
|
||||
<g :clip-path="`url(#${clipId})`" ref="candlesLayer"></g>
|
||||
<g :clip-path="`url(#${clipId})`" ref="dotsLayer"></g>
|
||||
<g ref="hoverLayer" :clip-path="`url(#${clipId})`" class="pointer-events-none" opacity="0">
|
||||
<line
|
||||
ref="hoverLine"
|
||||
stroke="rgba(211, 227, 253, 0.42)"
|
||||
stroke-dasharray="4 4"
|
||||
stroke-width="1"
|
||||
></line>
|
||||
<circle ref="hoverDot" fill="#111418" r="0" stroke="#40dba2" stroke-width="2"></circle>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<span
|
||||
v-if="active && !points.length"
|
||||
class="absolute inset-0 grid place-items-center text-[#8f949d]"
|
||||
>
|
||||
<Spinner />
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="pointer-events-none absolute w-[220px] translate-y-1 rounded-md border border-[#2d3440] bg-[#111418]/95 px-3 py-2 text-xs text-[#d3e3fd] opacity-0 shadow-lg shadow-black/30 transition-all duration-150 ease-out"
|
||||
:class="tooltip.visible && 'translate-y-0 opacity-100'"
|
||||
:style="tooltipStyle"
|
||||
>
|
||||
<div class="font-black text-[#40dba2]">{{ tooltip.price }}</div>
|
||||
<div class="mt-1 grid gap-0.5 text-[#8f949d]">
|
||||
<div>JST {{ tooltip.jstDate }}</div>
|
||||
<div>現地 {{ tooltip.localDate }}</div>
|
||||
</div>
|
||||
<div v-if="tooltip.detail" class="mt-1 leading-relaxed text-[#8f949d]">
|
||||
{{ tooltip.detail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,635 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
FileCheck2,
|
||||
Minus,
|
||||
TriangleAlert,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
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 {
|
||||
cashOrderMethodOptions,
|
||||
cashOrderPriceConditionOptions,
|
||||
cashOrderTriggerZoneOptions,
|
||||
orderKindOptions,
|
||||
tradeSideOptions,
|
||||
} from '../../constants/trade'
|
||||
import { ui } from '../../styles/ui'
|
||||
import { uiMotion } from '../../constants/motion'
|
||||
import type {
|
||||
ChartMode,
|
||||
ChartNotice,
|
||||
ChartRange,
|
||||
CashOrderAccountType,
|
||||
CashOrderMarket,
|
||||
CashOrderMethod,
|
||||
CashOrderPriceCondition,
|
||||
CashOrderTerm,
|
||||
CashOrderTriggerZone,
|
||||
OrderKind,
|
||||
Position,
|
||||
RealtimePricePoint,
|
||||
Stock,
|
||||
TradeSide,
|
||||
} from '../../types/trading'
|
||||
import { currency, currencyForMarket } from '../../utils/format'
|
||||
import RealtimePriceChart from './RealtimePriceChart.vue'
|
||||
|
||||
type StockInfoTab = 'detail' | 'holding'
|
||||
type CashOrderAccountTypeOption = {
|
||||
label: string
|
||||
value: CashOrderAccountType
|
||||
}
|
||||
type CashOrderMarketOption = {
|
||||
label: string
|
||||
value: CashOrderMarket
|
||||
}
|
||||
type CashOrderTermOption = {
|
||||
label: string
|
||||
value: CashOrderTerm
|
||||
}
|
||||
type CashOrderDateOption = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const chartRangeOptions: ChartRange[] = ['1D', '3D', '3M', '1Y', 'ALL']
|
||||
|
||||
defineProps<{
|
||||
viewedStocks: Stock[]
|
||||
selectedStock: Stock
|
||||
selectedPosition?: Position
|
||||
connected: boolean
|
||||
orderQuantity: number
|
||||
estimatedAmount: number
|
||||
cashOrderAccountTypeOptions: CashOrderAccountTypeOption[]
|
||||
cashOrderMarketOptions: CashOrderMarketOption[]
|
||||
cashOrderTermOptions: CashOrderTermOption[]
|
||||
cashOrderDateOptions: CashOrderDateOption[]
|
||||
cashOrderPriceStep: number
|
||||
canRequestCashEstimate: boolean
|
||||
canPlaceCashOrder: boolean
|
||||
realtimePricePoints: RealtimePricePoint[]
|
||||
chartNotice: ChartNotice | null
|
||||
pricePolling: boolean
|
||||
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 chartRange = defineModel<ChartRange>('chartRange', { required: true })
|
||||
const activeStockInfoTab = ref<StockInfoTab>('detail')
|
||||
const advancedOptionsOpen = ref(false)
|
||||
const priceChart = ref<InstanceType<typeof RealtimePriceChart> | null>(null)
|
||||
const primaryPriceConditionRequiresPrice = computed(() =>
|
||||
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
|
||||
cashOrderPriceCondition.value,
|
||||
),
|
||||
)
|
||||
const secondaryPriceConditionRequiresPrice = computed(() =>
|
||||
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
|
||||
cashOrderSecondaryPriceCondition.value,
|
||||
),
|
||||
)
|
||||
const tabTransition = uiMotion.trade.tabIndicator
|
||||
const tabContentTransition = uiMotion.trade.tabContentFade
|
||||
const advancedTransition = uiMotion.trade.advancedOptions
|
||||
|
||||
const emit = defineEmits<{
|
||||
openSearch: []
|
||||
selectStock: [stock: Stock]
|
||||
downloadCsv: []
|
||||
estimate: []
|
||||
confirmOrder: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section :class="ui.tradeLayout">
|
||||
<div :class="ui.centerStack">
|
||||
<article :class="ui.stockMetadataPanel">
|
||||
<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)">{{
|
||||
currencyForMarket(selectedStock.price, selectedStock.market)
|
||||
}}</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>
|
||||
</article>
|
||||
|
||||
<div :class="ui.stockBodyGrid">
|
||||
<article :class="[ui.stockPanel, ui.stockPanelSpan]">
|
||||
<div :class="ui.periodTabs">
|
||||
<button
|
||||
v-for="range in chartRangeOptions"
|
||||
:key="range"
|
||||
:class="[ui.periodButton, chartRange === range && ui.periodButtonActive]"
|
||||
type="button"
|
||||
:aria-pressed="chartRange === range"
|
||||
@click="chartRange = range"
|
||||
>
|
||||
{{ range }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :class="ui.chartActions">
|
||||
<div class="flex items-center gap-2">
|
||||
<div :class="ui.smallTabs">
|
||||
<button
|
||||
:class="[ui.smallTab, chartMode === 'line' && ui.smallTabActive]"
|
||||
type="button"
|
||||
@click="chartMode = 'line'"
|
||||
>
|
||||
推移
|
||||
</button>
|
||||
<button
|
||||
:class="[ui.smallTab, chartMode === 'box' && ui.smallTabActive]"
|
||||
type="button"
|
||||
@click="chartMode = 'box'"
|
||||
>
|
||||
箱ひげ
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="grid h-8 w-8 place-items-center rounded-md border border-[#2d3440] text-[#d3e3fd] transition hover:bg-[#1d232b] disabled:opacity-40"
|
||||
type="button"
|
||||
aria-label="縮小"
|
||||
:disabled="!hasQuote(selectedStock)"
|
||||
@click="priceChart?.zoomOut()"
|
||||
>
|
||||
<Minus class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="grid h-8 w-8 place-items-center rounded-md border border-[#2d3440] text-[#d3e3fd] transition hover:bg-[#1d232b] disabled:opacity-40"
|
||||
type="button"
|
||||
aria-label="拡大"
|
||||
:disabled="!hasQuote(selectedStock)"
|
||||
@click="priceChart?.zoomIn()"
|
||||
>
|
||||
<Plus class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="grid h-8 w-8 place-items-center rounded-md border border-[#2d3440] text-[#8f949d] transition hover:bg-[#1d232b] hover:text-[#d3e3fd] disabled:opacity-40"
|
||||
type="button"
|
||||
aria-label="縮尺を戻す"
|
||||
:disabled="!hasQuote(selectedStock)"
|
||||
@click="priceChart?.resetZoom()"
|
||||
>
|
||||
<RotateCcw class="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button :class="ui.ghostButton" type="button" @click="emit('downloadCsv')">
|
||||
<Download class="h-4 w-4" aria-hidden="true" />
|
||||
CSV
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :class="[ui.chartBox, 'overflow-visible']">
|
||||
<RealtimePriceChart
|
||||
v-if="hasQuote(selectedStock)"
|
||||
ref="priceChart"
|
||||
:points="realtimePricePoints"
|
||||
:stock-name="selectedStock.name"
|
||||
:active="pricePolling"
|
||||
:mode="chartMode"
|
||||
:range="chartRange"
|
||||
:market="selectedStock.market"
|
||||
:previous-close="selectedStock.prevClose"
|
||||
:notice="chartNotice"
|
||||
/>
|
||||
<span v-else class="grid h-full place-items-center text-[#8f949d]">
|
||||
<Spinner />
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="chartNotice"
|
||||
class="mt-2 flex items-start gap-1.5 px-3 pb-2 text-xs text-[#8f949d]"
|
||||
>
|
||||
<TriangleAlert class="mt-0.5 h-4 w-4 shrink-0 text-[#6f7783]" />
|
||||
<span>
|
||||
<span class="font-medium text-[#9aa0a9]">{{ chartNotice.title }}</span>
|
||||
<span v-if="chartNotice.detail" class="ml-1">{{ chartNotice.detail }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<LayoutGroup id="stock-info-tabs">
|
||||
<article :class="ui.infoTabPanel">
|
||||
<div :class="ui.infoTabList" role="tablist" aria-label="銘柄別情報">
|
||||
<button
|
||||
id="stock-detail-tab"
|
||||
:class="[
|
||||
ui.infoTabButton,
|
||||
activeStockInfoTab === 'detail' && ui.infoTabButtonActive,
|
||||
]"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeStockInfoTab === 'detail'"
|
||||
aria-controls="stock-detail-panel"
|
||||
@click="activeStockInfoTab = 'detail'"
|
||||
>
|
||||
詳細
|
||||
<motion.span
|
||||
v-if="activeStockInfoTab === 'detail'"
|
||||
layout-id="stock-info-tab-indicator"
|
||||
:class="ui.infoTabIndicator"
|
||||
:transition="tabTransition"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
id="stock-holding-tab"
|
||||
:class="[
|
||||
ui.infoTabButton,
|
||||
activeStockInfoTab === 'holding' && ui.infoTabButtonActive,
|
||||
]"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="activeStockInfoTab === 'holding'"
|
||||
aria-controls="stock-holding-panel"
|
||||
@click="activeStockInfoTab = 'holding'"
|
||||
>
|
||||
保有
|
||||
<motion.span
|
||||
v-if="activeStockInfoTab === 'holding'"
|
||||
layout-id="stock-info-tab-indicator"
|
||||
:class="ui.infoTabIndicator"
|
||||
:transition="tabTransition"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div :class="ui.infoTabBody">
|
||||
<div :class="ui.infoTabBodyInner">
|
||||
<motion.dl
|
||||
v-show="activeStockInfoTab === 'detail'"
|
||||
id="stock-detail-panel"
|
||||
key="detail"
|
||||
:class="ui.detailGrid"
|
||||
role="tabpanel"
|
||||
aria-labelledby="stock-detail-tab"
|
||||
:initial="{ opacity: 0 }"
|
||||
:animate="{ opacity: activeStockInfoTab === 'detail' ? 1 : 0 }"
|
||||
:transition="tabContentTransition"
|
||||
>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">国</dt>
|
||||
<dd class="font-bold">{{ selectedStock.country }}</dd>
|
||||
</div>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">市場</dt>
|
||||
<dd class="font-bold">{{ selectedStock.market }}</dd>
|
||||
</div>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">業種</dt>
|
||||
<dd class="font-bold">{{ selectedStock.sector }}</dd>
|
||||
</div>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">始値</dt>
|
||||
<dd class="font-bold">
|
||||
<template v-if="hasQuote(selectedStock)">{{
|
||||
currencyForMarket(selectedStock.open, selectedStock.market)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</dd>
|
||||
</div>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">高値</dt>
|
||||
<dd class="font-bold">
|
||||
<template v-if="hasQuote(selectedStock)">{{
|
||||
currencyForMarket(selectedStock.high, selectedStock.market)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</dd>
|
||||
</div>
|
||||
<div :class="ui.detailItem">
|
||||
<dt class="text-xs text-[#9aa0a9]">安値</dt>
|
||||
<dd class="font-bold">
|
||||
<template v-if="hasQuote(selectedStock)">{{
|
||||
currencyForMarket(selectedStock.low, selectedStock.market)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</dd>
|
||||
</div>
|
||||
</motion.dl>
|
||||
<motion.div
|
||||
v-show="activeStockInfoTab === 'holding'"
|
||||
id="stock-holding-panel"
|
||||
key="holding"
|
||||
role="tabpanel"
|
||||
aria-labelledby="stock-holding-tab"
|
||||
:initial="{ opacity: 0 }"
|
||||
:animate="{ opacity: activeStockInfoTab === 'holding' ? 1 : 0 }"
|
||||
:transition="tabContentTransition"
|
||||
>
|
||||
<div v-if="selectedPosition" :class="ui.holdingNote">
|
||||
保有 {{ selectedPosition.quantity }}株 / 平均
|
||||
{{ currencyForMarket(selectedPosition.avgPrice, selectedPosition.market) }}
|
||||
</div>
|
||||
<p v-else :class="ui.holdingEmpty">保有なし</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</LayoutGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="ui.tradeSeparator" aria-hidden="true"></div>
|
||||
|
||||
<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="株数を入力"
|
||||
/>
|
||||
<section :class="ui.advancedOptions">
|
||||
<button
|
||||
type="button"
|
||||
:class="ui.advancedSummary"
|
||||
:aria-expanded="advancedOptionsOpen"
|
||||
@click="advancedOptionsOpen = !advancedOptionsOpen"
|
||||
>
|
||||
<span>Advanced Options</span>
|
||||
<motion.span
|
||||
:class="ui.advancedSummaryIconWrap"
|
||||
:animate="{ rotate: advancedOptionsOpen ? 180 : 0 }"
|
||||
:transition="advancedTransition"
|
||||
>
|
||||
<ChevronDown :class="ui.advancedSummaryIcon" aria-hidden="true" />
|
||||
</motion.span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
v-if="advancedOptionsOpen"
|
||||
key="advanced-options"
|
||||
:class="ui.advancedBody"
|
||||
:initial="{ opacity: 0, height: 0, y: -4 }"
|
||||
:animate="{ opacity: 1, height: 'auto', y: 0 }"
|
||||
:exit="{ opacity: 0, height: 0, y: -4 }"
|
||||
:transition="advancedTransition"
|
||||
>
|
||||
<span :class="ui.advancedLabel">預り区分</span>
|
||||
<LayoutGroup id="cash-order-account-type">
|
||||
<div :class="ui.accountTypeGrid" role="radiogroup" aria-label="預り区分">
|
||||
<button
|
||||
v-for="option in cashOrderAccountTypeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
:aria-checked="cashOrderAccountType === option.value"
|
||||
:class="[
|
||||
ui.accountTypeButton,
|
||||
cashOrderAccountType === option.value && ui.accountTypeButtonActive,
|
||||
]"
|
||||
@click="cashOrderAccountType = option.value"
|
||||
>
|
||||
<motion.span
|
||||
v-if="cashOrderAccountType === option.value"
|
||||
layout-id="cash-order-account-type-indicator"
|
||||
:class="ui.accountTypeIndicator"
|
||||
:transition="tabTransition"
|
||||
/>
|
||||
<span :class="ui.accountTypeText">{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
<UiField v-model="cashOrderMarket" as="select" label="注文市場">
|
||||
<option
|
||||
v-for="option in cashOrderMarketOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<template>
|
||||
<UiField v-model="cashOrderPriceCondition" as="select" label="執行条件">
|
||||
<option
|
||||
v-for="option in cashOrderPriceConditionOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<UiField
|
||||
v-if="primaryPriceConditionRequiresPrice"
|
||||
v-model="priceInput"
|
||||
label="注文価格"
|
||||
type="number"
|
||||
min="1"
|
||||
:step="cashOrderPriceStep"
|
||||
placeholder="価格を入力"
|
||||
/>
|
||||
<UiField v-model="cashOrderTerm" as="select" label="有効期限">
|
||||
<option
|
||||
v-for="option in cashOrderTermOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<UiField
|
||||
v-if="cashOrderTerm === 'date' && cashOrderDateOptions.length"
|
||||
v-model="cashOrderDateInput"
|
||||
as="select"
|
||||
label="指定日"
|
||||
>
|
||||
<option
|
||||
v-for="option in cashOrderDateOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<UiField
|
||||
v-else-if="cashOrderTerm === 'date'"
|
||||
v-model="cashOrderDateInput"
|
||||
label="指定日"
|
||||
type="date"
|
||||
/>
|
||||
<UiField v-model="cashOrderMethod" as="select" label="特殊注文">
|
||||
<option
|
||||
v-for="option in cashOrderMethodOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<template v-if="cashOrderMethod !== 'normal'">
|
||||
<UiField v-model="cashOrderTriggerZone" as="select" label="逆指値条件">
|
||||
<option
|
||||
v-for="option in cashOrderTriggerZoneOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<UiField
|
||||
v-model="cashOrderTriggerPriceInput"
|
||||
label="逆指値価格"
|
||||
type="number"
|
||||
min="1"
|
||||
:step="cashOrderPriceStep"
|
||||
placeholder="価格を入力"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="cashOrderMethod === 'oco'">
|
||||
<UiField
|
||||
v-model="cashOrderSecondaryPriceCondition"
|
||||
as="select"
|
||||
label="OCO執行条件"
|
||||
>
|
||||
<option
|
||||
v-for="option in cashOrderPriceConditionOptions"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</UiField>
|
||||
<UiField
|
||||
v-if="secondaryPriceConditionRequiresPrice"
|
||||
v-model="cashOrderSecondaryPriceInput"
|
||||
label="OCO価格"
|
||||
type="number"
|
||||
min="1"
|
||||
:step="cashOrderPriceStep"
|
||||
placeholder="価格を入力"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div :class="ui.ticketBottom">
|
||||
<div :class="ui.estimateSummary">
|
||||
<span>概算金額</span>
|
||||
<strong>{{ currencyForMarket(estimatedAmount, selectedStock.market) }}</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>
|
||||
|
||||
<article :class="ui.watchlist">
|
||||
<button :class="ui.watchSearch" type="button" @click="emit('openSearch')">
|
||||
<Search class="h-4 w-4" aria-hidden="true" />
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
v-for="stock in viewedStocks"
|
||||
:key="stock.code"
|
||||
:class="[ui.watchRow, selectedStock.code === stock.code && ui.watchRowActive]"
|
||||
type="button"
|
||||
:aria-current="selectedStock.code === stock.code ? 'true' : undefined"
|
||||
@click="emit('selectStock', stock)"
|
||||
>
|
||||
<span class="grid gap-1">
|
||||
<strong>{{ stock.name }}</strong>
|
||||
<small>{{ stock.symbol }}</small>
|
||||
</span>
|
||||
<span class="grid justify-items-end gap-1">
|
||||
<strong>
|
||||
<template v-if="hasQuote(stock)">{{
|
||||
currencyForMarket(stock.price, stock.market)
|
||||
}}</template>
|
||||
<Spinner v-else size="sm" />
|
||||
</strong>
|
||||
<small v-if="hasQuote(stock)" :class="stock.change >= 0 ? ui.positive : ui.negative">
|
||||
{{ stock.change >= 0 ? '+' : '' }}{{ stock.change }}%
|
||||
</small>
|
||||
<Spinner v-else size="sm" />
|
||||
</span>
|
||||
</button>
|
||||
<p v-if="!viewedStocks.length" :class="[ui.muted, 'p-5 text-sm']">
|
||||
SBIに接続するか検索すると銘柄を表示します
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,575 @@
|
||||
import type {
|
||||
ChartNotice,
|
||||
MarketIndex,
|
||||
OrderDetail,
|
||||
OrderPreview,
|
||||
OrderRow,
|
||||
Position,
|
||||
RealtimePricePoint,
|
||||
Stock,
|
||||
TradeRecordRow,
|
||||
} from '../../types/trading'
|
||||
import { currency } from '../../utils/format'
|
||||
|
||||
export type RecordLike = Record<string, unknown>
|
||||
type IssueLike = {
|
||||
code: string
|
||||
market: string
|
||||
name: string
|
||||
searchText?: string
|
||||
}
|
||||
|
||||
export const emptyStock: Stock = {
|
||||
code: '',
|
||||
name: '未選択',
|
||||
symbol: '',
|
||||
searchText: '',
|
||||
country: '日本',
|
||||
market: '',
|
||||
sector: '',
|
||||
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
|
||||
|
||||
const issueSymbol = (code: string, market: string) => (market ? `${market}:${code}` : code)
|
||||
|
||||
const countryFromMarket = (market: string) =>
|
||||
market === 'XNAS' || market === 'XNYS' || market === 'ARCX' ? '米国' : '日本'
|
||||
|
||||
export const issueFrom = (value: unknown) => {
|
||||
const issue = asRecord(value)
|
||||
const code = textValue(issue.code, textValue(issue.issueCode))
|
||||
const market = textValue(issue.market, textValue(issue.marketCode, ''))
|
||||
const name = textValue(
|
||||
issue.name,
|
||||
textValue(issue.issueName, textValue(issue.stockName, textValue(issue.displayName))),
|
||||
)
|
||||
const searchText = [
|
||||
code,
|
||||
market,
|
||||
name,
|
||||
textValue(issue.extract),
|
||||
textValue(issue.extractWord),
|
||||
textValue(issue.hitString),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
return {
|
||||
code,
|
||||
market,
|
||||
name,
|
||||
searchText,
|
||||
}
|
||||
}
|
||||
|
||||
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: IssueLike): Stock => ({
|
||||
code: issue.code,
|
||||
name: issue.name || issue.code,
|
||||
symbol: issue.code ? issueSymbol(issue.code, issue.market) : '',
|
||||
searchText: issue.searchText,
|
||||
country: countryFromMarket(issue.market),
|
||||
market: issue.market,
|
||||
sector: '',
|
||||
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 },
|
||||
}
|
||||
}
|
||||
|
||||
const accountTypeLabels: Record<string, string> = {
|
||||
specific: '特定',
|
||||
general: '一般',
|
||||
growthInvestment: 'NISA成長投資枠',
|
||||
nisa: 'NISA',
|
||||
}
|
||||
|
||||
const accountTypeLabel = (value: string) => accountTypeLabels[value] ?? value
|
||||
|
||||
export const stockFromBoard = (value: unknown, fallbackIssue?: IssueLike): Stock => {
|
||||
const board = asRecord(value)
|
||||
const quoteRecord = asRecord(board.quote)
|
||||
const boardIssue = issueFrom(board.issue)
|
||||
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 || '',
|
||||
searchText: quoteIssue.searchText || boardIssue.searchText || fallbackIssue?.searchText || '',
|
||||
}
|
||||
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 ? issueSymbol(issue.code, issue.market) : '',
|
||||
searchText: issue.searchText,
|
||||
country: countryFromMarket(issue.market),
|
||||
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 signedValueFrom = (value: unknown) => {
|
||||
const record = asRecord(value)
|
||||
const parsedValue = nullableNumberValue(record.value)
|
||||
const text = textValue(record.text, parsedValue == null ? '' : String(parsedValue))
|
||||
const rawSign = textValue(record.sign)
|
||||
const sign: MarketIndex['sign'] =
|
||||
rawSign === 'positive' || rawSign === 'negative' || rawSign === 'zero'
|
||||
? rawSign
|
||||
: parsedValue == null || parsedValue === 0
|
||||
? 'zero'
|
||||
: parsedValue > 0
|
||||
? 'positive'
|
||||
: 'negative'
|
||||
return { value: parsedValue, text, sign }
|
||||
}
|
||||
|
||||
const percentValueFrom = (value: unknown) => {
|
||||
const record = asRecord(value)
|
||||
const parsedValue = nullableNumberValue(record.value)
|
||||
return {
|
||||
value: parsedValue,
|
||||
text: textValue(record.text, parsedValue == null ? '' : `${parsedValue}%`),
|
||||
}
|
||||
}
|
||||
|
||||
export const marketIndexFromApi = (value: unknown): MarketIndex | null => {
|
||||
const index = asRecord(value)
|
||||
const name = textValue(index.name)
|
||||
if (!name) return null
|
||||
|
||||
const parsedValue = nullableNumberValue(index.value)
|
||||
const change = signedValueFrom(index.change)
|
||||
const changeRate = percentValueFrom(index.changeRate)
|
||||
return {
|
||||
code: textValue(index.code) || undefined,
|
||||
name,
|
||||
value: parsedValue,
|
||||
valueText: textValue(index.valueText, parsedValue == null ? '' : String(parsedValue)),
|
||||
change: change.value,
|
||||
changeText: change.text,
|
||||
changeRate: changeRate.value,
|
||||
changeRateText: changeRate.text,
|
||||
sign: change.sign,
|
||||
timestamp: textValue(index.timestamp) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const zonedPartsFormatter = (timeZone: string) =>
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
hourCycle: 'h23',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
|
||||
const zonedParts = (date: Date, timeZone: string) => {
|
||||
const parts = Object.fromEntries(
|
||||
zonedPartsFormatter(timeZone)
|
||||
.formatToParts(date)
|
||||
.filter((part) => part.type !== 'literal')
|
||||
.map((part) => [part.type, Number(part.value)]),
|
||||
) as Partial<Record<'year' | 'month' | 'day' | 'hour' | 'minute' | 'second', number>>
|
||||
|
||||
const missing = (['year', 'month', 'day', 'hour', 'minute', 'second'] as const).filter(
|
||||
(part) => parts[part] == null,
|
||||
)
|
||||
if (missing.length) {
|
||||
throw new Error(`Failed to read ${missing.join(', ')} from ${timeZone} date parts`)
|
||||
}
|
||||
|
||||
return {
|
||||
year: parts.year as number,
|
||||
month: parts.month as number,
|
||||
day: parts.day as number,
|
||||
hour: parts.hour as number,
|
||||
minute: parts.minute as number,
|
||||
second: parts.second as number,
|
||||
}
|
||||
}
|
||||
|
||||
const timeZoneOffsetMs = (date: Date, timeZone: string) => {
|
||||
const parts = zonedParts(date, timeZone)
|
||||
return (
|
||||
Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second) -
|
||||
date.getTime()
|
||||
)
|
||||
}
|
||||
|
||||
const zonedDateTimeToUtcMs = (
|
||||
timeZone: string,
|
||||
parts: {
|
||||
year: number
|
||||
month: number
|
||||
day: number
|
||||
hour?: number
|
||||
minute?: number
|
||||
second?: number
|
||||
},
|
||||
) => {
|
||||
const utcGuess = Date.UTC(
|
||||
parts.year,
|
||||
parts.month - 1,
|
||||
parts.day,
|
||||
parts.hour ?? 0,
|
||||
parts.minute ?? 0,
|
||||
parts.second ?? 0,
|
||||
)
|
||||
return utcGuess - timeZoneOffsetMs(new Date(utcGuess), timeZone)
|
||||
}
|
||||
|
||||
export const startOfMarketDateUtcMs = (timeZone: string, now = new Date()) => {
|
||||
const parts = zonedParts(now, timeZone)
|
||||
return zonedDateTimeToUtcMs(timeZone, {
|
||||
year: parts.year,
|
||||
month: parts.month,
|
||||
day: parts.day,
|
||||
})
|
||||
}
|
||||
|
||||
export const marketDateKey = (timeZone: string, now = new Date()) => {
|
||||
const parts = zonedParts(now, timeZone)
|
||||
return [
|
||||
parts.year,
|
||||
String(parts.month).padStart(2, '0'),
|
||||
String(parts.day).padStart(2, '0'),
|
||||
].join('-')
|
||||
}
|
||||
|
||||
const chartDateTimeToIso = (value: string, timeZone = 'Asia/Tokyo') => {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
if (digits.length < 8) return ''
|
||||
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 time = zonedDateTimeToUtcMs(timeZone, { year, month, day, hour, minute })
|
||||
return Number.isNaN(time) ? '' : new Date(time).toISOString()
|
||||
}
|
||||
|
||||
export const pricePointsFromIssueChart = (
|
||||
value: unknown,
|
||||
timeZone = 'Asia/Tokyo',
|
||||
): RealtimePricePoint[] =>
|
||||
asArray(asRecord(value).prices)
|
||||
.map((entry) => {
|
||||
const price = asRecord(entry)
|
||||
const at = chartDateTimeToIso(textValue(price.dateTime), timeZone)
|
||||
const close = numberValue(price.close)
|
||||
return {
|
||||
at,
|
||||
price: close,
|
||||
open: numberValue(price.open),
|
||||
high: numberValue(price.high),
|
||||
low: numberValue(price.low),
|
||||
close,
|
||||
volume: numberValue(price.volume, 0),
|
||||
}
|
||||
})
|
||||
.filter((point) => point.at && point.price > 0)
|
||||
|
||||
const japanDateFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tokyo',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
|
||||
const chartDateTimeToLocalDate = (value: string) => {
|
||||
const digits = value.replace(/\D/g, '')
|
||||
if (digits.length < 8) return ''
|
||||
return `${digits.slice(0, 4)}-${digits.slice(4, 6)}-${digits.slice(6, 8)}`
|
||||
}
|
||||
|
||||
const japanToday = () => japanDateFormatter.format(new Date())
|
||||
|
||||
const isWeekendInJapan = () => {
|
||||
const weekday = new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: 'Asia/Tokyo',
|
||||
weekday: 'short',
|
||||
}).format(new Date())
|
||||
return weekday === 'Sat' || weekday === 'Sun'
|
||||
}
|
||||
|
||||
export const chartNoticeFromIssueChart = (value: unknown): ChartNotice | null => {
|
||||
const chart = asRecord(value)
|
||||
const latestDate = chartDateTimeToLocalDate(textValue(chart.latestDateTime))
|
||||
const today = japanToday()
|
||||
if (!latestDate || latestDate >= today) return null
|
||||
|
||||
if (!isWeekendInJapan()) return null
|
||||
|
||||
const formattedLatestDate = new Intl.DateTimeFormat('ja-JP', {
|
||||
timeZone: 'Asia/Tokyo',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
}).format(new Date(`${latestDate}T00:00:00+09:00`))
|
||||
|
||||
return {
|
||||
title: '本日は休場日です',
|
||||
detail: `最新のチャートは${formattedLatestDate}です`,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
const accountType = textValue(item.accountType, textValue(item.depositType))
|
||||
return {
|
||||
code: issue.code,
|
||||
name: issue.name,
|
||||
market: issue.market,
|
||||
quantity,
|
||||
avgPrice,
|
||||
currentPrice: nullableNumberValue(item.currentPrice),
|
||||
marketValue: numberValue(item.marketValue ?? item.valuationPrice),
|
||||
profitLoss,
|
||||
profitLossRate,
|
||||
type:
|
||||
textValue(item.depositTypeText) || (accountType ? accountTypeLabel(accountType) : undefined),
|
||||
accountType: accountType || 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 = '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)
|
||||
: (unexecutedQuantity ?? orderedQuantity ?? 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 ?? ''}`)),
|
||||
code: issue.code,
|
||||
date: textValue(item.orderedAt, textValue(item.expiresAt)),
|
||||
stock: issue.name || issue.code,
|
||||
market: issue.market,
|
||||
side,
|
||||
kind,
|
||||
quantity,
|
||||
unexecutedQuantity,
|
||||
executedQuantity,
|
||||
price,
|
||||
status,
|
||||
orderNumber: textValue(item.orderNumber),
|
||||
orderSubNo: textValue(item.orderSubNo),
|
||||
tradeId: textValue(item.tradeId),
|
||||
accountType: textValue(item.accountType, textValue(item.depositType)),
|
||||
cancelable: typeof item.cancelable === 'boolean' ? item.cancelable : undefined,
|
||||
correctable: typeof item.correctable === 'boolean' ? item.correctable : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const orderDetailFromApi = (value: unknown): OrderDetail | null => {
|
||||
const row = orderFromApi(value)
|
||||
if (!row) return null
|
||||
const item = asRecord(value)
|
||||
return {
|
||||
...row,
|
||||
expiresAt: textValue(item.expiresAt) || undefined,
|
||||
statusText: textValue(item.statusText) || undefined,
|
||||
depositType: textValue(item.depositType) || undefined,
|
||||
accountInformation: textValue(item.accountInformation) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export const tradeRecordFromApi = (value: unknown): TradeRecordRow | null => {
|
||||
const item = asRecord(value)
|
||||
const issue = issueFrom(item.issue)
|
||||
if (!issue.code) return null
|
||||
const tradeDate = textValue(item.tradeDate)
|
||||
const valueDate = textValue(item.valueDate)
|
||||
const type = textValue(item.tradeRecordTypeCode, textValue(item.type, '取引'))
|
||||
return {
|
||||
id: textValue(item.id, [issue.code, tradeDate, valueDate, type].filter(Boolean).join(':')),
|
||||
code: issue.code,
|
||||
stock: issue.name || issue.code,
|
||||
market: issue.market,
|
||||
type,
|
||||
quantity: nullableNumberValue(item.quantity),
|
||||
price: nullableNumberValue(item.price),
|
||||
amount: nullableNumberValue(item.amount),
|
||||
tradeDate: tradeDate || undefined,
|
||||
valueDate: valueDate || undefined,
|
||||
accountType: textValue(item.accountType) || undefined,
|
||||
settlementCurrencyCode: textValue(item.settlementCurrencyCode) || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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')
|
||||
@@ -0,0 +1,25 @@
|
||||
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/:id', name: 'trade', component: RouteStub },
|
||||
{ path: '/history', name: 'history', component: RouteStub },
|
||||
{ path: '/api-keys', redirect: '/settings/api-keys' },
|
||||
{ path: '/settings', redirect: '/settings/api-keys' },
|
||||
{
|
||||
path: '/settings/:section(api-keys|passkeys)/:mode(new)?',
|
||||
name: 'settings',
|
||||
component: RouteStub,
|
||||
},
|
||||
{ path: '/oauth/authorize', name: 'oauthAuthorize', component: RouteStub },
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/portfolio' },
|
||||
],
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
overflow: hidden;
|
||||
background: #101418;
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
export const ui = {
|
||||
appShell:
|
||||
'grid h-dvh grid-rows-[minmax(0,1fr)_auto] overflow-hidden bg-[#101418] text-[#e3e3e9] lg:grid-cols-[7rem_minmax(0,1fr)] lg:grid-rows-none',
|
||||
sidebar:
|
||||
'order-2 flex h-20 min-h-0 items-center overflow-x-auto border-t border-[#2f3338] bg-[#191c20] px-2 py-2 lg:order-none lg:h-full lg:flex-col lg:overflow-visible lg:border-t-0 lg:border-r lg:px-0 lg:py-4',
|
||||
brandMark:
|
||||
'hidden h-14 w-14 place-items-center rounded-[18px] bg-[#a8c7fa] text-3xl font-black text-[#0b305f] shadow-lg shadow-black/20 lg:grid',
|
||||
navStack:
|
||||
'flex w-full min-w-max justify-around gap-2 lg:mt-10 lg:grid lg:min-w-0 lg:gap-4 lg:px-2',
|
||||
navButton:
|
||||
'grid min-h-14 min-w-16 place-items-center gap-1 rounded-[20px] bg-transparent p-1 text-xs font-semibold text-[#c3c7cf] transition hover:bg-[#22272e] lg:min-h-16 lg:min-w-0',
|
||||
navButtonActive: 'text-[#d3e3fd]',
|
||||
navIcon: 'grid h-8 w-12 place-items-center rounded-full text-[#c3c7cf] lg:w-16',
|
||||
navIconActive: 'bg-[#263141] text-[#d3e3fd]',
|
||||
workspace:
|
||||
'order-1 flex min-h-0 min-w-0 flex-col gap-5 overflow-y-auto px-4 pt-4 pb-5 sm:px-6 sm:pt-6 lg:order-none lg:gap-7 lg:px-8 lg:pt-8 lg:pb-8',
|
||||
topbar: 'flex shrink-0 flex-wrap items-center justify-between gap-4',
|
||||
tradeTopbar: 'hidden',
|
||||
|
||||
authPanel: 'grid justify-center pt-6 sm:pt-12',
|
||||
panel:
|
||||
'grid content-start gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-4 shadow-lg shadow-black/15 sm:p-6',
|
||||
loginPanel: 'w-full max-w-[28rem]',
|
||||
panelHead: 'flex flex-wrap items-center justify-between gap-3',
|
||||
eyebrow: 'mb-1 text-xs font-black uppercase text-[#9aa0a9]',
|
||||
dashboardGrid:
|
||||
'grid min-h-0 flex-1 grid-cols-1 items-stretch gap-5 lg:grid-cols-[minmax(0,1.25fr)_minmax(20rem,0.75fr)] lg:grid-rows-[auto_minmax(20rem,auto)_auto] lg:gap-6',
|
||||
metricPanel:
|
||||
'grid min-h-36 content-center gap-3 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:min-h-40 sm:p-7',
|
||||
assetOverviewPanel:
|
||||
'grid min-h-48 grid-cols-1 items-center gap-x-7 gap-y-5 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 pb-8 shadow-lg shadow-black/15 sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)] sm:p-7 sm:pb-10 lg:col-span-2',
|
||||
assetOverviewHead: 'grid gap-3',
|
||||
assetOverviewSubtext: 'text-sm font-semibold text-[#c3c7cf]',
|
||||
assetBreakdownPanel: 'grid gap-3',
|
||||
assetBreakdownTitle: 'text-xs font-extrabold text-[#9aa0a9]',
|
||||
metricLabel: 'text-xs font-extrabold text-[#9aa0a9]',
|
||||
metricValue: 'break-words text-2xl font-black sm:text-3xl',
|
||||
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-80 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7',
|
||||
holdingsBody: 'flex min-h-0 flex-1 flex-col overflow-visible lg:overflow-hidden',
|
||||
holdingsRows: 'grid min-h-0 flex-1 content-start overflow-y-auto',
|
||||
holdingsHead:
|
||||
'hidden grid-cols-[1.6fr_0.7fr_0.6fr_0.9fr_0.9fr_auto] items-center gap-4 border-b border-[#33383f] py-3 text-xs font-extrabold text-[#8f949d] md:grid',
|
||||
holdingRow:
|
||||
'grid min-h-16 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-2xl bg-[#111418] px-3 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] md:grid-cols-[1.6fr_0.7fr_0.6fr_0.9fr_0.9fr_auto] md:gap-4 md:bg-transparent',
|
||||
typePill: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs text-[#d3e3fd]',
|
||||
muted: 'text-[#8f949d]',
|
||||
portfolioHistory:
|
||||
'flex min-h-80 flex-col gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7',
|
||||
historyList: 'flex min-h-0 flex-1 flex-col overflow-visible lg:overflow-hidden',
|
||||
historyRows: 'grid min-h-0 flex-1 content-start gap-5 overflow-y-auto lg:gap-7',
|
||||
marketIndexPanel:
|
||||
'grid gap-4 rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-5 shadow-lg shadow-black/15 sm:p-7 lg:col-span-2',
|
||||
marketIndexGrid: 'grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4',
|
||||
marketIndexCard: 'grid min-h-28 content-center gap-2 rounded-2xl bg-[#111418] p-4',
|
||||
marketIndexValue: 'break-words text-xl font-black text-[#e3e3e9]',
|
||||
emptyState: 'flex flex-1 items-center justify-center py-8 text-center',
|
||||
miniOrder: 'grid grid-cols-[minmax(0,1fr)_auto] gap-3',
|
||||
tradeLayout:
|
||||
'trade-layout grid w-full min-w-0 grid-cols-1 items-stretch gap-5 lg:min-h-[calc(100dvh-6rem)] lg:grid-cols-5',
|
||||
watchlist:
|
||||
'order-3 grid min-w-0 content-start gap-2 overflow-visible border-t border-[#30343a] bg-[#101418] pt-4 shadow-none lg:col-span-1 lg:h-full lg:min-h-[calc(100dvh-6rem)] lg:overflow-y-auto lg:border-t-0 lg:border-l lg:pt-0 lg:pl-2',
|
||||
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 rounded-lg border-l-2 border-transparent px-4 py-3 text-left text-[#e3e3e9] transition hover:bg-[#242930] focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-[#d3e3fd]',
|
||||
watchRowActive: 'bg-gray-900',
|
||||
centerStack: 'order-1 col-span-3 flex min-h-0 h-full flex-col gap-5',
|
||||
stockMetadataPanel:
|
||||
'grid min-h-0 gap-4 rounded-[24px] border-0 bg-[#101418] p-0 shadow-none sm:p-6',
|
||||
stockBodyGrid: 'grid min-h-0 flex-1 gap-5 lg:grid-rows-3',
|
||||
tradeSeparator: 'order-3 hidden',
|
||||
stockPanel:
|
||||
'grid min-h-0 h-full grid-rows-[auto_auto_1fr] gap-4 rounded-[24px] border-0 bg-[#101418] p-0 shadow-none sm:p-6',
|
||||
stockPanelSpan: 'lg:row-span-2',
|
||||
stockTitle: 'flex flex-wrap 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-start gap-1 sm:justify-items-end',
|
||||
periodTabs: 'flex flex-wrap justify-start gap-2 sm:justify-end',
|
||||
periodButton: 'min-h-7 rounded-full bg-transparent px-3 text-xs font-bold text-[#8f949d]',
|
||||
periodButtonActive: 'bg-[#d3e3fd] text-[#102033]',
|
||||
chartActions: 'flex flex-wrap 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-72 min-h-0 overflow-hidden rounded-[20px] bg-[#111418] lg:h-full lg:min-h-[14rem]',
|
||||
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-0 shadow-none sm:p-6',
|
||||
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',
|
||||
infoTabBodyInner: 'relative min-h-[9.5rem]',
|
||||
detailGrid: 'grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-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-visible border-0 bg-[#101418] p-0 shadow-none sm:p-6 lg:overflow-hidden',
|
||||
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]',
|
||||
accountTypeGrid: 'grid grid-cols-2 gap-2',
|
||||
accountTypeButton:
|
||||
'relative isolate grid min-h-11 place-items-center overflow-hidden rounded-[14px] bg-[#111418] px-2 py-2 text-center text-xs font-extrabold leading-snug text-[#9aa0a9] transition hover:bg-[#1d232b] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#d3e3fd]',
|
||||
accountTypeButtonActive: 'text-[#d3e3fd]',
|
||||
accountTypeIndicator: 'absolute inset-0 -z-10 rounded-[14px] bg-[#263141]',
|
||||
accountTypeText: 'relative block whitespace-normal break-keep leading-snug',
|
||||
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 flex-wrap items-center justify-between gap-2 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-2 items-center gap-3 rounded-[20px] bg-[#111418] p-4 md:grid-cols-[minmax(180px,1fr)_auto_auto_auto_auto_minmax(12rem,auto)]',
|
||||
statusBadge: 'w-fit rounded-full bg-[#263141] px-3 py-1 text-xs font-black text-[#d3e3fd]',
|
||||
pendingBadge: 'bg-[#4a3720] text-[#ffddb3]',
|
||||
apiLayout: 'grid grid-cols-1 gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(20rem,0.9fr)] lg:gap-7',
|
||||
settingsLayout: 'grid gap-7',
|
||||
row: 'grid grid-cols-1 items-center gap-3 rounded-[20px] bg-[#111418] p-4 sm:grid-cols-[minmax(0,1fr)_auto]',
|
||||
rowActions: 'flex flex-wrap items-center justify-end gap-2',
|
||||
keyRow: 'grid gap-3 rounded-[24px] bg-[#111418] p-4',
|
||||
profileRow:
|
||||
'grid grid-cols-[auto_minmax(0,1fr)] items-center gap-3 rounded-[20px] bg-[#111418] p-4 sm:grid-cols-[auto_minmax(0,1fr)_auto]',
|
||||
searchOverlay: 'fixed inset-0 z-20 bg-[#101418]/75 backdrop-blur-sm',
|
||||
searchSheet:
|
||||
'mx-auto mt-2 grid max-h-[calc(100dvh-1rem)] w-[calc(100vw-1rem)] max-w-[48rem] gap-3 overflow-auto rounded-[28px] border border-[#30343a] bg-[#1b1f24]/85 p-3 shadow-2xl shadow-black/35 sm:mt-4 sm:max-h-[85vh] sm:w-[calc(100vw-2rem)] sm:p-4 md:w-[48rem]',
|
||||
searchInputRow: 'grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_auto]',
|
||||
filterRow: 'grid grid-cols-1 gap-3 sm:grid-cols-2',
|
||||
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 flex-wrap 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-l-4 border-[#d3e3fd] bg-[#242930]',
|
||||
searchResultFocus:
|
||||
'bg-[#2a323a] outline outline-2 outline-[#d3e3fd] outline-offset-[-2px] shadow-[0_0_0_1px_rgba(211,227,253,0.35)]',
|
||||
confirmList: 'grid gap-2',
|
||||
confirmRow: 'flex justify-between gap-3 border-b border-[#33383f] pb-2',
|
||||
dialogNote: 'leading-7 text-[#c3c7cf]',
|
||||
} as const
|
||||
@@ -0,0 +1,173 @@
|
||||
export type TradeSide = 'buy' | 'sell'
|
||||
export type OrderKind = 'standard' | 's'
|
||||
export type CashOrderAccountType = 'specific' | 'general' | 'growthInvestment' | 'nisa'
|
||||
export type CashOrderMarket =
|
||||
| 'auto'
|
||||
| 'XTKS'
|
||||
| 'XNGO'
|
||||
| 'XFKA'
|
||||
| 'XSAP'
|
||||
| 'XNAS'
|
||||
| 'XNYS'
|
||||
| 'ARCX'
|
||||
| '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 ChartRange = '1D' | '3D' | '3M' | '1Y' | 'ALL'
|
||||
|
||||
export type RpcMessage = {
|
||||
id: number
|
||||
method: string
|
||||
params?: unknown
|
||||
}
|
||||
|
||||
export type OrderPreview = {
|
||||
issue: {
|
||||
code: string
|
||||
market: string
|
||||
}
|
||||
side: string
|
||||
quantity: number
|
||||
price?: {
|
||||
value: number | null
|
||||
text: string
|
||||
currency?: string
|
||||
}
|
||||
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
|
||||
open?: number
|
||||
high?: number
|
||||
low?: number
|
||||
close?: number
|
||||
volume?: number | null
|
||||
}
|
||||
|
||||
export type ChartNotice = {
|
||||
title: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export type MarketIndex = {
|
||||
code?: string
|
||||
name: string
|
||||
value: number | null
|
||||
valueText: string
|
||||
change: number | null
|
||||
changeText: string
|
||||
changeRate: number | null
|
||||
changeRateText: string
|
||||
sign: 'positive' | 'negative' | 'zero'
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export type Stock = {
|
||||
code: string
|
||||
name: string
|
||||
symbol: string
|
||||
searchText?: string
|
||||
country: string
|
||||
market: string
|
||||
sector: string
|
||||
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
|
||||
code: string
|
||||
date: string
|
||||
stock: string
|
||||
market: string
|
||||
side: TradeSide
|
||||
kind: OrderKind
|
||||
quantity: number | null
|
||||
unexecutedQuantity?: number | null
|
||||
executedQuantity?: number | null
|
||||
price: number | null
|
||||
status: '注文中' | '約定済' | '取消済'
|
||||
orderNumber?: string
|
||||
orderSubNo?: string
|
||||
tradeId?: string
|
||||
accountType?: string
|
||||
cancelable?: boolean
|
||||
correctable?: boolean
|
||||
}
|
||||
|
||||
export type OrderDetail = OrderRow & {
|
||||
expiresAt?: string
|
||||
statusText?: string
|
||||
depositType?: string
|
||||
accountInformation?: string
|
||||
}
|
||||
|
||||
export type TradeRecordRow = {
|
||||
id: string
|
||||
code: string
|
||||
stock: string
|
||||
market: string
|
||||
type: string
|
||||
quantity: number | null
|
||||
price: number | null
|
||||
amount: number | null
|
||||
tradeDate?: string
|
||||
valueDate?: string
|
||||
accountType?: string
|
||||
settlementCurrencyCode?: string
|
||||
}
|
||||
|
||||
export type Position = {
|
||||
code: string
|
||||
name: string
|
||||
market: string
|
||||
quantity: number
|
||||
avgPrice: number
|
||||
currentPrice?: number | null
|
||||
marketValue: number
|
||||
profitLoss: number
|
||||
profitLossRate: number
|
||||
type?: string
|
||||
accountType?: CashOrderAccountType | string
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
export type CurrencyCode = 'JPY' | 'USD'
|
||||
|
||||
export const currencyCodeForMarket = (market?: string): CurrencyCode =>
|
||||
market === 'XNAS' || market === 'XNYS' || market === 'ARCX' ? 'USD' : 'JPY'
|
||||
|
||||
export const currency = (value: number, currencyCode: CurrencyCode = 'JPY') =>
|
||||
new Intl.NumberFormat('ja-JP', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
maximumFractionDigits: currencyCode === 'JPY' ? 0 : 3,
|
||||
}).format(value)
|
||||
|
||||
export const currencyForMarket = (value: number, market?: string) =>
|
||||
currency(value, currencyCodeForMarket(market))
|
||||
|
||||
export const number = (value: number) => new Intl.NumberFormat('ja-JP').format(value)
|
||||
|
||||
export const signedCurrency = (value: number, currencyCode: CurrencyCode = 'JPY') =>
|
||||
`${value >= 0 ? '+' : '-'}${currency(Math.abs(value), currencyCode)}`
|
||||
|
||||
export const signedCurrencyForMarket = (value: number, market?: string) =>
|
||||
signedCurrency(value, currencyCodeForMarket(market))
|
||||
|
||||
export const signedPercent = (value: number, fractionDigits = 2) =>
|
||||
`${value >= 0 ? '+' : ''}${value.toFixed(fractionDigits)}%`
|
||||
Reference in New Issue
Block a user