355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
import { computed, ref, shallowRef } from 'vue'
|
|
|
|
import type {
|
|
ActionItem,
|
|
ActionsResponse,
|
|
PipelineStatus,
|
|
StockDetail,
|
|
StockSearchItem,
|
|
Summary,
|
|
TraderDetail,
|
|
TraderListItem,
|
|
WarningItem,
|
|
WatchlistItem,
|
|
} from '../types'
|
|
import { numberFromText } from '../utils/format'
|
|
|
|
async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
const headers = new Headers(init.headers)
|
|
if (init.body && !headers.has('Content-Type')) {
|
|
headers.set('Content-Type', 'application/json')
|
|
}
|
|
|
|
const response = await fetch(path, {
|
|
...init,
|
|
headers,
|
|
})
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`)
|
|
}
|
|
return response.json() as Promise<T>
|
|
}
|
|
|
|
export function useDashboardData() {
|
|
const status = ref<PipelineStatus | null>(null)
|
|
const summary = ref<Summary | null>(null)
|
|
const warnings = ref<WarningItem[]>([])
|
|
const traders = ref<TraderListItem[]>([])
|
|
const traderDetail = ref<TraderDetail | null>(null)
|
|
const stockDetail = ref<StockDetail | null>(null)
|
|
const actions = ref<ActionItem[]>([])
|
|
const watchlist = ref<WatchlistItem[]>([])
|
|
|
|
const selectedWarningCode = shallowRef('')
|
|
const selectedTraderId = shallowRef<number | null>(null)
|
|
const selectedStockCode = shallowRef('')
|
|
const selectedDateFrom = shallowRef('')
|
|
const selectedDateTo = shallowRef('')
|
|
const selectedTraderFilter = shallowRef('all')
|
|
const selectedActionFilter = shallowRef<'all' | 'buy' | 'sell' | 'net_buy' | 'net_sell'>('all')
|
|
const isBooting = shallowRef(true)
|
|
const errorMessage = shallowRef('')
|
|
|
|
const activeWarning = computed(() => {
|
|
return warnings.value.find((item) => item.stock_code === selectedWarningCode.value) ?? null
|
|
})
|
|
|
|
const availableTradeDates = computed(() => {
|
|
return (status.value?.recent_trade_days ?? [])
|
|
.map((item) => item.trade_date)
|
|
.filter((item): item is string => Boolean(item))
|
|
})
|
|
|
|
function inferActionSide(buyAmount: number, sellAmount: number, netAmount: number): ActionItem['action_side'] {
|
|
if (buyAmount > 0 && sellAmount <= 0) return 'buy'
|
|
if (sellAmount > 0 && buyAmount <= 0) return 'sell'
|
|
if (netAmount >= 0) return 'net_buy'
|
|
return 'net_sell'
|
|
}
|
|
|
|
function matchesActionFilter(item: ActionItem): boolean {
|
|
const buyAmount = numberFromText(item.buy_amount_wan) ?? 0
|
|
const sellAmount = numberFromText(item.sell_amount_wan) ?? 0
|
|
const netAmount = numberFromText(item.net_amount_wan) ?? buyAmount - sellAmount
|
|
|
|
if (selectedActionFilter.value === 'buy') return buyAmount > 0
|
|
if (selectedActionFilter.value === 'sell') return sellAmount > 0
|
|
if (selectedActionFilter.value === 'net_buy') return netAmount > 0
|
|
if (selectedActionFilter.value === 'net_sell') return netAmount < 0
|
|
return true
|
|
}
|
|
|
|
function aggregateCandidateActions(rows: ActionItem[]): ActionItem[] {
|
|
const groups = new Map<string, ActionItem>()
|
|
|
|
for (const item of rows) {
|
|
const groupKey = `${item.stock_code}::${item.trade_date}`
|
|
const existing = groups.get(groupKey)
|
|
if (!existing) {
|
|
groups.set(groupKey, {
|
|
...item,
|
|
participant_traders: item.trader_name ? [item.trader_name] : [],
|
|
participant_trader_count: item.trader_name ? 1 : 0,
|
|
})
|
|
continue
|
|
}
|
|
|
|
const nextBuy = (numberFromText(existing.buy_amount_wan) ?? 0) + (numberFromText(item.buy_amount_wan) ?? 0)
|
|
const nextSell = (numberFromText(existing.sell_amount_wan) ?? 0) + (numberFromText(item.sell_amount_wan) ?? 0)
|
|
const nextNet = nextBuy - nextSell
|
|
const traderNames = new Set([...(existing.participant_traders ?? [existing.trader_name]), item.trader_name].filter(Boolean))
|
|
const tableTitles = new Set([existing.table_title, item.table_title].filter(Boolean))
|
|
const seatNames = new Set([existing.seat_name, item.seat_name].filter(Boolean))
|
|
const participantTraders = [...traderNames]
|
|
|
|
groups.set(groupKey, {
|
|
...existing,
|
|
trader_name: participantTraders.join(' / '),
|
|
participant_traders: participantTraders,
|
|
participant_trader_count: participantTraders.length,
|
|
table_title: [...tableTitles].join(' / '),
|
|
seat_name: seatNames.size > 1 ? `${seatNames.size}个席位` : existing.seat_name,
|
|
buy_amount_wan: nextBuy.toFixed(2),
|
|
sell_amount_wan: nextSell.toFixed(2),
|
|
net_amount_wan: nextNet.toFixed(2),
|
|
action_side: inferActionSide(nextBuy, nextSell, nextNet),
|
|
})
|
|
}
|
|
|
|
return [...groups.values()].sort((left, right) => {
|
|
const traderDiff = (right.participant_trader_count ?? 0) - (left.participant_trader_count ?? 0)
|
|
if (traderDiff !== 0) return traderDiff
|
|
|
|
const netDiff = (numberFromText(right.net_amount_wan) ?? 0) - (numberFromText(left.net_amount_wan) ?? 0)
|
|
if (netDiff !== 0) return netDiff
|
|
|
|
return right.trade_date.localeCompare(left.trade_date)
|
|
})
|
|
}
|
|
|
|
const filteredActions = computed(() => {
|
|
return actions.value.filter(matchesActionFilter)
|
|
})
|
|
|
|
const watchlistMap = computed(() => {
|
|
return new Map(watchlist.value.map((item) => [item.stock_code, item]))
|
|
})
|
|
|
|
const watchedActionRows = computed(() => {
|
|
return filteredActions.value.filter((item) => watchlistMap.value.has(item.stock_code))
|
|
})
|
|
|
|
const candidateActionRows = computed(() => {
|
|
const rows = actions.value.filter((item) => !watchlistMap.value.has(item.stock_code))
|
|
return aggregateCandidateActions(rows).filter(matchesActionFilter)
|
|
})
|
|
|
|
const watchlistMetrics = computed(() => {
|
|
const rows = watchedActionRows.value
|
|
const uniqueCodes = new Set(rows.map((item) => item.stock_code))
|
|
const buyTotal = rows.reduce((sum, item) => sum + (numberFromText(item.buy_amount_wan) ?? 0), 0)
|
|
const sellTotal = rows.reduce((sum, item) => sum + (numberFromText(item.sell_amount_wan) ?? 0), 0)
|
|
const netPositiveCount = new Set(
|
|
rows
|
|
.filter((item) => (numberFromText(item.net_amount_wan) ?? 0) > 0)
|
|
.map((item) => item.stock_code),
|
|
).size
|
|
const warningCount = new Set(
|
|
warnings.value
|
|
.filter((item) => watchlistMap.value.has(item.stock_code))
|
|
.map((item) => item.stock_code),
|
|
).size
|
|
|
|
return {
|
|
watchCount: watchlist.value.length,
|
|
activeWatchCount: uniqueCodes.size,
|
|
buyTotalWan: buyTotal,
|
|
sellTotalWan: sellTotal,
|
|
netPositiveCount,
|
|
warningCount,
|
|
}
|
|
})
|
|
|
|
const watchlistStocksForDisplay = computed(() => {
|
|
return watchlist.value.map((item) => {
|
|
const warning = warnings.value.find((warningItem) => warningItem.stock_code === item.stock_code) ?? null
|
|
return {
|
|
...item,
|
|
hasWarning: Boolean(warning),
|
|
warning,
|
|
}
|
|
})
|
|
})
|
|
|
|
function isWatched(stockCode: string): boolean {
|
|
return watchlistMap.value.has(stockCode)
|
|
}
|
|
|
|
async function loadWatchlist() {
|
|
watchlist.value = await api<WatchlistItem[]>('/api/watchlist')
|
|
}
|
|
|
|
async function addToWatchlist(item: Pick<WatchlistItem, 'stock_code' | 'stock_name' | 'source_trade_date' | 'source_trader_name'>) {
|
|
if (watchlistMap.value.has(item.stock_code)) return
|
|
await api<WatchlistItem>('/api/watchlist', {
|
|
method: 'POST',
|
|
body: JSON.stringify(item),
|
|
})
|
|
await loadWatchlist()
|
|
}
|
|
|
|
async function removeFromWatchlist(stockCode: string) {
|
|
if (!watchlistMap.value.has(stockCode)) return
|
|
await api<{ ok: boolean; stock_code: string }>(`/api/watchlist/${encodeURIComponent(stockCode)}`, {
|
|
method: 'DELETE',
|
|
})
|
|
await loadWatchlist()
|
|
}
|
|
|
|
async function loadActions() {
|
|
const params = new URLSearchParams()
|
|
if (selectedDateFrom.value && selectedDateTo.value) {
|
|
params.set('date_from', selectedDateFrom.value)
|
|
params.set('date_to', selectedDateTo.value)
|
|
}
|
|
if (selectedTraderFilter.value !== 'all') params.set('trader_name', selectedTraderFilter.value)
|
|
params.set('limit', '300')
|
|
|
|
const response = await api<ActionsResponse>(`/api/actions?${params.toString()}`)
|
|
actions.value = response.actions
|
|
if (!selectedDateFrom.value && response.date_from) {
|
|
selectedDateFrom.value = response.date_from
|
|
}
|
|
if (!selectedDateTo.value && response.date_to) {
|
|
selectedDateTo.value = response.date_to
|
|
}
|
|
}
|
|
|
|
async function selectTrader(traderId: number) {
|
|
if (traderDetail.value?.trader.id === traderId) {
|
|
selectedTraderId.value = traderId
|
|
return
|
|
}
|
|
selectedTraderId.value = traderId
|
|
traderDetail.value = await api<TraderDetail>(`/api/traders/${traderId}`)
|
|
}
|
|
|
|
async function selectStock(stockCode: string) {
|
|
if (stockDetail.value?.stock.stock_code === stockCode) {
|
|
selectedStockCode.value = stockCode
|
|
return
|
|
}
|
|
selectedStockCode.value = stockCode
|
|
stockDetail.value = await api<StockDetail>(`/api/stocks/${encodeURIComponent(stockCode)}`)
|
|
}
|
|
|
|
async function searchStocks(query: string, limit = 8) {
|
|
const keyword = query.trim()
|
|
if (!keyword) return [] as StockSearchItem[]
|
|
|
|
const params = new URLSearchParams({
|
|
q: keyword,
|
|
limit: String(limit),
|
|
})
|
|
return api<StockSearchItem[]>(`/api/stocks/search?${params.toString()}`)
|
|
}
|
|
|
|
async function selectWarning(item: WarningItem) {
|
|
selectedWarningCode.value = item.stock_code
|
|
await selectStock(item.stock_code)
|
|
}
|
|
|
|
async function selectTradeDateRange(dateFrom: string, dateTo: string) {
|
|
selectedDateFrom.value = dateFrom
|
|
selectedDateTo.value = dateTo
|
|
await loadActions()
|
|
}
|
|
|
|
async function initialize() {
|
|
isBooting.value = true
|
|
errorMessage.value = ''
|
|
|
|
try {
|
|
const [statusResult, summaryResult, warningResult, traderResult, watchlistResult] = await Promise.all([
|
|
api<PipelineStatus>('/api/pipeline/status'),
|
|
api<Summary>('/api/summary'),
|
|
api<WarningItem[]>('/api/warnings?limit=40'),
|
|
api<TraderListItem[]>('/api/traders'),
|
|
api<WatchlistItem[]>('/api/watchlist'),
|
|
])
|
|
|
|
status.value = statusResult
|
|
summary.value = summaryResult
|
|
warnings.value = warningResult
|
|
traders.value = traderResult
|
|
watchlist.value = watchlistResult
|
|
|
|
const latestTradeDate = statusResult.recent_trade_days[0]?.trade_date ?? ''
|
|
selectedDateFrom.value = latestTradeDate
|
|
selectedDateTo.value = latestTradeDate
|
|
|
|
await loadActions()
|
|
|
|
if (traderResult[0]) {
|
|
selectedTraderId.value = traderResult[0].id
|
|
}
|
|
|
|
const watchlistCodeSet = new Set(watchlistResult.map((item) => item.stock_code))
|
|
const preferredWarningCode =
|
|
warningResult.find((item) => watchlistCodeSet.has(item.stock_code))?.stock_code ??
|
|
warningResult[0]?.stock_code ??
|
|
''
|
|
const preferredStockCode = watchlist.value[0]?.stock_code ?? preferredWarningCode
|
|
|
|
if (preferredWarningCode) {
|
|
selectedWarningCode.value = preferredWarningCode
|
|
}
|
|
if (preferredStockCode) {
|
|
selectedStockCode.value = preferredStockCode
|
|
}
|
|
} catch (error) {
|
|
errorMessage.value = String(error instanceof Error ? error.message : error)
|
|
} finally {
|
|
isBooting.value = false
|
|
}
|
|
}
|
|
|
|
return {
|
|
status,
|
|
summary,
|
|
warnings,
|
|
traders,
|
|
traderDetail,
|
|
stockDetail,
|
|
actions,
|
|
watchlist,
|
|
selectedWarningCode,
|
|
selectedTraderId,
|
|
selectedStockCode,
|
|
selectedDateFrom,
|
|
selectedDateTo,
|
|
selectedTraderFilter,
|
|
selectedActionFilter,
|
|
activeWarning,
|
|
availableTradeDates,
|
|
filteredActions,
|
|
watchedActionRows,
|
|
candidateActionRows,
|
|
watchlistMetrics,
|
|
watchlistStocksForDisplay,
|
|
isBooting,
|
|
errorMessage,
|
|
initialize,
|
|
loadActions,
|
|
loadWatchlist,
|
|
selectTrader,
|
|
selectStock,
|
|
searchStocks,
|
|
selectWarning,
|
|
selectTradeDateRange,
|
|
isWatched,
|
|
addToWatchlist,
|
|
removeFromWatchlist,
|
|
}
|
|
}
|