394 lines
12 KiB
Vue
394 lines
12 KiB
Vue
<script setup lang="ts">
|
|
import { AnimatePresence } from 'motion-v'
|
|
import { computed, onMounted, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { revokeApiKey } from './api'
|
|
import AppHeader from './components/layout/AppHeader.vue'
|
|
import AppSidebar from './components/layout/AppSidebar.vue'
|
|
import SearchDialog from './features/search/SearchDialog.vue'
|
|
import AuthGate from './features/auth/AuthGate.vue'
|
|
import { useAuthAdmin } from './features/auth/useAuthAdmin'
|
|
import HistoryView from './features/history/HistoryView.vue'
|
|
import OAuthApprovalPanel from './features/oauth/OAuthApprovalPanel.vue'
|
|
import { useOAuthApproval } from './features/oauth/useOAuthApproval'
|
|
import OrderDialogs from './features/orders/OrderDialogs.vue'
|
|
import PortfolioView from './features/portfolio/PortfolioView.vue'
|
|
import SettingsView from './features/settings/SettingsView.vue'
|
|
import TradeView from './features/trade/TradeView.vue'
|
|
import { useTradingSession } from './features/trading/useTradingSession'
|
|
import { stockIdFromTradeRouteId, tradeRouteIdFromStockId } from './constants/trade'
|
|
import { routeNames, type RouteName } from './router'
|
|
import { ui } from './styles/ui'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const activeTab = computed<RouteName>(() => {
|
|
return routeNames.includes(route.name as RouteName) ? (route.name as RouteName) : 'portfolio'
|
|
})
|
|
const isOAuthRoute = computed(() => route.name === 'oauthAuthorize')
|
|
const showAuthGate = computed(() => true)
|
|
const decodedRouteParam = (value: string) => {
|
|
try {
|
|
return decodeURIComponent(value)
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
const tradePath = (id: string) => `/trade/${encodeURIComponent(id)}`
|
|
const tradeId = computed(() =>
|
|
typeof route.params.id === 'string'
|
|
? stockIdFromTradeRouteId(decodedRouteParam(route.params.id))
|
|
: '',
|
|
)
|
|
const navigateTrade = (id = selectedStock.value.symbol || selectedStockCode.value) => {
|
|
if (!id) return
|
|
void router.push(tradePath(tradeRouteIdFromStockId(id)))
|
|
}
|
|
const navigate = (name: RouteName) => {
|
|
if (name === 'settings') return router.push('/settings/api-keys')
|
|
if (name === 'trade') return navigateTrade()
|
|
return router.push({ name })
|
|
}
|
|
|
|
const {
|
|
status,
|
|
apiKeys,
|
|
sbiPasskeys,
|
|
selectedPasskeyId,
|
|
setupPassword,
|
|
authBusy,
|
|
apiKeyLabel,
|
|
newApiKeySettings,
|
|
newApiToken,
|
|
sbiLabel,
|
|
sbiCredentialJson,
|
|
tradePassword,
|
|
sbiDeviceId,
|
|
refresh,
|
|
addApiKey,
|
|
saveApiKeySettings,
|
|
setupOwnerPasskey,
|
|
loginWithPasskey,
|
|
addSbiPasskey,
|
|
removeSbiPasskey,
|
|
} = useAuthAdmin()
|
|
|
|
const {
|
|
selectedStockCode,
|
|
selectedStockId,
|
|
tradeSide,
|
|
orderKind,
|
|
cashOrderAccountType,
|
|
cashOrderMarket,
|
|
cashOrderPriceCondition,
|
|
cashOrderTerm,
|
|
cashOrderDateInput,
|
|
cashOrderMethod,
|
|
cashOrderTriggerZone,
|
|
cashOrderTriggerPriceInput,
|
|
cashOrderSecondaryPriceCondition,
|
|
cashOrderSecondaryPriceInput,
|
|
quantityInput,
|
|
priceInput,
|
|
chartMode,
|
|
chartRange,
|
|
showSearch,
|
|
searchQuery,
|
|
countryFilter,
|
|
marketFilter,
|
|
showEstimateDialog,
|
|
showOrderDialog,
|
|
lastCashEstimate,
|
|
connected,
|
|
dataLoading,
|
|
searchLoading,
|
|
buyingPower,
|
|
holdingsMarketValue,
|
|
totalProfitLoss,
|
|
totalProfitLossRate,
|
|
marketIndexes,
|
|
orders,
|
|
cancelingOrderKey,
|
|
orderHistoryLoaded,
|
|
orderHistoryNotice,
|
|
positions,
|
|
chartPricePoints,
|
|
chartNotice,
|
|
pricePolling,
|
|
selectedStock,
|
|
orderQuantity,
|
|
orderPrice,
|
|
cashOrderPrimaryRequiresPrice,
|
|
cashOrderTriggerPrice,
|
|
cashOrderSecondaryPrice,
|
|
cashOrderAccountTypeOptions,
|
|
cashOrderMarketOptions,
|
|
cashOrderTermOptions,
|
|
cashOrderDateOptions,
|
|
cashOrderPriceStep,
|
|
estimatedAmount,
|
|
showPortfolioSpinner,
|
|
canRequestCashEstimate,
|
|
canPlaceCashOrder,
|
|
countries,
|
|
markets,
|
|
viewedStocks,
|
|
filteredStocks,
|
|
selectedPosition,
|
|
recentOrders,
|
|
totalAssetValue,
|
|
stockAssetRatio,
|
|
cashAssetRatio,
|
|
hasQuote,
|
|
selectStock,
|
|
selectStockByCode,
|
|
connect,
|
|
loadTradingData,
|
|
estimateCashOrder,
|
|
askPlaceOrder,
|
|
placeCashOrder,
|
|
cancelOrder,
|
|
loadOrderDetail,
|
|
loadTradeRecords,
|
|
loadPositionDetail,
|
|
estimateOrderCorrection,
|
|
placeOrderCorrection,
|
|
downloadCsv,
|
|
openTradeForStock,
|
|
openTradeForPosition,
|
|
} = useTradingSession(selectedPasskeyId)
|
|
|
|
const { oauthApproval, oauthSettings, loadOAuthApproval, approveOAuth } = useOAuthApproval()
|
|
|
|
const refreshAndMaybeConnect = () =>
|
|
refresh({
|
|
autoConnect: true,
|
|
connect: () => {
|
|
if (!connected.value) connect()
|
|
},
|
|
})
|
|
|
|
const addSbiPasskeyAndConnect = async () => {
|
|
await addSbiPasskey()
|
|
connect()
|
|
}
|
|
|
|
const revokeAndRefresh = async (id: string) => {
|
|
await revokeApiKey(id)
|
|
await refresh()
|
|
}
|
|
|
|
watch(
|
|
tradeId,
|
|
(id) => {
|
|
if (activeTab.value !== 'trade' || !id) return
|
|
selectStockByCode(id)
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
watch(
|
|
[activeTab, () => selectedStock.value.symbol, selectedStockId, selectedStockCode],
|
|
([tab]) => {
|
|
const id = selectedStock.value.symbol || selectedStockId.value || selectedStockCode.value
|
|
if (tab !== 'trade' || !id || tradeId.value === id) return
|
|
void router.replace(tradePath(tradeRouteIdFromStockId(id)))
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
await refreshAndMaybeConnect()
|
|
} catch {
|
|
status.value = { configured: false, authenticated: false }
|
|
}
|
|
await loadOAuthApproval()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<main :class="ui.appShell">
|
|
<AppSidebar v-if="!isOAuthRoute" :active-tab="activeTab" @navigate="navigate" />
|
|
|
|
<section :class="ui.workspace">
|
|
<AppHeader v-if="!isOAuthRoute && activeTab !== 'settings'" :active-tab="activeTab" />
|
|
|
|
<OAuthApprovalPanel
|
|
v-if="oauthApproval.active && status.authenticated"
|
|
v-model:settings="oauthSettings"
|
|
:approval="oauthApproval"
|
|
@approve="approveOAuth"
|
|
/>
|
|
|
|
<AuthGate
|
|
v-else-if="showAuthGate && !status.authenticated"
|
|
v-model:setup-password="setupPassword"
|
|
:status="status"
|
|
:auth-busy="authBusy"
|
|
@login="loginWithPasskey"
|
|
@setup="setupOwnerPasskey"
|
|
/>
|
|
|
|
<template v-else>
|
|
<PortfolioView
|
|
v-if="activeTab === 'portfolio'"
|
|
:show-portfolio-spinner="showPortfolioSpinner"
|
|
:total-asset-value="totalAssetValue"
|
|
:buying-power="buyingPower"
|
|
:holdings-market-value="holdingsMarketValue"
|
|
:total-profit-loss="totalProfitLoss"
|
|
:total-profit-loss-rate="totalProfitLossRate"
|
|
:market-indexes="marketIndexes"
|
|
:stock-asset-ratio="stockAssetRatio"
|
|
:cash-asset-ratio="cashAssetRatio"
|
|
:positions="positions"
|
|
:recent-orders="recentOrders"
|
|
:canceling-order-key="cancelingOrderKey"
|
|
:data-loading="dataLoading"
|
|
:connected="connected"
|
|
:order-history-loaded="orderHistoryLoaded"
|
|
:order-history-notice="orderHistoryNotice"
|
|
:load-position-detail="loadPositionDetail"
|
|
@connect="connect"
|
|
@open-position="
|
|
(code) =>
|
|
openTradeForPosition(code, () => void navigateTrade(selectedStock.symbol || code))
|
|
"
|
|
@cancel-order="cancelOrder"
|
|
/>
|
|
|
|
<TradeView
|
|
v-if="activeTab === 'trade'"
|
|
v-model:trade-side="tradeSide"
|
|
v-model:order-kind="orderKind"
|
|
v-model:cash-order-account-type="cashOrderAccountType"
|
|
v-model:cash-order-market="cashOrderMarket"
|
|
v-model:cash-order-price-condition="cashOrderPriceCondition"
|
|
v-model:cash-order-term="cashOrderTerm"
|
|
v-model:cash-order-date-input="cashOrderDateInput"
|
|
v-model:cash-order-method="cashOrderMethod"
|
|
v-model:cash-order-trigger-zone="cashOrderTriggerZone"
|
|
v-model:cash-order-trigger-price-input="cashOrderTriggerPriceInput"
|
|
v-model:cash-order-secondary-price-condition="cashOrderSecondaryPriceCondition"
|
|
v-model:cash-order-secondary-price-input="cashOrderSecondaryPriceInput"
|
|
v-model:quantity-input="quantityInput"
|
|
v-model:price-input="priceInput"
|
|
v-model:chart-mode="chartMode"
|
|
v-model:chart-range="chartRange"
|
|
:viewed-stocks="viewedStocks"
|
|
:selected-stock="selectedStock"
|
|
:selected-position="selectedPosition"
|
|
:connected="connected"
|
|
:order-quantity="orderQuantity"
|
|
:estimated-amount="estimatedAmount"
|
|
:cash-order-account-type-options="cashOrderAccountTypeOptions"
|
|
:cash-order-market-options="cashOrderMarketOptions"
|
|
:cash-order-term-options="cashOrderTermOptions"
|
|
:cash-order-date-options="cashOrderDateOptions"
|
|
:cash-order-price-step="cashOrderPriceStep"
|
|
:can-request-cash-estimate="canRequestCashEstimate"
|
|
:can-place-cash-order="canPlaceCashOrder"
|
|
:realtime-price-points="chartPricePoints"
|
|
:chart-notice="chartNotice"
|
|
:price-polling="pricePolling"
|
|
:has-quote="hasQuote"
|
|
@open-search="showSearch = true"
|
|
@select-stock="
|
|
(stock) => {
|
|
selectStock(stock)
|
|
navigateTrade(stock.symbol || stock.code)
|
|
}
|
|
"
|
|
@download-csv="downloadCsv"
|
|
@estimate="estimateCashOrder"
|
|
@confirm-order="askPlaceOrder"
|
|
/>
|
|
|
|
<HistoryView
|
|
v-if="activeTab === 'history'"
|
|
:orders="orders"
|
|
:connected="connected"
|
|
:data-loading="dataLoading"
|
|
:canceling-order-key="cancelingOrderKey"
|
|
:order-history-loaded="orderHistoryLoaded"
|
|
:order-history-notice="orderHistoryNotice"
|
|
:load-order-detail="loadOrderDetail"
|
|
:load-trade-records="loadTradeRecords"
|
|
:estimate-order-correction="estimateOrderCorrection"
|
|
:place-order-correction="placeOrderCorrection"
|
|
@refresh="loadTradingData"
|
|
@cancel="cancelOrder"
|
|
/>
|
|
|
|
<SettingsView
|
|
v-if="activeTab === 'settings'"
|
|
v-model:api-key-label="apiKeyLabel"
|
|
v-model:new-api-key-settings="newApiKeySettings"
|
|
v-model:new-api-token="newApiToken"
|
|
v-model:sbi-label="sbiLabel"
|
|
v-model:sbi-credential-json="sbiCredentialJson"
|
|
v-model:trade-password="tradePassword"
|
|
v-model:sbi-device-id="sbiDeviceId"
|
|
v-model:selected-passkey-id="selectedPasskeyId"
|
|
:api-keys="apiKeys"
|
|
:sbi-passkeys="sbiPasskeys"
|
|
@add-api-key="addApiKey"
|
|
@refresh="refresh"
|
|
@save-api-key-settings="saveApiKeySettings"
|
|
@revoke-api-key="revokeAndRefresh"
|
|
@add-sbi-passkey="addSbiPasskeyAndConnect"
|
|
@connect="connect"
|
|
@remove-sbi-passkey="removeSbiPasskey"
|
|
/>
|
|
</template>
|
|
</section>
|
|
|
|
<AnimatePresence>
|
|
<SearchDialog
|
|
v-if="showSearch"
|
|
key="search-dialog"
|
|
v-model:search-query="searchQuery"
|
|
v-model:country-filter="countryFilter"
|
|
v-model:market-filter="marketFilter"
|
|
:stocks="filteredStocks"
|
|
:selected-stock-code="selectedStockCode"
|
|
:countries="countries"
|
|
:markets="markets"
|
|
:loading="searchLoading"
|
|
@close="showSearch = false"
|
|
@select="
|
|
(stock) => openTradeForStock(stock, () => void navigateTrade(stock.symbol || stock.code))
|
|
"
|
|
/>
|
|
</AnimatePresence>
|
|
|
|
<OrderDialogs
|
|
:estimate="lastCashEstimate"
|
|
:show-estimate="showEstimateDialog"
|
|
:show-order="showOrderDialog"
|
|
:stock-name="selectedStock.name"
|
|
:stock-market="selectedStock.market"
|
|
:side="tradeSide"
|
|
:kind="orderKind"
|
|
:account-type="cashOrderAccountType"
|
|
:market="cashOrderMarket"
|
|
:price-condition="cashOrderPriceCondition"
|
|
:price="cashOrderPrimaryRequiresPrice ? orderPrice : 0"
|
|
:order-term="cashOrderTerm"
|
|
:order-date="cashOrderDateInput"
|
|
:order-method="cashOrderMethod"
|
|
:trigger-zone="cashOrderTriggerZone"
|
|
:trigger-price="cashOrderTriggerPrice"
|
|
:secondary-price-condition="cashOrderSecondaryPriceCondition"
|
|
:secondary-price="cashOrderSecondaryPrice"
|
|
:quantity="orderQuantity"
|
|
:amount="estimatedAmount"
|
|
@close-estimate="showEstimateDialog = false"
|
|
@proceed="askPlaceOrder"
|
|
@close-order="showOrderDialog = false"
|
|
@place="placeCashOrder"
|
|
/>
|
|
</main>
|
|
</template>
|