This commit is contained in:
Shotaro Nakamura
2026-06-19 14:43:06 +09:00
parent 20434dcd11
commit c1e3d9d9a0
27 changed files with 4414 additions and 595 deletions
+243 -6
View File
@@ -99,8 +99,22 @@ const confirmationIdFromPreview = (value: unknown) => {
return typeof confirmationId === 'string' && confirmationId ? confirmationId : undefined
}
const accountTypeSchema = z.enum(['general', 'specific', 'nisa', 'juniorNisa', 'unknown'])
const depositTypeSchema = z.enum(['general', 'specific', 'nisa', 'juniorNisa', 'unknown'])
const accountTypeSchema = z.enum([
'general',
'specific',
'growthInvestment',
'nisa',
'juniorNisa',
'unknown',
])
const depositTypeSchema = z.enum([
'general',
'specific',
'growthInvestment',
'nisa',
'juniorNisa',
'unknown',
])
const tradeSideSchema = z.enum(['buy', 'sell'])
const marketCodeSchema = z.string().min(1).describe('SBI market code')
const issueCodeSchema = z.string().min(1).describe('Issue code')
@@ -202,6 +216,9 @@ const cashOrderPriceConditionSchema = z.enum([
])
const cashOrderSchema = stockOrderBaseSchema.extend({
preOrderMarket: marketCodeSchema
.optional()
.describe('APK pre-order market for S-kabu; live S-kabu orders still send STK'),
price: z.number().positive().optional().describe('Order price for price-based orders'),
kind: z.enum(['market', 'limit', 'stop', 'oco', 'ifd', 'ifdo', 's', 'unknown']).optional(),
priceCondition: cashOrderPriceConditionSchema
@@ -219,21 +236,92 @@ const cashOrderSchema = stockOrderBaseSchema.extend({
.optional()
.describe('Secondary execution condition for OCO orders'),
secondaryPrice: z.number().positive().optional().describe('Secondary price for OCO orders'),
ippanMarginPaymentLimit: z
.string()
.optional()
.describe('APK ippan margin payment-limit code from board/pre-order information'),
sorLastMarket: marketCodeSchema
.optional()
.describe('Previous market code sent with SOR orders; defaults to login profile'),
})
const cashOrderPreOrderSchema = z.object({
issueCode: issueCodeSchema,
market: marketCodeSchema,
side: tradeSideSchema,
accountType: accountTypeSchema.optional(),
depositType: depositTypeSchema.optional(),
kind: z.enum(['s']).optional().describe('Requests APK S-kabu pre-order constraints'),
preOrderMarket: marketCodeSchema.optional().describe('APK pre-order market for S-kabu checks'),
})
const stockOrderMarginPositionSchema = z.object({
openTradeDate: z
.string()
.describe('Open trade date from the margin position record, in yyyyMMdd or yyyy-MM-dd format'),
openPrice: z
.union([z.number().positive(), z.string().min(1)])
.describe('Open price from the margin position record'),
quantity: z
.union([z.number().positive(), z.string().min(1)])
.describe('Selected quantity from the margin position record'),
orgNewTradeDate: z.string().describe('Original new-trade date from the margin position record'),
bargainMarketCode: marketCodeSchema.describe(
'Bargain market code from the margin position record',
),
})
const placeCashOrderSchema = cashOrderSchema.extend({
confirmationId: z
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
omitConfirmation: z
.boolean()
.optional()
.describe('APK confirmation-screen omission flag for live submit calls'),
allowTrading: z.literal(true).optional().describe('Explicitly allows sending a live order'),
})
const orderCorrectionSchema = z.object({
orderNumber: z
.string()
.min(1)
.optional()
.describe('Order number shown in order inquiry; required by the mobile pre-correction route'),
orderId: orderIdSchema,
issueCode: issueCodeSchema.optional().describe('Issue code from the pre-correction response'),
market: marketCodeSchema.optional().describe('Market code from the pre-correction response'),
tradeId: z.string().min(1).optional().describe('Original trade id code'),
correctionType: z.string().min(1).optional().describe('Additional correction flag'),
status: z.string().min(1).optional().describe('Original order status code'),
rbeOrderStatus: z.string().min(1).optional().describe('Original RBE order status code'),
depositTypeText: z.string().min(1).optional().describe('Display deposit type text'),
orderMethod: z.enum(['normal', 'stop', 'oco']).optional().describe('Primary order method'),
priceCondition: cashOrderPriceConditionSchema
.optional()
.describe('Corrected primary execution condition'),
triggerZone: z.enum(['above', 'below']).optional().describe('Stop trigger direction'),
triggerPrice: z.number().positive().optional().describe('Stop trigger price'),
secondaryPriceCondition: cashOrderPriceConditionSchema
.optional()
.describe('Secondary/OCO execution condition'),
secondaryPrice: z.number().positive().optional().describe('Secondary/OCO price'),
ifdPriceCondition: cashOrderPriceConditionSchema
.optional()
.describe('IFD follow-up execution condition for IF/IFDOCO correction'),
ifdPrice: z.number().positive().optional().describe('IFD follow-up price'),
ifdOrderMethod: z
.enum(['normal', 'stop', 'oco'])
.optional()
.describe('IFD follow-up special order method'),
ifdTriggerZone: z.enum(['above', 'below']).optional().describe('IFD stop trigger direction'),
ifdTriggerPrice: z.number().positive().optional().describe('IFD stop trigger price'),
ifdSecondaryPriceCondition: cashOrderPriceConditionSchema
.optional()
.describe('IFD secondary/OCO execution condition'),
ifdSecondaryPrice: z.number().positive().optional().describe('IFD secondary/OCO price'),
correctionControlFlag: z.enum(['1', '2']).optional().describe('Mobile correction control flag'),
quantity: z.number().positive().optional().describe('Corrected order quantity'),
price: z.number().positive().optional().describe('Corrected order price'),
})
@@ -260,13 +348,40 @@ const placeOrderCancelSchema = orderCancelSchema.extend({
.describe('Explicitly allows sending a live cancellation request'),
})
const marginOpenOrderSchema = cashOrderSchema
const marginOpenTradeTypeSchema = z.enum([
'standard',
'generalBuy',
'generalSellShort',
'generalSellInventoryLimited',
'generalSellInventoryUnlimited',
'day',
'hyper',
])
const marginOpenOrderSchema = cashOrderSchema.extend({
kind: z.enum(['market', 'limit', 'stop', 'oco', 'ifd', 'ifdo', 'unknown']).optional(),
marginTradeType: marginOpenTradeTypeSchema.describe('APK margin-open trade type'),
ippanMarginPaymentLimit: z
.string()
.optional()
.describe('APK ippan margin payment-limit code from board/pre-order information'),
})
const marginOpenOrderPreOrderSchema = stockOrderBaseSchema.omit({ quantity: true }).extend({
marginTradeType: marginOpenTradeTypeSchema
.optional()
.describe('APK margin-open trade type used by the mobile pre-order request'),
})
const placeMarginOpenOrderSchema = marginOpenOrderSchema.extend({
confirmationId: z
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
omitConfirmation: z
.boolean()
.optional()
.describe('APK confirmation-screen omission flag for live submit calls'),
allowTrading: z
.literal(true)
.optional()
@@ -275,9 +390,31 @@ const placeMarginOpenOrderSchema = marginOpenOrderSchema.extend({
const marginCloseOrderSchema = cashOrderSchema.extend({
positionId: positionIdSchema.optional().describe('Position ID to close'),
marginCloseTradeType: z
.enum(['sixMonth', 'noLimit', 'oneDay', 'fifteenDay'])
.describe('APK margin-close trade type'),
marginPositions: z
.array(stockOrderMarginPositionSchema)
.optional()
.describe('Margin position records selected for specified close orders'),
marginClosePositionOrder: z
.enum(['profitFirst', 'lossFirst', 'newestFirst', 'oldestFirst', 'specify'])
.optional()
.describe('APK close-position ordering used by summary close orders'),
})
const marginCloseOrderPreOrderSchema = stockOrderBaseSchema.omit({ quantity: true }).extend({
marginCloseTradeType: z
.enum(['sixMonth', 'noLimit', 'oneDay', 'fifteenDay'])
.optional()
.describe('APK margin-close trade type used by the mobile pre-order request'),
})
const placeMarginCloseOrderSchema = marginCloseOrderSchema.extend({
omitConfirmation: z
.boolean()
.optional()
.describe('APK confirmation-screen omission flag for live submit calls'),
allowTrading: z
.literal(true)
.optional()
@@ -293,6 +430,22 @@ const actualDeliveryOrderSchema = z.object({
price: z.number().positive().optional().describe('Order price for price-based requests'),
kind: z.enum(['genbiki', 'genwatashi']),
positionId: positionIdSchema.optional().describe('Position ID to deliver'),
marginPositions: z
.array(stockOrderMarginPositionSchema)
.optional()
.describe('Margin position records selected for genbiki/genwatashi delivery'),
ippanMarginPaymentLimit: z
.string()
.optional()
.describe('APK ippan margin payment-limit code from board/pre-order information'),
})
const actualDeliveryOrderPreOrderSchema = actualDeliveryOrderSchema.omit({
quantity: true,
price: true,
positionId: true,
marginPositions: true,
ippanMarginPaymentLimit: true,
})
const placeActualDeliveryOrderSchema = actualDeliveryOrderSchema.extend({
@@ -300,6 +453,10 @@ const placeActualDeliveryOrderSchema = actualDeliveryOrderSchema.extend({
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
omitConfirmation: z
.boolean()
.optional()
.describe('APK confirmation-screen omission flag for live submit calls'),
allowTrading: z
.literal(true)
.optional()
@@ -311,6 +468,39 @@ const ifdOrderSchema = cashOrderSchema.extend({
.enum(['cash', 'marginOpen'])
.optional()
.describe('Product to use for the first IFD leg'),
marginTradeType: marginOpenTradeTypeSchema
.optional()
.describe('APK margin-open trade type for the first leg when tradeType is marginOpen'),
ippanMarginPaymentLimit: z
.string()
.optional()
.describe('APK ippan margin payment-limit code for the first IFD leg'),
ifdPriceCondition: cashOrderPriceConditionSchema
.optional()
.describe('Execution condition for the IFD follow-up leg'),
ifdPrice: z.number().positive().optional().describe('Order price for the IFD follow-up leg'),
ifdOrderTerm: z.enum(['day', 'week', 'date']).optional().describe('IFD follow-up validity term'),
ifdOrderDate: z
.string()
.optional()
.describe('IFD follow-up validity date in yyyyMMdd or yyyy-MM-dd format'),
ifdOrderMethod: z
.enum(['normal', 'stop', 'oco'])
.optional()
.describe('Special order method for the IFD follow-up leg'),
ifdTriggerZone: z
.enum(['above', 'below'])
.optional()
.describe('Stop trigger direction for the IFD follow-up leg'),
ifdTriggerPrice: z
.number()
.positive()
.optional()
.describe('Stop trigger price for the IFD follow-up leg'),
ifdSecondaryPriceCondition: cashOrderPriceConditionSchema
.optional()
.describe('Secondary OCO execution condition for IFDOCO'),
ifdSecondaryPrice: z.number().positive().optional().describe('Secondary OCO price for IFDOCO'),
})
const placeIfdOrderSchema = ifdOrderSchema.extend({
@@ -318,15 +508,58 @@ const placeIfdOrderSchema = ifdOrderSchema.extend({
.string()
.optional()
.describe('Confirmation ID returned by the confirmation step'),
omitConfirmation: z
.boolean()
.optional()
.describe('APK confirmation-screen omission flag for live submit calls'),
allowTrading: z.literal(true).optional().describe('Explicitly allows sending a live IFD order'),
})
const themeInvestmentOrderSchema = z.object({
themeId: z.string().min(1).describe('Theme ID for the theme investment order'),
themeSetYyyymm: z
.string()
.length(6)
.describe('Theme set year/month (`theme_set_yyyymm`) from the mobile APK handoff'),
themeCourse: z
.union([z.number().int().nonnegative(), z.string().min(1).max(2)])
.describe('Theme course (`theme_course`) from the mobile APK handoff'),
side: tradeSideSchema,
accountType: accountTypeSchema.optional().describe('Account/deposit type used for the order'),
depositType: accountTypeSchema.optional().describe('Deposit type used for the order'),
components: z
.array(
z.object({
issueCode: issueCodeSchema,
quantity: z.union([z.number().positive(), z.string().min(1)]),
}),
)
.min(1)
.max(10)
.describe('Component stock orders selected by the mobile theme investment flow'),
amount: z.number().positive().optional().describe('Order amount for the theme investment order'),
})
const themeInvestmentPreOrderSchema = z.object({
themeId: z.string().min(1).describe('Theme ID from the mobile theme investment handoff'),
themeName: z
.string()
.min(1)
.optional()
.describe('Theme name from the mobile theme investment handoff'),
exchangeCode: marketCodeSchema.describe('Exchange code used by the mobile pre-order call'),
components: z
.array(
z.object({
issueCode: issueCodeSchema,
quantity: z.union([z.number().positive(), z.string().min(1)]).optional(),
}),
)
.min(1)
.max(10)
.describe('Component stocks selected by the mobile theme investment flow'),
})
const placeThemeInvestmentOrderSchema = themeInvestmentOrderSchema.extend({
allowTrading: z
.literal(true)
@@ -366,6 +599,7 @@ const methodParamSchemas = {
'watchlist.list': undefined,
'orders.inquiry.executionsToday': orderInquiryOptionsSchema.optional(),
'orders.inquiry.open': orderInquiryOptionsSchema.optional(),
'orders.cash.preOrder': cashOrderPreOrderSchema,
'orders.cash.estimate': cashOrderSchema,
'orders.cash.place': placeCashOrderSchema,
'orders.cash.estimateCorrection': orderCorrectionSchema,
@@ -373,23 +607,26 @@ const methodParamSchemas = {
'orders.cash.placeCorrection': placeOrderCorrectionSchema,
'orders.cash.estimateCancel': orderCancelSchema,
'orders.cash.placeCancel': placeOrderCancelSchema,
'orders.margin.preOrderOpen': marginOpenOrderPreOrderSchema,
'orders.margin.estimateOpen': marginOpenOrderSchema,
'orders.margin.open': placeMarginOpenOrderSchema,
'orders.margin.preOrderClose': marginCloseOrderPreOrderSchema,
'orders.margin.estimateClose': marginCloseOrderSchema,
'orders.margin.close': placeMarginCloseOrderSchema,
'orders.margin.estimateCloseSummary': marginCloseOrderSchema,
'orders.margin.closeSummary': placeMarginCloseOrderSchema,
'orders.margin.estimateSummary': marginCloseOrderSchema,
'orders.margin.placeSummary': placeMarginCloseOrderSchema,
'orders.margin.preOrderActualDelivery': actualDeliveryOrderPreOrderSchema,
'orders.margin.estimateActualDelivery': actualDeliveryOrderSchema,
'orders.margin.actualDelivery': placeActualDeliveryOrderSchema,
'orders.ifd.estimate': ifdOrderSchema,
'orders.ifd.place': placeIfdOrderSchema,
'orders.ifd.estimateCorrection': orderCorrectionSchema,
'orders.ifd.placeCorrection': placeOrderCorrectionSchema,
'orders.ifd.estimateCancel': orderCorrectionSchema,
'orders.ifd.placeCancel': placeOrderCorrectionSchema,
'orders.themeInvestment.list': undefined,
'orders.ifd.estimateCancel': orderCancelSchema,
'orders.ifd.placeCancel': placeOrderCancelSchema,
'orders.themeInvestment.list': themeInvestmentPreOrderSchema,
'orders.themeInvestment.estimate': themeInvestmentOrderSchema,
'orders.themeInvestment.place': placeThemeInvestmentOrderSchema,
} satisfies Record<RpcMethod, z.ZodType | undefined>
+4
View File
@@ -32,6 +32,7 @@ export const RPC_METHODS = [
'watchlist.list',
'orders.inquiry.executionsToday',
'orders.inquiry.open',
'orders.cash.preOrder',
'orders.cash.estimate',
'orders.cash.place',
'orders.cash.estimateCorrection',
@@ -39,14 +40,17 @@ export const RPC_METHODS = [
'orders.cash.placeCorrection',
'orders.cash.estimateCancel',
'orders.cash.placeCancel',
'orders.margin.preOrderOpen',
'orders.margin.estimateOpen',
'orders.margin.open',
'orders.margin.preOrderClose',
'orders.margin.estimateClose',
'orders.margin.close',
'orders.margin.estimateCloseSummary',
'orders.margin.closeSummary',
'orders.margin.estimateSummary',
'orders.margin.placeSummary',
'orders.margin.preOrderActualDelivery',
'orders.margin.estimateActualDelivery',
'orders.margin.actualDelivery',
'orders.ifd.estimate',
+2
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@simplewebauthn/browser": "^13.1.2",
"d3": "^7.9.0",
"lucide-vue-next": "^1.0.0",
"motion-v": "^2.3.0",
"tailwindcss": "^4.1.10",
@@ -19,6 +20,7 @@
},
"devDependencies": {
"@tailwindcss/vite": "^4.3.1",
"@types/d3": "^7.4.3",
"@vitejs/plugin-vue": "^6.0.7",
"typescript": "^5",
"vue-tsc": "^3"
+18 -3
View File
@@ -25,7 +25,10 @@ const activeTab = computed<RouteName>(() => {
return routeNames.includes(route.name as RouteName) ? (route.name as RouteName) : 'portfolio'
})
const showAuthGate = computed(() => true)
const navigate = (name: RouteName) => router.push({ name })
const navigate = (name: RouteName) => {
if (name === 'settings') return router.push('/settings/api-keys')
return router.push({ name })
}
const {
status,
@@ -67,6 +70,7 @@ const {
quantityInput,
priceInput,
chartMode,
chartRange,
showSearch,
searchQuery,
countryFilter,
@@ -86,6 +90,7 @@ const {
orderHistoryNotice,
positions,
chartPricePoints,
chartNotice,
pricePolling,
selectedStock,
orderQuantity,
@@ -93,6 +98,11 @@ const {
cashOrderPrimaryRequiresPrice,
cashOrderTriggerPrice,
cashOrderSecondaryPrice,
cashOrderAccountTypeOptions,
cashOrderMarketOptions,
cashOrderTermOptions,
cashOrderDateOptions,
cashOrderPriceStep,
estimatedAmount,
showPortfolioSpinner,
canRequestCashEstimate,
@@ -106,7 +116,6 @@ const {
totalAssetValue,
stockAssetRatio,
cashAssetRatio,
boxPlotStyle,
hasQuote,
selectStock,
connect,
@@ -211,17 +220,23 @@ onMounted(async () => {
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"
:box-plot-style="boxPlotStyle"
:has-quote="hasQuote"
@open-search="showSearch = true"
@select-stock="selectStock"
@@ -103,8 +103,8 @@ const methodAllowed = (method: string) => {
const ui = {
root: 'grid gap-3',
limitGrid: 'grid grid-cols-3 gap-3',
limitGridCompact: 'grid-cols-5',
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]',
@@ -113,7 +113,7 @@ const ui = {
actions: 'mt-3 flex flex-wrap gap-2',
button:
'min-h-9 rounded-full bg-[#263141] px-4 text-sm font-extrabold text-[#d3e3fd] transition hover:bg-[#303b4d]',
methodGrid: 'mt-3 grid grid-cols-2 gap-2',
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',
@@ -128,7 +128,7 @@ const ui = {
<input v-model.number="settings.maxTradesPerHour" :class="ui.input" type="number" min="0" />
</label>
<label :class="ui.label">
3時間 取引上限
6時間 取引上限
<input
v-model.number="settings.maxTradesPer6Hours"
:class="ui.input"
@@ -13,6 +13,7 @@ const props = withDefaults(
readonly?: boolean
disabled?: boolean
min?: string | number
step?: string | number
autofocus?: boolean
}>(),
{
@@ -24,6 +25,7 @@ const props = withDefaults(
readonly: false,
disabled: false,
min: undefined,
step: undefined,
autofocus: false,
},
)
@@ -77,6 +79,7 @@ const controlClass =
:value="modelValue"
:type="type"
:min="min"
:step="step"
:placeholder="placeholder"
:autocomplete="autocomplete"
:spellcheck="spellcheck"
+5 -10
View File
@@ -3,7 +3,6 @@ import type {
CashOrderMarket,
CashOrderMethod,
CashOrderPriceCondition,
CashOrderTerm,
CashOrderTriggerZone,
OrderKind,
TradeSide,
@@ -25,6 +24,8 @@ export const cashOrderAccountTypeOptions: Array<{
}> = [
{ label: '特定', value: 'specific' },
{ label: '一般', value: 'general' },
{ label: 'NISA成長投資枠', value: 'growthInvestment' },
{ label: 'NISA', value: 'nisa' },
]
export const cashOrderMarketOptions: Array<{
@@ -33,6 +34,9 @@ export const cashOrderMarketOptions: Array<{
}> = [
{ label: '自動', value: 'auto' },
{ label: '東証', value: 'TKY' },
{ label: '名証', value: 'NGY' },
{ label: '福証', value: 'FKO' },
{ label: '札証', value: 'SPR' },
{ label: 'SOR', value: 'SOR' },
{ label: 'PTS', value: 'PTS' },
{ label: 'PTS(X)', value: 'PTX' },
@@ -58,15 +62,6 @@ export const cashOrderPriceConditionOptions: Array<{
{ label: '不成', value: 'funari' },
]
export const cashOrderTermOptions: Array<{
label: string
value: CashOrderTerm
}> = [
{ label: '当日中', value: 'day' },
{ label: '今週中', value: 'week' },
{ label: '日付指定', value: 'date' },
]
export const cashOrderMethodOptions: Array<{
label: string
value: CashOrderMethod
@@ -65,6 +65,14 @@ 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>
@@ -80,7 +88,7 @@ const triggerZoneLabel = (zone: CashOrderTriggerZone) => (zone === 'above' ? '
</div>
<div :class="ui.confirmRow">
<dt>預り区分</dt>
<dd>{{ accountType === 'specific' ? '特定' : '一般' }}</dd>
<dd>{{ accountTypeLabel(accountType) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>注文市場</dt>
@@ -151,7 +159,7 @@ const triggerZoneLabel = (zone: CashOrderTriggerZone) => (zone === 'above' ? '
</div>
<div :class="ui.confirmRow">
<dt>預り区分</dt>
<dd>{{ accountType === 'specific' ? '特定' : '一般' }}</dd>
<dd>{{ accountTypeLabel(accountType) }}</dd>
</div>
<div :class="ui.confirmRow">
<dt>注文市場</dt>
@@ -1,5 +1,18 @@
<script setup lang="ts">
import { Ban, KeyRound, Plug, RefreshCw, Save, Trash2 } from 'lucide-vue-next'
import {
Ban,
ChevronRight,
KeyRound,
Pencil,
Plug,
RefreshCw,
Save,
ShieldCheck,
Trash2,
UserRoundCog,
} from 'lucide-vue-next'
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 { ui } from '../../styles/ui'
@@ -27,142 +40,326 @@ const emit = defineEmits<{
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 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 sectionTitle = computed(() =>
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="ui.settingsLayout">
<div :class="ui.apiLayout">
<article :class="ui.panel">
<div :class="ui.panelHead">
<div>
<p :class="ui.eyebrow">Fine-grained</p>
<h2>API Key 発行</h2>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('addApiKey')">
<KeyRound class="h-4 w-4" aria-hidden="true" />
発行
</button>
<section
class="grid min-h-[calc(100dvh-9rem)] min-w-0 max-w-full gap-0 lg:grid-cols-[18rem_minmax(0,1fr)]"
>
<aside
class="min-w-0 border-b border-[#30343a] pb-4 lg:sticky lg:top-0 lg:h-fit lg:border-r lg:border-b-0 lg:pr-5 lg:pb-0"
aria-label="Settings"
>
<div class="grid gap-5">
<div class="grid gap-1">
<h2 class="text-xl font-black text-[#e3e3e9]">設定</h2>
<p class="text-sm font-semibold text-[#9aa0a9]">用途ごとに管理項目を分けています。</p>
</div>
<label :class="ui.label">
名前
<input v-model="apiKeyLabel" :class="ui.input" placeholder="例: trade-bot-readonly" />
</label>
<ApiKeyPolicyEditor v-model="newApiKeySettings" permissions-open />
<textarea v-if="newApiToken" v-model="newApiToken" :class="ui.input" readonly></textarea>
</article>
<article :class="ui.panel">
<div :class="ui.panelHead">
<h2>発行済みキー</h2>
<button :class="ui.ghostButton" type="button" @click="emit('refresh')">
<RefreshCw class="h-4 w-4" aria-hidden="true" />
更新
</button>
<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-5 pt-5 lg:pl-7 lg:pt-0">
<header
class="flex flex-wrap items-center justify-between gap-3 border-b border-[#30343a] pb-5"
>
<div class="grid gap-1">
<p :class="ui.eyebrow">Settings</p>
<h2 class="text-2xl font-black text-[#e3e3e9]">{{ sectionTitle }}</h2>
</div>
<div :class="ui.list">
<div v-for="key in apiKeys" :key="key.id" :class="ui.keyRow">
<div :class="ui.row">
<span>{{ key.label }}</span>
<div :class="ui.rowActions">
<button
:class="ui.primaryButton"
type="button"
title="Save settings"
@click="emit('saveApiKeySettings', key)"
>
<Save class="h-4 w-4" aria-hidden="true" />
保存
</button>
<button
:class="ui.dangerButton"
type="button"
title="Revoke"
@click="emit('revokeApiKey', key.id)"
>
<Ban class="h-4 w-4" aria-hidden="true" />
失効
</button>
<button :class="ui.ghostButton" type="button" @click="emit('refresh')">
<RefreshCw class="h-4 w-4" aria-hidden="true" />
更新
</button>
</header>
<template v-if="activeSection === 'api-keys'">
<article :class="[ui.panel, 'min-w-0 overflow-hidden']">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p :class="ui.eyebrow">Create</p>
<h3 class="text-lg font-black">API Key 発行</h3>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('addApiKey')">
<KeyRound class="h-4 w-4" aria-hidden="true" />
発行
</button>
</div>
<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>
<article :class="[ui.panel, 'min-w-0 overflow-hidden']">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p :class="ui.eyebrow">Active keys</p>
<h3 class="text-lg font-black">発行済みキー</h3>
</div>
</div>
<div v-if="apiKeys.length" :class="ui.list">
<div v-for="key in apiKeys" :key="key.id" :class="ui.keyRow">
<div :class="ui.row">
<span class="min-w-0 truncate font-black">{{ key.label }}</span>
<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>
<ApiKeyPolicyEditor
:model-value="key"
compact
@update:model-value="Object.assign(key, $event)"
/>
</div>
</div>
</article>
<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="[ui.panel, 'min-w-0 overflow-hidden']">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p :class="ui.eyebrow">Profiles</p>
<h3 class="text-lg font-black">パスキー追加</h3>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('addSbiPasskey')">
<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="[ui.panel, 'min-w-0 overflow-hidden']">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p :class="ui.eyebrow">Connection</p>
<h3 class="text-lg font-black">プロフィール切り替え</h3>
</div>
<button :class="ui.ghostButton" type="button" @click="emit('connect')">
<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>
<div :class="ui.apiLayout">
<article :class="ui.panel">
<div :class="ui.panelHead">
<div>
<p :class="ui.eyebrow">Profiles</p>
<h2>パスキー設定</h2>
<div
v-if="editingApiKey"
class="fixed inset-0 z-30 grid place-items-center overflow-y-auto bg-[#101418]/75 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
@click.self="closeApiKeyEditor"
>
<section
class="grid max-h-[calc(100dvh-2rem)] w-full max-w-5xl gap-5 overflow-y-auto rounded-[28px] border border-[#30343a] bg-[#1b1f24] p-6 shadow-2xl shadow-black/35"
>
<header
class="flex flex-wrap items-center justify-between gap-3 border-b border-[#33383f] pb-4"
>
<div class="grid min-w-0 gap-1">
<p :class="ui.eyebrow">Edit API key</p>
<h3 class="truncate text-xl font-black text-[#e3e3e9]">
{{ editingApiKey.label }}
</h3>
</div>
<button :class="ui.primaryButton" type="button" @click="emit('addSbiPasskey')">
<Save class="h-4 w-4" aria-hidden="true" />
保存
</button>
</div>
<label :class="ui.label">
名前
<input v-model="sbiLabel" :class="ui.input" placeholder="例: 個人メイン" />
</label>
<label :class="ui.label">
パスキー JSON
<textarea
v-model="sbiCredentialJson"
:class="ui.input"
spellcheck="false"
placeholder="{ ... }"
></textarea>
</label>
<div :class="ui.ticketBox">
<label :class="ui.label">
取引パスワード
<input v-model="tradePassword" :class="ui.input" type="password" autocomplete="off" />
</label>
<label :class="ui.label">
デバイスキー
<input v-model="sbiDeviceId" :class="ui.input" autocomplete="off" spellcheck="false" />
</label>
</div>
</article>
<article :class="ui.panel">
<div :class="ui.panelHead">
<h2>プロフィール切り替え</h2>
<button :class="ui.ghostButton" type="button" @click="emit('connect')">
<Plug class="h-4 w-4" aria-hidden="true" />
接続
</button>
</div>
<div :class="ui.list">
<label v-for="passkey in sbiPasskeys" :key="passkey.id" :class="ui.profileRow">
<input
v-model="selectedPasskeyId"
type="radio"
:value="passkey.id"
@change="emit('connect')"
/>
<span>
<strong>{{ passkey.label }}</strong>
<small>{{ new Date(passkey.createdAt).toLocaleString() }}</small>
</span>
<button
:class="ui.dangerButton"
type="button"
title="Delete passkey"
@click.prevent="emit('removeSbiPasskey', passkey.id)"
>
<Trash2 class="h-4 w-4" aria-hidden="true" />
削除
<div class="flex flex-wrap gap-2">
<button :class="ui.ghostButton" type="button" @click="closeApiKeyEditor">
キャンセル
</button>
</label>
</div>
</article>
<button :class="ui.primaryButton" type="button" @click="saveEditingApiKey">
<Save class="h-4 w-4" aria-hidden="true" />
保存
</button>
</div>
</header>
<ApiKeyPolicyEditor v-model="editingApiKey" permissions-open />
</section>
</div>
</section>
</template>
@@ -1,225 +1,719 @@
<script setup lang="ts">
import Chart from 'chart.js/auto'
import { Minus, Plus, RotateCcw } from 'lucide-vue-next'
import * as d3 from 'd3'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import Spinner from '../../components/ui/Spinner.vue'
import type { RealtimePricePoint } from '../../types/trading'
import type { ChartMode, ChartNotice, ChartRange, RealtimePricePoint } from '../../types/trading'
const props = defineProps<{
points: RealtimePricePoint[]
stockName: string
active: boolean
mode: ChartMode
range: ChartRange
previousClose?: number
notice?: ChartNotice | null
}>()
const canvas = ref<HTMLCanvasElement | null>(null)
const scroller = ref<HTMLDivElement | null>(null)
const zoomScale = ref(1)
let chart: Chart<'line', number[], string> | undefined
let wasScrolledToEnd = true
type ChartPoint = RealtimePricePoint & {
index: number
date: Date
}
const minChartWidth = 720
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: '',
date: '',
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 = 3
const maxZoom = 6
const zoomStep = 0.2
const basePointSpacing = 10
const pointSpacing = computed(() => basePointSpacing * zoomScale.value)
const dayMs = 24 * 60 * 60 * 1000
const tolerancePx = 10
const offscreenPointBuffer = 4
const minXAxisTickSpacing = 72
const labelFor = (point: RealtimePricePoint) => {
const date = new Date(point.at)
if (date.getHours() === 0 && date.getMinutes() === 0 && date.getSeconds() === 0) {
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 (
point.date.getHours() === 0 &&
point.date.getMinutes() === 0 &&
point.date.getSeconds() === 0
) {
return new Intl.DateTimeFormat('ja-JP', {
month: 'numeric',
day: 'numeric',
}).format(date)
}).format(point.date)
}
return new Intl.DateTimeFormat('ja-JP', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date)
}).format(point.date)
}
const labels = computed(() => props.points.map(labelFor))
const closePrice = (point: ChartPoint) => point.close ?? point.price
const prices = computed(() => props.points.map((point) => point.price))
const previousCloseValue = computed(() =>
props.range === '1D' && Number.isFinite(props.previousClose) && props.previousClose
? props.previousClose
: null,
)
const chartWidth = computed(() => {
const width = Math.max(minChartWidth, props.points.length * pointSpacing.value + 72)
return `max(100%, ${width}px)`
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 tooltipDateFor = (point: ChartPoint) => {
const hasTime =
point.date.getHours() !== 0 || point.date.getMinutes() !== 0 || point.date.getSeconds() !== 0
return new Intl.DateTimeFormat('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
...(hasTime
? {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}
: {}),
}).format(point.date)
}
const tooltipStyle = computed(() => {
const width = 152
const pad = 8
const left = Math.min(
Math.max(pad, tooltip.value.x + 12),
Math.max(pad, size.value.width - width - pad),
)
const top =
tooltip.value.y > 76
? Math.max(pad, tooltip.value.y - 70)
: Math.min(size.value.height - 64, tooltip.value.y + 14)
return {
left: `${left}px`,
top: `${top}px`,
}
})
const rememberScrollPosition = () => {
const element = scroller.value
if (!element) return
wasScrolledToEnd =
element.scrollLeft + element.clientWidth >= element.scrollWidth - pointSpacing.value
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 scrollToEndIfNeeded = async () => {
if (!wasScrolledToEnd) return
await nextTick()
const element = scroller.value
if (!element) return
element.scrollLeft = element.scrollWidth
const baseContentWidth = () => {
const points = chartPoints.value
return Math.max(plotWidth.value, Math.max(1, points.length - 1) * basePointSpacing)
}
const updateChart = () => {
if (!chart) return
rememberScrollPosition()
chart.data.labels = labels.value
const dataset = chart.data.datasets[0]
if (dataset) dataset.data = prices.value
chart.update('none')
chart.resize()
void scrollToEndIfNeeded()
const 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: `${closePrice(point).toLocaleString('ja-JP')}円`,
date: tooltipDateFor(point),
detail:
props.mode === 'box'
? `始 ${ohlc.open.toLocaleString('ja-JP')} / 高 ${ohlc.high.toLocaleString('ja-JP')} / 安 ${ohlc.low.toLocaleString('ja-JP')}`
: '',
}
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) => {
rememberScrollPosition()
zoomScale.value = Math.min(maxZoom, Math.max(minZoom, value))
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 = () => setZoomScale(1)
const resetZoom = () => alignLatest(1)
const setZoomScaleFromInput = (event: Event) => {
const value = Number((event.target as HTMLInputElement).value)
setZoomScale(value)
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) => {
if (!event.ctrlKey && !event.metaKey) return
event.preventDefault()
setZoomScale(zoomScale.value + (event.deltaY < 0 ? zoomStep : -zoomStep))
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)
}
onMounted(() => {
if (!canvas.value) return
chart = new Chart(canvas.value, {
type: 'line',
data: {
labels: labels.value,
datasets: [
{
data: prices.value,
borderColor: '#40dba2',
backgroundColor: 'rgba(64, 219, 162, 0.14)',
borderWidth: 2,
fill: true,
pointBackgroundColor: '#d3e3fd',
pointBorderColor: '#40dba2',
pointRadius: 2.5,
pointHoverRadius: 4,
tension: 0.25,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
animation: false,
plugins: {
legend: { display: false },
tooltip: {
displayColors: false,
callbacks: {
label: (item) => `${(item.parsed.y ?? 0).toLocaleString('ja-JP')}円`,
},
},
},
scales: {
x: {
grid: { color: 'rgba(143, 148, 157, 0.12)' },
ticks: { color: '#8f949d', maxTicksLimit: 5 },
},
y: {
grid: { color: 'rgba(143, 148, 157, 0.12)' },
ticks: {
color: '#8f949d',
callback: (value) =>
typeof value === 'number' ? value.toLocaleString('ja-JP') : value,
},
},
},
},
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)
})
void scrollToEndIfNeeded()
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, updateChart, { deep: true })
watch(chartWidth, () => {
chart?.resize()
void scrollToEndIfNeeded()
})
watch(
() => props.points,
() => {
if (followsLatest) alignLatest(zoomTransform.k)
else applyTransform(zoomTransform)
},
{ deep: true },
)
watch(
() => [props.mode, props.range, props.previousClose],
() => {
hideHover()
renderChart()
},
)
onBeforeUnmount(() => {
chart?.destroy()
chart = undefined
resizeObserver?.disconnect()
if (svg.value) d3.select(svg.value).on('.zoom', null)
})
</script>
<template>
<div class="relative h-full w-full">
<div
ref="scroller"
class="h-full w-full overflow-x-auto overflow-y-hidden"
@scroll="rememberScrollPosition"
<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"
>
<div class="h-full" :style="{ width: chartWidth }">
<canvas
ref="canvas"
class="h-full w-full"
:aria-label="`${stockName} realtime price chart`"
></canvas>
</div>
</div>
<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="absolute right-2 top-2 flex items-center gap-1 rounded-md border border-[#2d3440] bg-[#111418]/90 px-1.5 py-1 shadow-sm"
v-if="notice"
class="pointer-events-none absolute left-3 top-3 max-w-[min(22rem,calc(100%-1.5rem))] rounded-md border border-[#3a424f] bg-[#111418]/90 px-3 py-2 text-xs text-[#d3e3fd] shadow-lg shadow-black/20"
>
<button
class="grid h-6 w-6 place-items-center rounded text-[#d3e3fd] hover:bg-[#1d232b]"
type="button"
aria-label="縮小"
@click="zoomOut"
>
<Minus class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<input
:value="zoomScale"
class="h-6 w-24 accent-[#40dba2]"
type="range"
:min="minZoom"
:max="maxZoom"
:step="zoomStep"
aria-label="グラフ縮尺"
@input="setZoomScaleFromInput"
/>
<button
class="grid h-6 w-6 place-items-center rounded text-[#d3e3fd] hover:bg-[#1d232b]"
type="button"
aria-label="拡大"
@click="zoomIn"
>
<Plus class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<button
class="grid h-6 w-6 place-items-center rounded text-[#8f949d] hover:bg-[#1d232b] hover:text-[#d3e3fd]"
type="button"
aria-label="縮尺を戻す"
@click="resetZoom"
>
<RotateCcw class="h-3.5 w-3.5" aria-hidden="true" />
</button>
<div class="font-black text-[#ffcf6e]">{{ notice.title }}</div>
<div v-if="notice.detail" class="mt-0.5 text-[#8f949d]">{{ notice.detail }}</div>
</div>
<div
class="pointer-events-none absolute w-[152px] translate-y-1 rounded-md border border-[#2d3440] bg-[#111418]/95 px-3 py-2 text-xs text-[#d3e3fd] opacity-0 shadow-lg shadow-black/30 transition-all duration-150 ease-out"
:class="tooltip.visible && 'translate-y-0 opacity-100'"
:style="tooltipStyle"
>
<div class="font-black text-[#40dba2]">{{ tooltip.price }}</div>
<div class="mt-1 text-[#8f949d]">{{ tooltip.date }}</div>
<div v-if="tooltip.detail" class="mt-1 leading-relaxed text-[#8f949d]">
{{ tooltip.detail }}
</div>
</div>
</div>
</template>
+143 -76
View File
@@ -1,5 +1,14 @@
<script setup lang="ts">
import { ChevronDown, Download, FileCheck2, Search, ShieldCheck } from 'lucide-vue-next'
import {
ChevronDown,
Download,
FileCheck2,
Minus,
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'
@@ -7,19 +16,17 @@ 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,
ChartNotice,
ChartRange,
CashOrderAccountType,
CashOrderMarket,
CashOrderMethod,
@@ -36,6 +43,24 @@ import { currency } 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', '7D', '1M', '3M', '1Y', 'ALL']
defineProps<{
viewedStocks: Stock[]
@@ -44,17 +69,16 @@ defineProps<{
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
boxPlotStyle: {
min: string
q1: string
median: string
q3: string
max: string
}
hasQuote: (stock: Stock) => boolean
}>()
@@ -90,11 +114,10 @@ const cashOrderSecondaryPriceInput = defineModel<string>('cashOrderSecondaryPric
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 orderMarketOptions = computed(() =>
orderKind.value === 's' ? sKabuOrderMarketOptions : cashOrderMarketOptions,
)
const priceChart = ref<InstanceType<typeof RealtimePriceChart> | null>(null)
const primaryPriceConditionRequiresPrice = computed(() =>
['limit', 'limitAtOpen', 'limitAtClose', 'limitIoc', 'funari'].includes(
cashOrderPriceCondition.value,
@@ -178,27 +201,65 @@ const emit = defineEmits<{
</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>
<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="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 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" />
@@ -206,53 +267,22 @@ const emit = defineEmits<{
</button>
</div>
<div v-if="chartMode === 'line'" :class="ui.chartBox">
<div :class="ui.chartBox">
<RealtimePriceChart
v-if="hasQuote(selectedStock)"
ref="priceChart"
:points="realtimePricePoints"
:stock-name="selectedStock.name"
:active="pricePolling"
:mode="chartMode"
:range="chartRange"
:previous-close="selectedStock.prevClose"
:notice="chartNotice"
/>
<span v-else class="grid h-full place-items-center text-[#8f949d]">
<Spinner />
</span>
</div>
<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">
@@ -413,10 +443,30 @@ const emit = defineEmits<{
:transition="advancedTransition"
>
<span :class="ui.advancedLabel">預り区分</span>
<UiSegmented
v-model="cashOrderAccountType"
:options="cashOrderAccountTypeOptions"
/>
<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"
@@ -424,7 +474,7 @@ const emit = defineEmits<{
:disabled="orderKind === 's'"
>
<option
v-for="option in orderMarketOptions"
v-for="option in cashOrderMarketOptions"
:key="option.value"
:value="option.value"
>
@@ -447,6 +497,7 @@ const emit = defineEmits<{
label="注文価格"
type="number"
min="1"
:step="cashOrderPriceStep"
placeholder="価格を入力"
/>
<UiField v-model="cashOrderTerm" as="select" label="有効期限">
@@ -459,7 +510,21 @@ const emit = defineEmits<{
</option>
</UiField>
<UiField
v-if="cashOrderTerm === 'date'"
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"
@@ -488,6 +553,7 @@ const emit = defineEmits<{
label="逆指値価格"
type="number"
min="1"
:step="cashOrderPriceStep"
placeholder="価格を入力"
/>
</template>
@@ -511,6 +577,7 @@ const emit = defineEmits<{
label="OCO価格"
type="number"
min="1"
:step="cashOrderPriceStep"
placeholder="価格を入力"
/>
</template>
@@ -1,4 +1,5 @@
import type {
ChartNotice,
OrderPreview,
OrderRow,
Position,
@@ -180,7 +181,83 @@ export const stockFromBoard = (
}
}
const chartDateTimeToIso = (value: string) => {
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,
})
}
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))
@@ -188,19 +265,72 @@ const chartDateTimeToIso = (value: string) => {
const day = Number(digits.slice(6, 8))
const hour = Number(digits.slice(8, 10) || '0')
const minute = Number(digits.slice(10, 12) || '0')
const date = new Date(year, month - 1, day, hour, minute)
return Number.isNaN(date.getTime()) ? '' : date.toISOString()
const time = zonedDateTimeToUtcMs(timeZone, { year, month, day, hour, minute })
return Number.isNaN(time) ? '' : new Date(time).toISOString()
}
export const pricePointsFromIssueChart = (value: unknown): RealtimePricePoint[] =>
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))
return { at, price: numberValue(price.close) }
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
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: isWeekendInJapan() ? '本日は休場日です' : '本日の取引データはまだありません',
detail: `最新のチャートは${formattedLatestDate}です`,
}
}
export const positionFromApi = (value: unknown): Position | null => {
const item = asRecord(value)
const issue = issueFrom(item.issue)
@@ -243,7 +373,7 @@ export const orderFromApi = (value: unknown): OrderRow | null => {
const quantity =
status === '約定済'
? (executedQuantity ?? orderedQuantity ?? unexecutedQuantity)
: (orderedQuantity ?? unexecutedQuantity ?? executedQuantity)
: (unexecutedQuantity ?? orderedQuantity ?? executedQuantity)
const price =
status === '約定済'
? (nullableNumberValue(item.executedPrice) ?? nullableNumberValue(item.price))
@@ -1,7 +1,14 @@
import { computed, ref, watch, type Ref } from 'vue'
import { createRpcSocket } from '../../api'
import {
cashOrderAccountTypeOptions as defaultCashOrderAccountTypeOptions,
cashOrderMarketOptions as defaultCashOrderMarketOptions,
sKabuOrderMarketOptions,
} from '../../constants/trade'
import type {
ChartMode,
ChartRange,
ChartNotice,
CashOrderAccountType,
CashOrderMarket,
CashOrderMethod,
@@ -29,8 +36,10 @@ import {
orderFromApi,
orderHistoryKey,
orderHistoryResultNotice,
chartNoticeFromIssueChart,
pricePointsFromIssueChart,
positionFromApi,
startOfMarketDateUtcMs,
stockFromBoard,
stockFromIssue,
stockFromPosition,
@@ -43,6 +52,26 @@ type RpcResolver = {
reject: (reason: Error) => void
}
type CashOrderTermOption = {
label: string
value: CashOrderTerm
}
type CashOrderAccountTypeOption = {
label: string
value: CashOrderAccountType
}
type CashOrderMarketOption = {
label: string
value: CashOrderMarket
}
type CashOrderDateOption = {
label: string
value: string
}
const priceBasedCashOrderConditions = new Set<CashOrderPriceCondition>([
'limit',
'limitAtOpen',
@@ -54,6 +83,77 @@ const priceBasedCashOrderConditions = new Set<CashOrderPriceCondition>([
const cashOrderPriceConditionRequiresPrice = (condition: CashOrderPriceCondition) =>
priceBasedCashOrderConditions.has(condition)
const chartRangeOptions = {
'1D': { period: 'minute', unit: 5, count: 120 },
'7D': { period: 'day', unit: 1, count: 7 },
'1M': { period: 'day', unit: 1, count: 31 },
'3M': { period: 'day', unit: 1, count: 93 },
'1Y': { period: 'day', unit: 1, count: 365 },
ALL: { period: 'month', unit: 1, count: 9999 },
} as const satisfies Record<
ChartRange,
{ period: 'minute' | 'day' | 'week' | 'month'; unit: number; count: number }
>
const marketTimeZones: Record<string, string> = {
FKO: 'Asia/Tokyo',
NGY: 'Asia/Tokyo',
PTS: 'Asia/Tokyo',
PTX: 'Asia/Tokyo',
SOR: 'Asia/Tokyo',
SPR: 'Asia/Tokyo',
STK: 'Asia/Tokyo',
TKY: 'Asia/Tokyo',
AMEX: 'America/New_York',
ARCX: 'America/New_York',
NAS: 'America/New_York',
NASDAQ: 'America/New_York',
NYS: 'America/New_York',
NYSE: 'America/New_York',
XNAS: 'America/New_York',
XNYS: 'America/New_York',
}
const countryTimeZones: Record<string, string> = {
アメリカ: 'America/New_York',
日本: 'Asia/Tokyo',
米国: 'America/New_York',
US: 'America/New_York',
USA: 'America/New_York',
}
const timeZoneForStock = (stock: Stock) =>
marketTimeZones[stock.market.toUpperCase()] ?? countryTimeZones[stock.country] ?? null
const normalizeApkOrderTermDate = (value: string) => {
const digits = value.replace(/\D/g, '')
if (digits.length === 6) return `20${digits}`
if (digits.length === 8) return digits
return ''
}
const displayApkOrderTermDate = (value: string) => {
const normalized = normalizeApkOrderTermDate(value)
if (!normalized) return value
return `${normalized.slice(0, 4)}/${normalized.slice(4, 6)}/${normalized.slice(6, 8)}`
}
const apkExchangeMarketCodes: CashOrderMarket[] = ['SOR', 'TKY', 'NGY', 'FKO', 'SPR', 'PTS', 'PTX']
const parseApkExchangeMarkets = (value: string) => {
const markets: CashOrderMarket[] = []
for (let index = 0; index < value.length; index += 3) {
const code = value.slice(index, index + 3) as CashOrderMarket
if (apkExchangeMarketCodes.includes(code) && !markets.includes(code)) markets.push(code)
}
return markets
}
const priceMatchesStep = (price: number, step: number) => {
if (!Number.isFinite(price) || !Number.isFinite(step) || step <= 0) return true
return Math.abs(price / step - Math.round(price / step)) < 1e-8
}
export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const selectedStockCode = ref('')
const viewedStockCodes = ref<string[]>([])
@@ -72,6 +172,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const quantityInput = ref('')
const priceInput = ref('')
const chartMode = ref<ChartMode>('line')
const chartRange = ref<ChartRange>('1D')
const showSearch = ref(false)
const searchQuery = ref('')
const countryFilter = ref('all')
@@ -81,6 +182,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const pendingCashEstimateId = ref<number | null>(null)
const lastCashEstimate = ref<OrderPreview | null>(null)
const lastCashEstimateKey = ref('')
const cashPreOrder = ref<RecordLike | null>(null)
const ws = ref<WebSocket | null>(null)
const rpcPending = new Map<number, RpcResolver>()
const sbiConnected = ref(false)
@@ -96,16 +198,16 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const positions = ref<Position[]>([])
const stocks = ref<Stock[]>([])
const historicalPricePoints = ref<RealtimePricePoint[]>([])
const chartNotice = ref<ChartNotice | null>(null)
const realtimePricePoints = ref<RealtimePricePoint[]>([])
const pricePolling = ref(false)
let rpcId = 0
let boardPollingSubscriptionId = ''
let boardPollingRequestId = 0
let chartHistoryRequestId = 0
let cashPreOrderRequestId = 0
const maxRealtimePricePoints = 120
const maxChartPricePoints = 240
const errorMessage = (cause: unknown, fallback: string) =>
cause instanceof Error ? cause.message : fallback
@@ -139,6 +241,111 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
if (orderKind.value === 's') return 'STK'
return cashOrderMarket.value === 'auto' ? selectedStock.value.market : cashOrderMarket.value
})
const sKabuAvailable = computed(() => {
if (orderKind.value !== 's') return true
const sKabu = cashPreOrder.value ? asRecord(cashPreOrder.value.sKabu) : null
return sKabu?.available !== false
})
const cashOrderIppanMarginPaymentLimit = computed(() => {
const margin = cashPreOrder.value ? asRecord(cashPreOrder.value.margin) : {}
return textValue(margin.ippanPaymentLimit) || undefined
})
const cashOrderAccountTypeOptions = computed<CashOrderAccountTypeOption[]>(() => {
const nisa = cashPreOrder.value ? asRecord(cashPreOrder.value.nisa) : {}
const options = defaultCashOrderAccountTypeOptions.filter(
(option) => option.value === 'specific' || option.value === 'general',
)
if (textValue(nisa.growthServiceKbn) === '1') {
const growth = defaultCashOrderAccountTypeOptions.find(
(option) => option.value === 'growthInvestment',
)
if (growth) options.push(growth)
}
if (textValue(nisa.serviceKbn) === '1') {
const nisaOption = defaultCashOrderAccountTypeOptions.find(
(option) => option.value === 'nisa',
)
if (nisaOption) options.push(nisaOption)
}
return options
})
const cashOrderMarketOptions = computed<CashOrderMarketOption[]>(() => {
if (orderKind.value === 's') return sKabuOrderMarketOptions
const exchangeList = textValue(cashPreOrder.value?.exchangeList)
if (!exchangeList) return defaultCashOrderMarketOptions
const markets = parseApkExchangeMarkets(exchangeList)
const options = markets
.map((market) => defaultCashOrderMarketOptions.find((option) => option.value === market))
.filter((option): option is CashOrderMarketOption => Boolean(option))
return options.length ? options : defaultCashOrderMarketOptions
})
const preferredCashOrderMarket = computed(() => {
if (orderKind.value === 's') return 'STK'
const index = Number(textValue(cashPreOrder.value?.exchangeListIndexFlag))
const indexedOption =
Number.isInteger(index) && index > 0 ? cashOrderMarketOptions.value[index - 1] : undefined
return (
indexedOption?.value ??
cashOrderMarketOptions.value.find((option) => option.value === selectedStock.value.market)
?.value ??
cashOrderMarketOptions.value[0]?.value ??
'auto'
)
})
const apkOrderTerms = computed(() =>
asArray(cashPreOrder.value?.orderTerms)
.map((value) => textValue(value))
.filter(Boolean),
)
const apkOrderTermDates = computed(() =>
asArray(cashPreOrder.value?.orderTermDates)
.map((value) => textValue(value))
.filter(Boolean),
)
const cashOrderTermOptions = computed<CashOrderTermOption[]>(() => {
if (orderKind.value === 's') return [{ label: '当日中', value: 'day' }]
const terms = apkOrderTerms.value
if (!terms.length) {
return [
{ label: '当日中', value: 'day' },
{ label: '今週中', value: 'week' },
{ label: '日付指定', value: 'date' },
]
}
const options: CashOrderTermOption[] = []
if (terms.some((term) => term === '当日中')) options.push({ label: '当日中', value: 'day' })
if (terms.some((term) => term === '今週中')) options.push({ label: '今週中', value: 'week' })
if (apkOrderTermDates.value.length || terms.some((term) => /\d/.test(term))) {
options.push({ label: '日付指定', value: 'date' })
}
return options.length ? options : [{ label: '当日中', value: 'day' }]
})
const cashOrderDateOptions = computed<CashOrderDateOption[]>(() => {
const dates = apkOrderTermDates.value.length
? apkOrderTermDates.value
: apkOrderTerms.value.filter((term) => /\d/.test(term))
return dates
.map((date) => ({
label: displayApkOrderTermDate(date),
value: normalizeApkOrderTermDate(date),
}))
.filter((option) => option.value)
})
const cashOrderPriceStep = computed(() => {
const steps = asArray(cashPreOrder.value?.priceSteps)
.map((value) => {
const record = asRecord(value)
return {
upper: numberValue(record.from),
step: numberValue(record.to),
}
})
.filter((step) => step.upper > 0 && step.step > 0)
.sort((left, right) => left.upper - right.upper)
if (!steps.length) return 1
const referencePrice = orderPrice.value > 0 ? orderPrice.value : selectedStock.value.price
return steps.find((step) => referencePrice <= step.upper)?.step ?? steps.at(-1)?.step ?? 1
})
const estimatedAmount = computed(() => Math.max(0, orderQuantity.value * orderPrice.value))
const hasQuote = (stock: Stock) => stock.price > 0
const hasAccountSummary = computed(
@@ -154,10 +361,12 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
JSON.stringify({
issueCode: selectedStock.value.code,
market: resolvedCashOrderMarket.value,
preOrderMarket: orderKind.value === 's' ? selectedStock.value.market : undefined,
side: tradeSide.value,
quantity: orderQuantity.value,
kind: orderKind.value,
accountType: cashOrderAccountType.value,
depositType: cashOrderAccountType.value,
priceCondition: orderKind.value === 's' ? undefined : cashOrderPriceCondition.value,
price:
orderKind.value !== 's' && cashOrderPrimaryRequiresPrice.value
@@ -187,14 +396,30 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
cashOrderSecondaryRequiresPrice.value
? cashOrderSecondaryPrice.value
: undefined,
ippanMarginPaymentLimit:
orderKind.value !== 's' ? cashOrderIppanMarginPaymentLimit.value : undefined,
}),
)
const canRequestCashEstimate = computed(() => {
if (!connected.value || !selectedStock.value.code || orderQuantity.value <= 0) return false
if (orderKind.value === 's' && !selectedStock.value.market) return false
if (!sKabuAvailable.value) return false
if (orderKind.value === 's') return true
if (cashOrderPrimaryRequiresPrice.value && orderPrice.value <= 0) return false
if (
cashOrderPrimaryRequiresPrice.value &&
!priceMatchesStep(orderPrice.value, cashOrderPriceStep.value)
) {
return false
}
if (cashOrderTerm.value === 'date' && !cashOrderDateInput.value) return false
if (cashOrderMethod.value !== 'normal' && cashOrderTriggerPrice.value <= 0) return false
if (
cashOrderMethod.value !== 'normal' &&
!priceMatchesStep(cashOrderTriggerPrice.value, cashOrderPriceStep.value)
) {
return false
}
if (
cashOrderMethod.value === 'oco' &&
cashOrderSecondaryRequiresPrice.value &&
@@ -202,6 +427,13 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
) {
return false
}
if (
cashOrderMethod.value === 'oco' &&
cashOrderSecondaryRequiresPrice.value &&
!priceMatchesStep(cashOrderSecondaryPrice.value, cashOrderPriceStep.value)
) {
return false
}
return true
})
const canPlaceCashOrder = computed(
@@ -251,22 +483,20 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
if (!totalAssetValue.value) return 0
return (buyingPower.value / totalAssetValue.value) * 100
})
const chartPricePoints = computed(() =>
[...historicalPricePoints.value, ...realtimePricePoints.value].slice(-maxChartPricePoints),
)
const boxPlotStyle = computed(() => {
const box = selectedStock.value.box
const range = Math.max(1, box.max - box.min)
const toPct = (value: number) => ((value - box.min) / range) * 100
return {
min: `${toPct(box.min)}%`,
q1: `${toPct(box.q1)}%`,
median: `${toPct(box.median)}%`,
q3: `${toPct(box.q3)}%`,
max: `${toPct(box.max)}%`,
}
})
const selectedStockTimeZone = computed(() => timeZoneForStock(selectedStock.value))
const chartPricePoints = computed(() => {
const points = [...historicalPricePoints.value, ...realtimePricePoints.value]
if (chartRange.value !== '1D') return points
const timeZone = selectedStockTimeZone.value
if (!timeZone) return points
const start = startOfMarketDateUtcMs(timeZone)
return points.filter((point) => {
const time = Date.parse(point.at)
return Number.isFinite(time) && time >= start
})
})
const recordViewedStock = (code: string) => {
if (!code) return
viewedStockCodes.value = [code, ...viewedStockCodes.value.filter((entry) => entry !== code)]
@@ -289,7 +519,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
if (!Number.isFinite(price) || price <= 0) return
realtimePricePoints.value = [
...realtimePricePoints.value,
{ at: at.toISOString(), price },
{ at: at.toISOString(), price, open: price, high: price, low: price, close: price },
].slice(-maxRealtimePricePoints)
}
@@ -550,6 +780,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
boardPollingSubscriptionId = ''
chartHistoryRequestId += 1
historicalPricePoints.value = []
chartNotice.value = null
realtimePricePoints.value = []
pricePolling.value = false
sbiConnected.value = false
@@ -591,19 +822,27 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const loadSelectedStockChart = async () => {
historicalPricePoints.value = []
chartNotice.value = null
const stock = selectedStock.value
if (!connected.value || !stock.code) return
const requestId = ++chartHistoryRequestId
const chartOptions = chartRangeOptions[chartRange.value]
try {
const timeZone = selectedStockTimeZone.value
if (!timeZone) {
throw new Error(`Unsupported market timezone for ${stock.market || stock.country}`)
}
const chart = await rpcCall<RecordLike>('market.issue.chart', {
issueCode: stock.code,
market: stock.market || undefined,
period: 'day',
count: 120,
period: chartOptions.period,
unit: chartOptions.unit,
count: chartOptions.count,
})
if (requestId !== chartHistoryRequestId) return
historicalPricePoints.value = pricePointsFromIssueChart(chart)
historicalPricePoints.value = pricePointsFromIssueChart(chart, timeZone)
chartNotice.value = chartNoticeFromIssueChart(chart)
} catch (cause) {
if (requestId === chartHistoryRequestId) {
reportDataError(errorMessage(cause, '価格履歴の取得に失敗しました'), cause)
@@ -649,10 +888,12 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
const cashOrderParams = () => ({
issueCode: selectedStock.value.code,
market: resolvedCashOrderMarket.value || undefined,
preOrderMarket: orderKind.value === 's' ? selectedStock.value.market || undefined : undefined,
side: tradeSide.value,
quantity: orderQuantity.value,
kind: orderKind.value === 's' ? 's' : undefined,
accountType: cashOrderAccountType.value,
depositType: cashOrderAccountType.value,
price:
orderKind.value !== 's' && cashOrderPrimaryRequiresPrice.value ? orderPrice.value : undefined,
priceCondition: orderKind.value !== 's' ? cashOrderPriceCondition.value : undefined,
@@ -680,8 +921,36 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
cashOrderSecondaryRequiresPrice.value
? cashOrderSecondaryPrice.value
: undefined,
ippanMarginPaymentLimit:
orderKind.value !== 's' ? cashOrderIppanMarginPaymentLimit.value : undefined,
})
const refreshCashPreOrder = async () => {
const requestId = ++cashPreOrderRequestId
if (!connected.value || !selectedStock.value.code || !resolvedCashOrderMarket.value) {
cashPreOrder.value = null
return
}
try {
const preOrder = await rpcCall<RecordLike>('orders.cash.preOrder', {
issueCode: selectedStock.value.code,
market: resolvedCashOrderMarket.value,
preOrderMarket:
orderKind.value === 's' ? selectedStock.value.market || undefined : undefined,
side: tradeSide.value,
kind: orderKind.value === 's' ? 's' : undefined,
accountType: cashOrderAccountType.value,
depositType: cashOrderAccountType.value,
})
if (requestId === cashPreOrderRequestId) cashPreOrder.value = preOrder
} catch (cause) {
if (requestId === cashPreOrderRequestId) {
cashPreOrder.value = null
reportDataError(errorMessage(cause, '注文前情報の取得に失敗しました'), cause)
}
}
}
const askPlaceOrder = () => {
if (!canPlaceCashOrder.value) return
showEstimateDialog.value = false
@@ -783,7 +1052,35 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
})
watch(cashOrderTerm, (term) => {
if (term !== 'date') cashOrderDateInput.value = ''
if (term !== 'date') {
cashOrderDateInput.value = ''
return
}
const firstDate = cashOrderDateOptions.value[0]?.value
if (firstDate && !cashOrderDateInput.value) cashOrderDateInput.value = firstDate
})
watch(cashOrderAccountTypeOptions, (options) => {
if (options.some((option) => option.value === cashOrderAccountType.value)) return
cashOrderAccountType.value = options[0]?.value ?? 'specific'
})
watch(cashOrderMarketOptions, (options) => {
if (options.some((option) => option.value === cashOrderMarket.value)) return
cashOrderMarket.value = preferredCashOrderMarket.value
})
watch(cashOrderTermOptions, (options) => {
if (options.some((option) => option.value === cashOrderTerm.value)) return
cashOrderTerm.value = options[0]?.value ?? 'day'
})
watch(cashOrderDateOptions, (options) => {
if (cashOrderTerm.value !== 'date') return
const firstOption = options[0]
if (!firstOption) return
if (options.some((option) => option.value === cashOrderDateInput.value)) return
cashOrderDateInput.value = firstOption.value
})
watch(cashOrderMethod, (method) => {
@@ -795,6 +1092,22 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
if (method === 'stop') cashOrderSecondaryPriceInput.value = ''
})
watch(
[
connected,
() => selectedStock.value.code,
() => selectedStock.value.market,
resolvedCashOrderMarket,
tradeSide,
orderKind,
cashOrderAccountType,
],
() => {
void refreshCashPreOrder()
},
{ immediate: true },
)
watch(searchQuery, (query) => {
clearTimeout(searchTimer)
const trimmed = query.trim()
@@ -818,7 +1131,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
})
watch(
[connected, () => selectedStock.value.code, () => selectedStock.value.market],
[connected, () => selectedStock.value.code, () => selectedStock.value.market, chartRange],
() => {
void loadSelectedStockChart()
void startBoardPolling()
@@ -843,6 +1156,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
quantityInput,
priceInput,
chartMode,
chartRange,
showSearch,
searchQuery,
countryFilter,
@@ -863,6 +1177,7 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
positions,
realtimePricePoints,
chartPricePoints,
chartNotice,
pricePolling,
selectedStock,
orderQuantity,
@@ -870,6 +1185,11 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
cashOrderPrimaryRequiresPrice,
cashOrderTriggerPrice,
cashOrderSecondaryPrice,
cashOrderAccountTypeOptions,
cashOrderMarketOptions,
cashOrderTermOptions,
cashOrderDateOptions,
cashOrderPriceStep,
estimatedAmount,
showPortfolioSpinner,
canRequestCashEstimate,
@@ -883,7 +1203,6 @@ export const useTradingSession = (selectedPasskeyId: Ref<string>) => {
totalAssetValue,
stockAssetRatio,
cashAssetRatio,
boxPlotStyle,
hasQuote,
selectStock,
connect,
+3 -2
View File
@@ -12,8 +12,9 @@ export const router = createRouter({
{ path: '/portfolio', name: 'portfolio', component: RouteStub },
{ path: '/trade', name: 'trade', component: RouteStub },
{ path: '/history', name: 'history', component: RouteStub },
{ path: '/api-keys', redirect: '/settings' },
{ path: '/settings', name: 'settings', component: RouteStub },
{ path: '/api-keys', redirect: '/settings/api-keys' },
{ path: '/settings', redirect: '/settings/api-keys' },
{ path: '/settings/:section(api-keys|passkeys)', name: 'settings', component: RouteStub },
{ path: '/oauth/authorize', name: 'oauthAuthorize', component: RouteStub },
{ path: '/:pathMatch(.*)*', redirect: '/portfolio' },
],
+7 -1
View File
@@ -79,7 +79,7 @@ export const ui = {
stockHoldingNote:
'inline-flex w-fit items-center rounded-full border border-[#2f343b] bg-[#111418] px-4 py-2 text-sm font-semibold text-[#c3c7cf]',
quoteBox: 'grid justify-items-end gap-1',
periodTabs: 'flex justify-end gap-3',
periodTabs: 'flex flex-wrap justify-end gap-2',
periodButton: 'min-h-7 rounded-full bg-transparent px-3 text-xs font-bold text-[#8f949d]',
periodButtonActive: 'bg-[#d3e3fd] text-[#102033]',
chartActions: 'flex items-center justify-between gap-4',
@@ -120,6 +120,12 @@ export const ui = {
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]',
+13 -2
View File
@@ -1,7 +1,7 @@
export type TradeSide = 'buy' | 'sell'
export type OrderKind = 'standard' | 's'
export type CashOrderAccountType = 'specific' | 'general'
export type CashOrderMarket = 'auto' | 'TKY' | 'SOR' | 'PTS' | 'PTX' | 'STK'
export type CashOrderAccountType = 'specific' | 'general' | 'growthInvestment' | 'nisa'
export type CashOrderMarket = 'auto' | 'TKY' | 'NGY' | 'FKO' | 'SPR' | 'SOR' | 'PTS' | 'PTX' | 'STK'
export type CashOrderPriceCondition =
| 'limit'
| 'limitAtOpen'
@@ -16,6 +16,7 @@ export type CashOrderTerm = 'day' | 'week' | 'date'
export type CashOrderMethod = 'normal' | 'stop' | 'oco'
export type CashOrderTriggerZone = 'above' | 'below'
export type ChartMode = 'line' | 'box'
export type ChartRange = '1D' | '7D' | '1M' | '3M' | '1Y' | 'ALL'
export type RpcMessage = {
id: number
@@ -48,6 +49,16 @@ export type JsonRpcResponse = {
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 Stock = {
+6 -3
View File
@@ -12,10 +12,13 @@ COPY . .
RUN bun --filter @repo/csbie build
FROM oven/bun:1-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
ENV CSBIE_DATABASE_PATH=/app/data/csbie.sqlite
WORKDIR /app
COPY --from=build /app/apps/csbie/dist ./apps/csbie/dist
COPY --from=build /app/apps/csbie-ui/dist ./apps/csbie-ui/dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/node_modules/.bun/@napi-rs+keyring@*/node_modules/@napi-rs/keyring ./node_modules/@napi-rs/keyring
COPY --from=build /app/node_modules/.bun/@napi-rs+keyring-linux-x64-gnu@*/node_modules/@napi-rs/keyring-linux-x64-gnu ./node_modules/@napi-rs/keyring-linux-x64-gnu
WORKDIR /app/apps/csbie/dist
EXPOSE 8787
CMD ["bun", "apps/csbie/dist/index.js"]
CMD ["bun", "index.js"]
+1 -1
View File
@@ -5,7 +5,7 @@
"scripts": {
"clean": "rm -rf dist ../csbie-ui/dist",
"dev": "bun --env-file=../../.env src/dev.ts",
"build": "bun --filter @repo/csbie-ui build && bun build src/index.ts --target=bun --outdir=dist",
"build": "bun --filter @repo/csbie-ui build && bun build src/index.ts --target=bun --outdir=dist --external @napi-rs/keyring",
"start": "bun --env-file=../../.env dist/index.js",
"typecheck": "tsc"
},