Add Ticker24h support: hourly market snapshots with USD-normalized volume filtering
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 */ }
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user