547 lines
20 KiB
Vue
547 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import { ChevronDown, Download, FileCheck2, Search, ShieldCheck } from 'lucide-vue-next'
|
|
import { AnimatePresence, LayoutGroup, motion } from 'motion-v'
|
|
import { computed, ref } from 'vue'
|
|
import Spinner from '../../components/ui/Spinner.vue'
|
|
import UiButton from '../../components/ui/UiButton.vue'
|
|
import UiField from '../../components/ui/UiField.vue'
|
|
import UiSegmented from '../../components/ui/UiSegmented.vue'
|
|
import {
|
|
cashOrderAccountTypeOptions,
|
|
cashOrderMarketOptions,
|
|
cashOrderMethodOptions,
|
|
cashOrderPriceConditionOptions,
|
|
cashOrderTermOptions,
|
|
cashOrderTriggerZoneOptions,
|
|
orderKindOptions,
|
|
sKabuOrderMarketOptions,
|
|
tradeSideOptions,
|
|
} from '../../constants/trade'
|
|
import { ui } from '../../styles/ui'
|
|
import type {
|
|
ChartMode,
|
|
CashOrderAccountType,
|
|
CashOrderMarket,
|
|
CashOrderMethod,
|
|
CashOrderPriceCondition,
|
|
CashOrderTerm,
|
|
CashOrderTriggerZone,
|
|
OrderKind,
|
|
Position,
|
|
RealtimePricePoint,
|
|
Stock,
|
|
TradeSide,
|
|
} from '../../types/trading'
|
|
import { currency } from '../../utils/format'
|
|
import RealtimePriceChart from './RealtimePriceChart.vue'
|
|
|
|
type StockInfoTab = 'detail' | 'holding'
|
|
|
|
defineProps<{
|
|
viewedStocks: Stock[]
|
|
selectedStock: Stock
|
|
selectedPosition?: Position
|
|
connected: boolean
|
|
orderQuantity: number
|
|
estimatedAmount: number
|
|
canRequestCashEstimate: boolean
|
|
canPlaceCashOrder: boolean
|
|
realtimePricePoints: RealtimePricePoint[]
|
|
pricePolling: boolean
|
|
boxPlotStyle: {
|
|
min: string
|
|
q1: string
|
|
median: string
|
|
q3: string
|
|
max: string
|
|
}
|
|
hasQuote: (stock: Stock) => boolean
|
|
}>()
|
|
|
|
const tradeSide = defineModel<TradeSide>('tradeSide', { required: true })
|
|
const orderKind = defineModel<OrderKind>('orderKind', { required: true })
|
|
const cashOrderAccountType = defineModel<CashOrderAccountType>('cashOrderAccountType', {
|
|
required: true,
|
|
})
|
|
const cashOrderMarket = defineModel<CashOrderMarket>('cashOrderMarket', {
|
|
required: true,
|
|
})
|
|
const cashOrderPriceCondition = defineModel<CashOrderPriceCondition>('cashOrderPriceCondition', {
|
|
required: true,
|
|
})
|
|
const cashOrderTerm = defineModel<CashOrderTerm>('cashOrderTerm', { required: true })
|
|
const cashOrderDateInput = defineModel<string>('cashOrderDateInput', { required: true })
|
|
const cashOrderMethod = defineModel<CashOrderMethod>('cashOrderMethod', { required: true })
|
|
const cashOrderTriggerZone = defineModel<CashOrderTriggerZone>('cashOrderTriggerZone', {
|
|
required: true,
|
|
})
|
|
const cashOrderTriggerPriceInput = defineModel<string>('cashOrderTriggerPriceInput', {
|
|
required: true,
|
|
})
|
|
const cashOrderSecondaryPriceCondition = defineModel<CashOrderPriceCondition>(
|
|
'cashOrderSecondaryPriceCondition',
|
|
{
|
|
required: true,
|
|
},
|
|
)
|
|
const cashOrderSecondaryPriceInput = defineModel<string>('cashOrderSecondaryPriceInput', {
|
|
required: true,
|
|
})
|
|
const quantityInput = defineModel<string>('quantityInput', { required: true })
|
|
const priceInput = defineModel<string>('priceInput', { required: true })
|
|
const chartMode = defineModel<ChartMode>('chartMode', { required: true })
|
|
const activeStockInfoTab = ref<StockInfoTab>('detail')
|
|
const advancedOptionsOpen = ref(false)
|
|
const orderMarketOptions = computed(() =>
|
|
orderKind.value === 's' ? sKabuOrderMarketOptions : cashOrderMarketOptions,
|
|
)
|
|
const primaryPriceConditionRequiresPrice = computed(() =>
|
|
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
|
|
cashOrderPriceCondition.value,
|
|
),
|
|
)
|
|
const secondaryPriceConditionRequiresPrice = computed(() =>
|
|
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
|
|
cashOrderSecondaryPriceCondition.value,
|
|
),
|
|
)
|
|
const tabTransition = { type: 'spring', damping: 28, stiffness: 360 } as const
|
|
const tabContentTransition = { duration: 0.18, ease: 'easeOut' } as const
|
|
const advancedTransition = { duration: 0.18, ease: 'easeOut' } as const
|
|
|
|
const emit = defineEmits<{
|
|
openSearch: []
|
|
selectStock: [stock: Stock]
|
|
downloadCsv: []
|
|
estimate: []
|
|
confirmOrder: []
|
|
}>()
|
|
</script>
|
|
|
|
<template>
|
|
<section :class="ui.tradeLayout">
|
|
<article :class="ui.watchlist">
|
|
<button :class="ui.watchSearch" type="button" @click="emit('openSearch')">
|
|
<Search class="h-4 w-4" aria-hidden="true" />
|
|
Search
|
|
</button>
|
|
<button
|
|
v-for="stock in viewedStocks"
|
|
:key="stock.code"
|
|
:class="[ui.watchRow, selectedStock.code === stock.code && ui.watchRowActive]"
|
|
type="button"
|
|
:aria-current="selectedStock.code === stock.code ? 'true' : undefined"
|
|
@click="emit('selectStock', stock)"
|
|
>
|
|
<span class="grid gap-1">
|
|
<strong>{{ stock.name }}</strong>
|
|
<small>{{ stock.symbol }}</small>
|
|
</span>
|
|
<span class="grid justify-items-end gap-1">
|
|
<strong>
|
|
<template v-if="hasQuote(stock)">{{ currency(stock.price) }}</template>
|
|
<Spinner v-else size="sm" />
|
|
</strong>
|
|
<small v-if="hasQuote(stock)" :class="stock.change >= 0 ? ui.positive : ui.negative">
|
|
{{ stock.change >= 0 ? '+' : '' }}{{ stock.change }}%
|
|
</small>
|
|
<Spinner v-else size="sm" />
|
|
</span>
|
|
</button>
|
|
<p v-if="!viewedStocks.length" :class="[ui.muted, 'p-5 text-sm']">
|
|
SBIに接続するか検索すると銘柄を表示します
|
|
</p>
|
|
</article>
|
|
|
|
<div :class="ui.centerStack">
|
|
<article :class="ui.stockPanel">
|
|
<div :class="ui.stockTitle">
|
|
<div>
|
|
<p :class="ui.eyebrow">{{ selectedStock.symbol }}</p>
|
|
<h2>{{ selectedStock.name }}</h2>
|
|
</div>
|
|
<div :class="ui.quoteBox">
|
|
<strong>
|
|
<template v-if="hasQuote(selectedStock)">{{
|
|
currency(selectedStock.price)
|
|
}}</template>
|
|
<Spinner v-else size="sm" />
|
|
</strong>
|
|
<small
|
|
v-if="hasQuote(selectedStock)"
|
|
:class="selectedStock.change >= 0 ? ui.positive : ui.negative"
|
|
>
|
|
{{ selectedStock.change >= 0 ? '+' : '' }}{{ selectedStock.change }}%
|
|
</small>
|
|
<Spinner v-else size="sm" />
|
|
</div>
|
|
</div>
|
|
|
|
<div :class="ui.periodTabs">
|
|
<button :class="[ui.periodButton, ui.periodButtonActive]" type="button">1D</button>
|
|
<button :class="ui.periodButton" type="button">1W</button>
|
|
<button :class="ui.periodButton" type="button">1M</button>
|
|
</div>
|
|
|
|
<div :class="ui.chartActions">
|
|
<div :class="ui.smallTabs">
|
|
<button
|
|
:class="[ui.smallTab, chartMode === 'line' && ui.smallTabActive]"
|
|
type="button"
|
|
@click="chartMode = 'line'"
|
|
>
|
|
推移
|
|
</button>
|
|
<button
|
|
:class="[ui.smallTab, chartMode === 'box' && ui.smallTabActive]"
|
|
type="button"
|
|
@click="chartMode = 'box'"
|
|
>
|
|
箱ひげ
|
|
</button>
|
|
</div>
|
|
<button :class="ui.ghostButton" type="button" @click="emit('downloadCsv')">
|
|
<Download class="h-4 w-4" aria-hidden="true" />
|
|
CSV
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="chartMode === 'line'" :class="ui.chartBox">
|
|
<RealtimePriceChart
|
|
v-if="hasQuote(selectedStock)"
|
|
:points="realtimePricePoints"
|
|
:stock-name="selectedStock.name"
|
|
:active="pricePolling"
|
|
/>
|
|
<span v-else class="grid h-full place-items-center text-[#8f949d]">
|
|
<Spinner />
|
|
</span>
|
|
</div>
|
|
<div v-else :class="ui.boxplot">
|
|
<div :class="ui.boxplotScale">
|
|
<span>
|
|
<template v-if="hasQuote(selectedStock)">{{
|
|
currency(selectedStock.box.min)
|
|
}}</template>
|
|
<Spinner v-else size="sm" />
|
|
</span>
|
|
<span>
|
|
<template v-if="hasQuote(selectedStock)">{{
|
|
currency(selectedStock.box.max)
|
|
}}</template>
|
|
<Spinner v-else size="sm" />
|
|
</span>
|
|
</div>
|
|
<div v-if="hasQuote(selectedStock)" :class="ui.boxplotTrack">
|
|
<i
|
|
:class="ui.whisker"
|
|
:style="{
|
|
left: boxPlotStyle.min,
|
|
width: `calc(${boxPlotStyle.max} - ${boxPlotStyle.min})`,
|
|
}"
|
|
></i>
|
|
<i
|
|
:class="ui.box"
|
|
:style="{
|
|
left: boxPlotStyle.q1,
|
|
width: `calc(${boxPlotStyle.q3} - ${boxPlotStyle.q1})`,
|
|
}"
|
|
></i>
|
|
<i :class="ui.median" :style="{ left: boxPlotStyle.median }"></i>
|
|
</div>
|
|
<div v-else class="grid min-h-20 place-items-center text-[#8f949d]">
|
|
<Spinner />
|
|
</div>
|
|
</div>
|
|
</article>
|
|
|
|
<LayoutGroup id="stock-info-tabs">
|
|
<article :class="ui.infoTabPanel">
|
|
<div :class="ui.infoTabList" role="tablist" aria-label="銘柄別情報">
|
|
<button
|
|
id="stock-detail-tab"
|
|
:class="[ui.infoTabButton, activeStockInfoTab === 'detail' && ui.infoTabButtonActive]"
|
|
type="button"
|
|
role="tab"
|
|
:aria-selected="activeStockInfoTab === 'detail'"
|
|
aria-controls="stock-detail-panel"
|
|
@click="activeStockInfoTab = 'detail'"
|
|
>
|
|
詳細
|
|
<motion.span
|
|
v-if="activeStockInfoTab === 'detail'"
|
|
layout-id="stock-info-tab-indicator"
|
|
:class="ui.infoTabIndicator"
|
|
:transition="tabTransition"
|
|
/>
|
|
</button>
|
|
<button
|
|
id="stock-holding-tab"
|
|
:class="[
|
|
ui.infoTabButton,
|
|
activeStockInfoTab === 'holding' && ui.infoTabButtonActive,
|
|
]"
|
|
type="button"
|
|
role="tab"
|
|
:aria-selected="activeStockInfoTab === 'holding'"
|
|
aria-controls="stock-holding-panel"
|
|
@click="activeStockInfoTab = 'holding'"
|
|
>
|
|
保有
|
|
<motion.span
|
|
v-if="activeStockInfoTab === 'holding'"
|
|
layout-id="stock-info-tab-indicator"
|
|
:class="ui.infoTabIndicator"
|
|
:transition="tabTransition"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
<div :class="ui.infoTabBody">
|
|
<AnimatePresence mode="wait" :initial="false">
|
|
<motion.dl
|
|
v-if="activeStockInfoTab === 'detail'"
|
|
id="stock-detail-panel"
|
|
key="detail"
|
|
:class="ui.detailGrid"
|
|
role="tabpanel"
|
|
aria-labelledby="stock-detail-tab"
|
|
:initial="{ opacity: 0, y: 8 }"
|
|
:animate="{ opacity: 1, y: 0 }"
|
|
:exit="{ opacity: 0, y: -8 }"
|
|
:transition="tabContentTransition"
|
|
>
|
|
<div :class="ui.detailItem">
|
|
<dt class="text-xs text-[#9aa0a9]">国</dt>
|
|
<dd class="font-bold">{{ selectedStock.country }}</dd>
|
|
</div>
|
|
<div :class="ui.detailItem">
|
|
<dt class="text-xs text-[#9aa0a9]">市場</dt>
|
|
<dd class="font-bold">{{ selectedStock.market }}</dd>
|
|
</div>
|
|
<div :class="ui.detailItem">
|
|
<dt class="text-xs text-[#9aa0a9]">業種</dt>
|
|
<dd class="font-bold">{{ selectedStock.sector }}</dd>
|
|
</div>
|
|
<div :class="ui.detailItem">
|
|
<dt class="text-xs text-[#9aa0a9]">始値</dt>
|
|
<dd class="font-bold">
|
|
<template v-if="hasQuote(selectedStock)">{{
|
|
currency(selectedStock.open)
|
|
}}</template>
|
|
<Spinner v-else size="sm" />
|
|
</dd>
|
|
</div>
|
|
<div :class="ui.detailItem">
|
|
<dt class="text-xs text-[#9aa0a9]">高値</dt>
|
|
<dd class="font-bold">
|
|
<template v-if="hasQuote(selectedStock)">{{
|
|
currency(selectedStock.high)
|
|
}}</template>
|
|
<Spinner v-else size="sm" />
|
|
</dd>
|
|
</div>
|
|
<div :class="ui.detailItem">
|
|
<dt class="text-xs text-[#9aa0a9]">安値</dt>
|
|
<dd class="font-bold">
|
|
<template v-if="hasQuote(selectedStock)">{{
|
|
currency(selectedStock.low)
|
|
}}</template>
|
|
<Spinner v-else size="sm" />
|
|
</dd>
|
|
</div>
|
|
</motion.dl>
|
|
|
|
<motion.div
|
|
v-else
|
|
id="stock-holding-panel"
|
|
key="holding"
|
|
role="tabpanel"
|
|
aria-labelledby="stock-holding-tab"
|
|
:initial="{ opacity: 0, y: 8 }"
|
|
:animate="{ opacity: 1, y: 0 }"
|
|
:exit="{ opacity: 0, y: -8 }"
|
|
:transition="tabContentTransition"
|
|
>
|
|
<div v-if="selectedPosition" :class="ui.holdingNote">
|
|
保有 {{ selectedPosition.quantity }}株 / 平均
|
|
{{ currency(selectedPosition.avgPrice) }}
|
|
</div>
|
|
<p v-else :class="ui.holdingEmpty">保有なし</p>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
</div>
|
|
</article>
|
|
</LayoutGroup>
|
|
</div>
|
|
|
|
<div :class="ui.tradeSeparator" aria-hidden="true"></div>
|
|
|
|
<article :class="ui.ticketPanel">
|
|
<div :class="ui.ticketTop">
|
|
<UiSegmented v-model="tradeSide" :options="tradeSideOptions" />
|
|
</div>
|
|
<div :class="ui.ticketScroll">
|
|
<div :class="ui.ticketScrollInner">
|
|
<UiSegmented v-model="orderKind" :options="orderKindOptions" />
|
|
<div :class="ui.ticketBox">
|
|
<UiField
|
|
v-model="quantityInput"
|
|
label="発注株数"
|
|
type="number"
|
|
min="1"
|
|
placeholder="株数を入力"
|
|
/>
|
|
<details
|
|
:class="ui.advancedOptions"
|
|
@toggle="advancedOptionsOpen = ($event.target as HTMLDetailsElement).open"
|
|
>
|
|
<summary :class="ui.advancedSummary">
|
|
<span>Advanced Options</span>
|
|
<motion.span
|
|
:class="ui.advancedSummaryIconWrap"
|
|
:animate="{ rotate: advancedOptionsOpen ? 180 : 0 }"
|
|
:transition="advancedTransition"
|
|
>
|
|
<ChevronDown :class="ui.advancedSummaryIcon" aria-hidden="true" />
|
|
</motion.span>
|
|
</summary>
|
|
<motion.div
|
|
:class="ui.advancedBody"
|
|
:initial="{ opacity: 0, y: -4 }"
|
|
:animate="{ opacity: 1, y: 0 }"
|
|
:transition="advancedTransition"
|
|
>
|
|
<span :class="ui.advancedLabel">預り区分</span>
|
|
<UiSegmented
|
|
v-model="cashOrderAccountType"
|
|
:options="cashOrderAccountTypeOptions"
|
|
/>
|
|
<UiField
|
|
v-model="cashOrderMarket"
|
|
as="select"
|
|
label="注文市場"
|
|
:disabled="orderKind === 's'"
|
|
>
|
|
<option
|
|
v-for="option in orderMarketOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</UiField>
|
|
<template v-if="orderKind !== 's'">
|
|
<UiField v-model="cashOrderPriceCondition" as="select" label="執行条件">
|
|
<option
|
|
v-for="option in cashOrderPriceConditionOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</UiField>
|
|
<UiField
|
|
v-if="primaryPriceConditionRequiresPrice"
|
|
v-model="priceInput"
|
|
label="注文価格"
|
|
type="number"
|
|
min="1"
|
|
placeholder="価格を入力"
|
|
/>
|
|
<UiField v-model="cashOrderTerm" as="select" label="有効期限">
|
|
<option
|
|
v-for="option in cashOrderTermOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</UiField>
|
|
<UiField
|
|
v-if="cashOrderTerm === 'date'"
|
|
v-model="cashOrderDateInput"
|
|
label="指定日"
|
|
type="date"
|
|
/>
|
|
<UiField v-model="cashOrderMethod" as="select" label="特殊注文">
|
|
<option
|
|
v-for="option in cashOrderMethodOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</UiField>
|
|
<template v-if="cashOrderMethod !== 'normal'">
|
|
<UiField v-model="cashOrderTriggerZone" as="select" label="逆指値条件">
|
|
<option
|
|
v-for="option in cashOrderTriggerZoneOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</UiField>
|
|
<UiField
|
|
v-model="cashOrderTriggerPriceInput"
|
|
label="逆指値価格"
|
|
type="number"
|
|
min="1"
|
|
placeholder="価格を入力"
|
|
/>
|
|
</template>
|
|
<template v-if="cashOrderMethod === 'oco'">
|
|
<UiField
|
|
v-model="cashOrderSecondaryPriceCondition"
|
|
as="select"
|
|
label="OCO執行条件"
|
|
>
|
|
<option
|
|
v-for="option in cashOrderPriceConditionOptions"
|
|
:key="option.value"
|
|
:value="option.value"
|
|
>
|
|
{{ option.label }}
|
|
</option>
|
|
</UiField>
|
|
<UiField
|
|
v-if="secondaryPriceConditionRequiresPrice"
|
|
v-model="cashOrderSecondaryPriceInput"
|
|
label="OCO価格"
|
|
type="number"
|
|
min="1"
|
|
placeholder="価格を入力"
|
|
/>
|
|
</template>
|
|
</template>
|
|
</motion.div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div :class="ui.ticketBottom">
|
|
<div :class="ui.estimateSummary">
|
|
<span>概算金額</span>
|
|
<strong>{{ currency(estimatedAmount) }}</strong>
|
|
</div>
|
|
<div :class="ui.actions">
|
|
<UiButton type="button" :disabled="!canRequestCashEstimate" @click="emit('estimate')">
|
|
<FileCheck2 class="h-4 w-4" aria-hidden="true" />
|
|
見積
|
|
</UiButton>
|
|
<UiButton
|
|
variant="danger"
|
|
type="button"
|
|
:disabled="!canPlaceCashOrder"
|
|
@click="emit('confirmOrder')"
|
|
>
|
|
<ShieldCheck class="h-4 w-4" aria-hidden="true" />
|
|
注文確認
|
|
</UiButton>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</section>
|
|
</template>
|