Add Ticker24h support: hourly market snapshots with USD-normalized volume filtering

This commit is contained in:
2026-04-26 18:39:52 -04:00
parent 85fcbe1330
commit 0178b5d29d
45 changed files with 1995 additions and 170 deletions

View File

@@ -2,6 +2,8 @@
import { ref } from 'vue'
import DetailsEditDialog from './DetailsEditDialog.vue'
import ResearchViewDialog from './ResearchViewDialog.vue'
import { useIndicatorStore } from '../stores/indicators'
import { useIndicatorTypesStore } from '../stores/indicatorTypes'
const props = defineProps<{
category: 'indicator' | 'strategy' | 'research'
@@ -14,6 +16,9 @@ const editingName = ref('')
const viewDialogVisible = ref(false)
const viewingName = ref('')
const indicatorStore = useIndicatorStore()
const indicatorTypesStore = useIndicatorTypesStore()
function openEdit(name: string) {
editingName.value = name
dialogVisible.value = true
@@ -24,8 +29,28 @@ function openView(name: string) {
viewDialogVisible.value = true
}
function addToChart(pandasTaName: string, displayName: string) {
const type = indicatorTypesStore.types[pandasTaName]
if (!type) return
const defaultParams: Record<string, any> = {}
for (const [k, p] of Object.entries(type.metadata.parameters)) {
defaultParams[k] = p.default
}
const now = Math.floor(Date.now() / 1000)
indicatorStore.addIndicator({
id: `${pandasTaName}_${Date.now()}`,
pandas_ta_name: pandasTaName,
instance_name: displayName,
parameters: defaultParams,
visible: true,
pane: type.metadata.pane,
custom_metadata: type.metadata,
created_at: now,
modified_at: now,
})
}
function onUpdated(_payload: { category: string; name: string; success: boolean; error?: string }) {
// Hook for handling the details_updated response — add logic here as needed
}
</script>
@@ -35,8 +60,9 @@ function onUpdated(_payload: { category: string; name: string; success: boolean;
<div v-for="row in rows" :key="row.id" class="item-row">
<span class="item-name">{{ row.display_name }}</span>
<span class="item-desc">{{ row.description ?? '' }}</span>
<button v-if="category === 'research'" class="view-btn" @click="openView(row.display_name)">View</button>
<button class="edit-btn" @click="openEdit(row.display_name)">Edit</button>
<button class="edit-btn" @click="openEdit(row.display_name)">Spec</button>
<button v-if="category === 'research'" class="view-btn" @click="openView(row.display_name)">Result</button>
<button v-if="category === 'indicator'" class="use-btn" @click="addToChart(row.id, row.display_name)">Use</button>
</div>
</div>
@@ -138,4 +164,21 @@ function onUpdated(_payload: { category: string; name: string; success: boolean;
border-color: #089981;
color: #089981;
}
.use-btn {
flex-shrink: 0;
background: none;
border: 1px solid #3d3d3d;
color: #888;
cursor: pointer;
font-size: 11px;
padding: 2px 8px;
border-radius: 3px;
line-height: 18px;
}
.use-btn:hover {
border-color: #4a9eca;
color: #4a9eca;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, watch, computed, onUnmounted } from 'vue'
import { ref, watch, computed, onUnmounted, nextTick } from 'vue'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import { useEditor, EditorContent } from '@tiptap/vue-3'
@@ -54,12 +54,13 @@ watch(() => props.visible, (v) => {
}
})
const messageHandler = (msg: WebSocketMessage) => {
const messageHandler = async (msg: WebSocketMessage) => {
if (msg.category !== props.category || msg.name !== props.name) return
if (msg.type === 'details_data') {
originalContent.value = msg.details ?? ''
editor.value?.commands.setContent(msg.details ?? '')
await nextTick()
originalContent.value = (editor.value?.storage as any).markdown.getMarkdown() ?? ''
loadState.value = 'ready'
} else if (msg.type === 'details_error') {
loadError.value = msg.error ?? 'Failed to load details'

View File

@@ -79,21 +79,37 @@ function evaluateIndicator(
periodSeconds: number,
pandasTaName: string,
parameters: Record<string, any>,
timeoutMs = 30_000
timeoutMs = 30_000,
signal?: AbortSignal,
): Promise<EvaluateResult> {
const requestId = `cind_${Date.now()}_${Math.random().toString(36).substring(7)}`
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
if (signal?.aborted) {
reject(new DOMException('Evaluation superseded', 'AbortError'))
return
}
const cleanup = () => {
clearTimeout(timer)
wsManager.removeHandler(handler)
signal?.removeEventListener('abort', onAbort)
}
const onAbort = () => {
cleanup()
reject(new DOMException('Evaluation superseded', 'AbortError'))
}
signal?.addEventListener('abort', onAbort, { once: true })
const timer = window.setTimeout(() => {
cleanup()
reject(new Error(`evaluate_indicator timeout for ${pandasTaName}`))
}, timeoutMs)
const handler: MessageHandler = (message: any) => {
if (message.type !== 'evaluate_indicator_result') return
if (message.request_id !== requestId) return
clearTimeout(timer)
wsManager.removeHandler(handler)
cleanup()
if (message.error) reject(new Error(message.error))
else resolve(message as EvaluateResult)
}
@@ -155,6 +171,10 @@ const refreshCallbacks = new Map<string, (newCfgKey: string) => void>()
// Used to detect when the visible range has expanded beyond the fetched range.
const fetchedRanges = new Map<string, { fromTime: number; toTime: number }>()
// indicatorId → AbortController for the in-flight evaluate_indicator request.
// Aborted when a newer request supersedes it (e.g. rapid scrolling).
const pendingEvaluations = new Map<string, AbortController>()
// TradingView widget reference — set by useCustomIndicators() so the
// constructor can query the current visible range.
let _tvWidget: any = null
@@ -230,8 +250,16 @@ function makeStudyConstructor(nOutputs: number = MAX_PLOTS) {
// Record the fetched range so range-expansion watchers can detect gaps
fetchedRanges.set(entry.indicatorId, { fromTime, toTime })
evaluateIndicator(symbol, fromTime, toTime, periodSeconds, entry.pandasTaName, entry.parameters)
// Cancel any in-flight evaluation for this indicator before starting a new one
const prevAbort = pendingEvaluations.get(entry.indicatorId)
if (prevAbort) prevAbort.abort()
const abortController = new AbortController()
pendingEvaluations.set(entry.indicatorId, abortController)
evaluateIndicator(symbol, fromTime, toTime, periodSeconds, entry.pandasTaName,
entry.parameters, 30_000, abortController.signal)
.then((result) => {
pendingEvaluations.delete(entry.indicatorId)
if (myGen !== _fetchGen) return
dataCache.set(capturedDk, buildDataCache(result))
const refreshKey = `${capturedCfgKey}__r`
@@ -240,7 +268,9 @@ function makeStudyConstructor(nOutputs: number = MAX_PLOTS) {
if (cb) cb(refreshKey)
})
.catch((err) => {
if (err instanceof DOMException && err.name === 'AbortError') return
console.error('[CustomIndicators] Failed to fetch data for', entry.pandasTaName, err)
pendingEvaluations.delete(entry.indicatorId)
fetchedRanges.delete(entry.indicatorId)
})
}
@@ -664,6 +694,12 @@ export function useCustomIndicators(tvWidget: any) {
refreshCallbacks.delete(indicatorId)
fetchedRanges.delete(indicatorId)
const abort = pendingEvaluations.get(indicatorId)
if (abort) {
abort.abort()
pendingEvaluations.delete(indicatorId)
}
pendingRegistration.delete(indicatorId)
if (entry.tvStudyId) {
try { tvWidget.activeChart().removeStudy(entry.tvStudyId) } catch { /* already gone */ }

View File

@@ -119,6 +119,12 @@ export class WebSocketDatafeed implements IBasicDataFeed {
const subscription = this.subscriptions.get(message.subscription_id)
if (subscription && message.bar) {
const symbolKey = subscription.symbolInfo.ticker || subscription.symbolInfo.name
// Drop bars whose ticker doesn't match the current subscription's symbol.
// This prevents a bar in-flight from a previous chart symbol reaching the new chart
// when the user switches symbols faster than the network round-trip.
if (message.ticker && message.ticker !== symbolKey) return
const denoms = this.symbolDenominators.get(symbolKey) || { tick: 1, base: 1 }
// Drop bars already covered by getBars history to prevent time-order violations
@@ -334,7 +340,8 @@ export class WebSocketDatafeed implements IBasicDataFeed {
type: 'subscribe_bars',
symbol: symbolInfo.ticker || symbolInfo.name,
period_seconds: intervalToSeconds(resolution),
subscription_id: listenerGuid
subscription_id: listenerGuid,
bar_type: 'open', // chart always wants live in-progress bar updates
})
.then((response) => {
if (response.success) {
@@ -353,14 +360,21 @@ export class WebSocketDatafeed implements IBasicDataFeed {
unsubscribeBars(listenerGuid: string): void {
const sub = this.subscriptions.get(listenerGuid)
// Remove immediately so any in-flight bar_update for this guid is ignored
// before the async server response arrives.
this.subscriptions.delete(listenerGuid)
if (sub) {
const barKey = `${sub.symbolInfo.ticker || sub.symbolInfo.name}_${intervalToSeconds(sub.resolution)}`
this.lastBarTimes.delete(barKey)
}
this.sendRequest<any>({
type: 'unsubscribe_bars',
symbol: sub?.symbolInfo.ticker || sub?.symbolInfo.name,
period_seconds: sub ? intervalToSeconds(sub.resolution) : 60,
subscription_id: listenerGuid
})
.then(() => {
this.subscriptions.delete(listenerGuid)
})
.catch((error) => {
console.error('Failed to unsubscribe from bars:', error)
})