From fd431516cca94d4c4a6a4428a3200b0ff6fe1ca1 Mon Sep 17 00:00:00 2001 From: Tim Olson Date: Thu, 9 Apr 2026 17:00:43 -0400 Subject: [PATCH] custom indicators fixed --- bin/bump-sandbox | 40 ++ bin/dev | 22 +- deploy/k8s/dev/configs/gateway-config.yaml | 4 +- gateway/src/clients/duckdb-client.ts | 33 +- gateway/src/clients/zmq-relay-client.ts | 4 +- gateway/src/harness/prompts/system-prompt.md | 25 +- gateway/src/workspace/types.ts | 5 + gateway/src/workspace/workspace-manager.ts | 2 + sandbox/dexorder/iceberg_client.py | 20 +- sandbox/main.py | 79 ++- web/src/App.vue | 3 + web/src/components/ChartView.vue | 98 +-- web/src/components/ChatPanel.vue | 5 + web/src/composables/useCustomIndicators.ts | 638 ++++++++++++------ web/src/composables/useTradingViewDatafeed.ts | 20 +- .../composables/useTradingViewIndicators.ts | 203 +++--- web/src/stores/indicatorTypes.ts | 17 + 17 files changed, 778 insertions(+), 440 deletions(-) create mode 100755 bin/bump-sandbox create mode 100644 web/src/stores/indicatorTypes.ts diff --git a/bin/bump-sandbox b/bin/bump-sandbox new file mode 100755 index 00000000..6b49eab4 --- /dev/null +++ b/bin/bump-sandbox @@ -0,0 +1,40 @@ +#!/bin/bash +# Builds and pushes a sandbox image for production, then pins the version in the prod config. +# Usage: bin/bump-sandbox [sidecar] (pass "sidecar" to also bump lifecycle-sidecar) +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REMOTE=${REMOTE:-git.dxod.org/dexorder/dexorder} +PROD_CONFIG="$ROOT_DIR/deploy/k8s/prod/configs/gateway-config.yaml" + +bump() { + local project="$1" + echo "==> Building $project..." + "$SCRIPT_DIR/build" "$project" + TAG="$(cd "$ROOT_DIR/$project" && git log --oneline | head -1 | cut -d ' ' -f 1)" + local image="$REMOTE/ai-$project:$TAG" + echo "==> Pushing $image..." + docker push "$image" + docker push "$REMOTE/ai-$project:latest" + echo "==> Updating prod config: $project → $TAG" + case "$project" in + sandbox) + sed -i "s|sandbox_image: .*|sandbox_image: $image|" "$PROD_CONFIG" + ;; + lifecycle-sidecar) + sed -i "s|sidecar_image: .*|sidecar_image: $image|" "$PROD_CONFIG" + ;; + esac +} + +bump sandbox +if [ "${1:-}" == "sidecar" ]; then + bump lifecycle-sidecar +fi + +echo "" +echo "Done. Review and commit the config change:" +echo " git diff $PROD_CONFIG" +echo " git add $PROD_CONFIG && git commit -m 'bump sandbox'" +echo " bin/dev restart gateway # run with --context=prod or equivalent" diff --git a/bin/dev b/bin/dev index f15499c2..4bfc96fe 100755 --- a/bin/dev +++ b/bin/dev @@ -153,7 +153,9 @@ rebuild_images() { # Build lifecycle-sidecar (Go binary) if [ "$service" == "all" ] || [ "$service" == "lifecycle-sidecar" ] || [ "$service" == "sidecar" ]; then echo -e "${GREEN}→${NC} Building lifecycle-sidecar..." - SIDECAR_TAG=$(build_and_get_tag lifecycle-sidecar) || exit 1 + _SIDECAR_BUILD_TAG=$(build_and_get_tag lifecycle-sidecar) || exit 1 + docker tag dexorder/ai-lifecycle-sidecar:$_SIDECAR_BUILD_TAG dexorder/ai-lifecycle-sidecar:dev + SIDECAR_TAG=$_SIDECAR_BUILD_TAG fi # Build web (Vue.js application) @@ -165,7 +167,9 @@ rebuild_images() { # Build sandbox (Python client library) if [ "$service" == "all" ] || [ "$service" == "sandbox" ]; then echo -e "${GREEN}→${NC} Building sandbox..." - SANDBOX_TAG=$(build_and_get_tag sandbox) || exit 1 + _SANDBOX_BUILD_TAG=$(build_and_get_tag sandbox) || exit 1 + docker tag dexorder/ai-sandbox:$_SANDBOX_BUILD_TAG dexorder/ai-sandbox:dev + SANDBOX_TAG=$_SANDBOX_BUILD_TAG fi # Save the tags for deployment (all services, preserving any we didn't rebuild) @@ -749,20 +753,6 @@ case "$COMMAND" in # Handle sandbox separately if [ "$sandbox_requested" == "1" ]; then - if [ -f "$ROOT_DIR/.dev-image-tag" ]; then - source "$ROOT_DIR/.dev-image-tag" - fi - echo -e "${GREEN}→${NC} Updating gateway config with new sandbox image tag ($SANDBOX_TAG)..." - cd "$ROOT_DIR/deploy/k8s/dev" - _sb_bak=$(mktemp) - cp "$ROOT_DIR/deploy/k8s/dev/configs/gateway-config.yaml" "$_sb_bak" - sed -i "s|sandbox_image: dexorder/ai-sandbox:.*|sandbox_image: dexorder/ai-sandbox:$SANDBOX_TAG|g" "$ROOT_DIR/deploy/k8s/dev/configs/gateway-config.yaml" - sed -i "s|sidecar_image: dexorder/ai-lifecycle-sidecar:.*|sidecar_image: dexorder/ai-lifecycle-sidecar:$SIDECAR_TAG|g" "$ROOT_DIR/deploy/k8s/dev/configs/gateway-config.yaml" - "$SCRIPT_DIR/config-update" dev - cp "$_sb_bak" "$ROOT_DIR/deploy/k8s/dev/configs/gateway-config.yaml" - rm "$_sb_bak" - echo -e "${GREEN}→${NC} Restarting gateway to pick up new sandbox image tag..." - kubectl rollout restart deployment/gateway echo -e "${GREEN}→${NC} Deleting user container deployments in sandbox namespace..." kubectl delete deployments --all -n sandbox 2>/dev/null || true echo -e "${GREEN}✓ User containers will be recreated by gateway on next login${NC}" diff --git a/deploy/k8s/dev/configs/gateway-config.yaml b/deploy/k8s/dev/configs/gateway-config.yaml index 5baef146..3af92f91 100644 --- a/deploy/k8s/dev/configs/gateway-config.yaml +++ b/deploy/k8s/dev/configs/gateway-config.yaml @@ -56,8 +56,8 @@ data: namespace: sandbox service_namespace: default in_cluster: true - sandbox_image: dexorder/ai-sandbox:dev20260408140409 - sidecar_image: dexorder/ai-lifecycle-sidecar:dev20260407185216 + sandbox_image: dexorder/ai-sandbox:dev20260409143116 + sidecar_image: dexorder/ai-lifecycle-sidecar:dev20260408103634 storage_class: standard image_pull_policy: Never # For minikube dev - use local images diff --git a/gateway/src/clients/duckdb-client.ts b/gateway/src/clients/duckdb-client.ts index 4e5c8676..ffdfd7db 100644 --- a/gateway/src/clients/duckdb-client.ts +++ b/gateway/src/clients/duckdb-client.ts @@ -405,22 +405,25 @@ export class DuckDBClient { return []; } - // Query the Iceberg table with filters + // Query the Iceberg table with filters, deduplicating by ingested_at so that + // duplicate parquet files (e.g. from repeated Flink job runs on the same key + // range) never produce more than one row per (ticker, period_seconds, timestamp). const sql = ` - SELECT - timestamp, - ticker, - period_seconds, - open, - high, - low, - close, - volume - FROM iceberg_scan('${tablePath}') - WHERE ticker = ? - AND period_seconds = ? - AND timestamp >= ? - AND timestamp < ? + SELECT timestamp, ticker, period_seconds, open, high, low, close, volume + FROM ( + SELECT + timestamp, ticker, period_seconds, open, high, low, close, volume, ingested_at, + ROW_NUMBER() OVER ( + PARTITION BY timestamp + ORDER BY ingested_at DESC + ) AS rn + FROM iceberg_scan('${tablePath}') + WHERE ticker = ? + AND period_seconds = ? + AND timestamp >= ? + AND timestamp < ? + ) + WHERE rn = 1 ORDER BY timestamp ASC `; diff --git a/gateway/src/clients/zmq-relay-client.ts b/gateway/src/clients/zmq-relay-client.ts index 5722d3ec..3b02550c 100644 --- a/gateway/src/clients/zmq-relay-client.ts +++ b/gateway/src/clients/zmq-relay-client.ts @@ -31,7 +31,7 @@ export interface ZMQRelayConfig { relayRequestEndpoint: string; // e.g., "tcp://relay:5559" relayNotificationEndpoint: string; // e.g., "tcp://relay:5558" clientId?: string; // Optional client ID, will generate if not provided - requestTimeout?: number; // Request timeout in ms (default: 30000) + requestTimeout?: number; // Request timeout in ms (default: 60000) onMetadataUpdate?: () => Promise; // Callback when symbol metadata updates } @@ -65,7 +65,7 @@ export class ZMQRelayClient { relayRequestEndpoint: config.relayRequestEndpoint, relayNotificationEndpoint: config.relayNotificationEndpoint, clientId: config.clientId || `gateway-${randomUUID().slice(0, 8)}`, - requestTimeout: config.requestTimeout || 30000, + requestTimeout: config.requestTimeout || 60000, onMetadataUpdate: config.onMetadataUpdate || (async () => {}), }; this.logger = logger; diff --git a/gateway/src/harness/prompts/system-prompt.md b/gateway/src/harness/prompts/system-prompt.md index 2eb394bf..20c9ba0e 100644 --- a/gateway/src/harness/prompts/system-prompt.md +++ b/gateway/src/harness/prompts/system-prompt.md @@ -5,27 +5,36 @@ You help users research markets, develop indicators and strategies, and analyze Your text responses should be markdown, using emojiis, color, and formatting to create a visually appealing response. +# User Information + **User License:** {{licenseType}} **Available Features:** {{features}} ---- - # Platform Capabilities -Dexorder trading platform provides OHLC data at a 1-minute resolution and supports strategies that read one or more OHLC feeds at a 1-minute resolution or coarser. It also offers a wide range of built-in indicators and allows users to create custom indicators for advanced analysis. +Dexorder trading platform provides OHLC data at a 1-minute resolution and supports strategies that read one or more OHLC feeds. It also offers a wide range of built-in indicators and allows users to create custom indicators for advanced analysis. Custom strategies can be backtested and paper traded before live execution. -Dexorder does not support tick-by-tick trading or high-frequency strategies. -Dexorder does not support long-running computations like paramater optimizations or training machine learning models. -Dexorder does not support portfolio optimization or trading strategies that require a large number of symbols. +Dexorder does not support: +* tick-by-tick trading or high-frequency strategies. +* long-running computations like paramater optimizations or training machine learning models. +* portfolio optimization or trading strategies that require a large number of symbols. -If the user asks for a capability not provided by Dexorder, decline and offer alternatives. +Dexorder does support: +* backtesting strategies against historical data. +* multi-symbol comparisons. +* multi-timeframe analysis. +* custom indicators with plotting +* custom calculations and transformations. +* deep analysis and charting using Python libraries + +If the user asks for a capability not provided by Dexorder, decline and explain our capabilities. # Important Instructions ## Investment Advice -**NEVER** recommend any specific ticker, trade, or strategy. You may suggest mechanical adjustments or improvements to strategies, but you must never recommend that the user adopt a specific trade or position. +**NEVER** recommend any specific ticker, trade, or position. You may suggest mechanical adjustments or improvements to strategies, but you must **NEVER** offer an opinion on a specific trade or position. You are **NOT** a registered investment advisor. ## Task Delegation - For ANY research questions, deep analysis, statistical analysis, charting requests, or market data queries that require computation, you MUST use the 'research' tool diff --git a/gateway/src/workspace/types.ts b/gateway/src/workspace/types.ts index 13e6b8fc..64c9e870 100644 --- a/gateway/src/workspace/types.ts +++ b/gateway/src/workspace/types.ts @@ -98,6 +98,11 @@ export const DEFAULT_STORES: StoreConfig[] = [ persistent: true, initialState: () => ({}), }, + { + name: 'indicator_types', + persistent: true, + initialState: () => ({}), + }, { name: 'channelState', persistent: false, diff --git a/gateway/src/workspace/workspace-manager.ts b/gateway/src/workspace/workspace-manager.ts index 4c346af8..70e41ec6 100644 --- a/gateway/src/workspace/workspace-manager.ts +++ b/gateway/src/workspace/workspace-manager.ts @@ -194,6 +194,8 @@ export class WorkspaceManager { const storeConfig = this.stores.find((s) => s.name === storeName); if (storeConfig?.persistent) { this.dirtyStores.add(storeName); + // Persist immediately so changes survive page reloads (not just graceful shutdown) + await this.saveDirtyStores(); } // Send response if needed diff --git a/sandbox/dexorder/iceberg_client.py b/sandbox/dexorder/iceberg_client.py index ba9875a4..c487e87c 100644 --- a/sandbox/dexorder/iceberg_client.py +++ b/sandbox/dexorder/iceberg_client.py @@ -92,6 +92,12 @@ class IcebergClient: # Reload table metadata to pick up snapshots committed after this client was initialized self.table = self.catalog.load_table(f"{self.namespace}.ohlc") + # Always fetch ingested_at alongside requested columns so we can deduplicate + # duplicate parquet files left by repeated Flink job runs on the same key range. + fetch_columns = None + if columns is not None: + fetch_columns = list(columns) if "ingested_at" in columns else list(columns) + ["ingested_at"] + scan = self.table.scan( row_filter=And( EqualTo("ticker", ticker), @@ -101,12 +107,22 @@ class IcebergClient: ) ) - if columns is not None: - scan = scan.select(*columns) + if fetch_columns is not None: + scan = scan.select(*fetch_columns) df = scan.to_pandas() if not df.empty: + # Deduplicate: keep the most-recently-ingested row per timestamp. + if "ingested_at" in df.columns: + df = ( + df.sort_values("ingested_at", ascending=False) + .drop_duplicates(subset=["timestamp"]) + ) + # Drop ingested_at if the caller did not ask for it + if columns is not None and "ingested_at" not in columns and "ingested_at" in df.columns: + df = df.drop(columns=["ingested_at"]) + df = df.sort_values("timestamp") # Convert integer nanosecond timestamps to DatetimeIndex df.index = pd.to_datetime(df["timestamp"], unit="ns", utc=True) diff --git a/sandbox/main.py b/sandbox/main.py index c018da59..38c90885 100644 --- a/sandbox/main.py +++ b/sandbox/main.py @@ -15,6 +15,7 @@ import logging import os import signal import sys +import time from pathlib import Path from typing import Optional @@ -35,7 +36,7 @@ from dexorder.conda_manager import sync_packages, install_packages from dexorder.events import EventType, UserEvent, DeliverySpec from dexorder.impl.charting_api_impl import ChartingAPIImpl from dexorder.impl.data_api_impl import DataAPIImpl -from dexorder.tools.python_tools import get_category_manager +from dexorder.tools.python_tools import get_category_manager, sanitize_name from dexorder.tools.workspace_tools import get_workspace_store from dexorder.tools.evaluate_indicator import evaluate_indicator from dexorder.tools.backtest_strategy import backtest_strategy @@ -57,6 +58,75 @@ def get_data_dir() -> Path: return DATA_DIR +# ============================================================================= +# Indicator Types Helpers +# ============================================================================= + +def _build_indicator_type_entry(meta: dict) -> dict: + """Build an indicator_types workspace entry from indicator metadata dict.""" + name = meta.get('name', '') + pandas_ta_name = f"custom_{sanitize_name(name).lower()}" + now = int(time.time()) + return { + 'pandas_ta_name': pandas_ta_name, + 'display_name': name, + 'description': meta.get('description', ''), + 'metadata': { + 'display_name': name, + 'parameters': meta.get('parameters') or {}, + 'input_series': meta.get('input_series') or ['close'], + 'output_columns': meta.get('output_columns') or [{'name': 'value'}], + 'pane': meta.get('pane', 'separate'), + 'filled_areas': meta.get('filled_areas') or [], + 'bands': meta.get('bands') or [], + }, + 'created_at': now, + 'modified_at': now, + } + + +def _upsert_indicator_type(workspace_store, category_manager, name: str) -> None: + """Read indicator metadata from disk and upsert into indicator_types workspace store.""" + read_result = category_manager.read('indicator', name) + if not read_result.get('exists') or not read_result.get('metadata'): + return + meta = read_result['metadata'] + entry = _build_indicator_type_entry(meta) + pandas_ta_name = entry['pandas_ta_name'] + + # Preserve original created_at if already present + existing = workspace_store.read('indicator_types') + existing_types = (existing.get('data') or {}).get('types') or {} + if pandas_ta_name in existing_types: + entry['created_at'] = existing_types[pandas_ta_name].get('created_at', entry['created_at']) + + workspace_store.patch('indicator_types', [ + {'op': 'add', 'path': f'/types/{pandas_ta_name}', 'value': entry} + ]) + logging.info(f"Upserted indicator_types/{pandas_ta_name} for '{name}'") + + +def _populate_indicator_types_from_disk(workspace_store, category_manager) -> None: + """Scan existing indicators and add any missing entries to indicator_types store.""" + existing = workspace_store.read('indicator_types') + existing_types = (existing.get('data') or {}).get('types') or {} + + list_result = category_manager.list_items('indicator') + items = list_result.get('items', []) + added = 0 + for item in items: + item_name = item.get('name', '') + if not item_name: + continue + pandas_ta_name = f"custom_{sanitize_name(item_name).lower()}" + if pandas_ta_name not in existing_types: + _upsert_indicator_type(workspace_store, category_manager, item_name) + added += 1 + + if added > 0: + logging.info(f"Populated {added} indicator type(s) from disk into indicator_types store") + + # ============================================================================= # Configuration # ============================================================================= @@ -156,6 +226,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server category_manager = get_category_manager(config.data_dir) logging.info(f"Category manager initialized at {config.data_dir}") + # Populate indicator_types store from existing indicators on disk (migration/startup sync) + _populate_indicator_types_from_disk(workspace_store, category_manager) + @server.list_resources() async def list_resources(): """List available resources""" @@ -674,6 +747,8 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server logging.info(f"python_write '{arguments.get('name')}': returning {len(content)} items, {image_count} images") else: logging.info(f"python_write '{arguments.get('name')}': no execution result (category={arguments.get('category')})") + if result.get("success") and arguments.get("category") == "indicator": + _upsert_indicator_type(workspace_store, category_manager, arguments.get("name", "")) return content elif name == "python_edit": result = category_manager.edit( @@ -698,6 +773,8 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server logging.info(f"python_edit '{arguments.get('name')}': returning {len(content)} items, {image_count} images") else: logging.info(f"python_edit '{arguments.get('name')}': no execution result") + if result.get("success") and arguments.get("category") == "indicator": + _upsert_indicator_type(workspace_store, category_manager, arguments.get("name", "")) return content elif name == "python_read": return category_manager.read( diff --git a/web/src/App.vue b/web/src/App.vue index 50accf15..d2a750f4 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -8,6 +8,7 @@ import LoginScreen from './components/LoginScreen.vue' import { useChartStore } from './stores/chart' import { useShapeStore } from './stores/shapes' import { useIndicatorStore } from './stores/indicators' +import { useIndicatorTypesStore } from './stores/indicatorTypes' import { useChannelStore } from './stores/channel' import { useStateSync } from './composables/useStateSync' import { wsManager } from './composables/useWebSocket' @@ -93,11 +94,13 @@ const initializeApp = async () => { const chartStore = useChartStore() const shapeStore = useShapeStore() const indicatorStore = useIndicatorStore() + const indicatorTypesStore = useIndicatorTypesStore() const channelStore = useChannelStore() const stateSync = useStateSync({ chartState: chartStore, shapes: shapeStore, indicators: indicatorStore, + indicator_types: indicatorTypesStore, channelState: channelStore }) stateSyncCleanup = stateSync.cleanup diff --git a/web/src/components/ChartView.vue b/web/src/components/ChartView.vue index 8a5f1005..a3997f19 100644 --- a/web/src/components/ChartView.vue +++ b/web/src/components/ChartView.vue @@ -6,6 +6,7 @@ import { useTradingViewShapes } from '../composables/useTradingViewShapes' import { useTradingViewIndicators } from '../composables/useTradingViewIndicators' import { useCustomIndicators, getCustomIndicatorsGetter } from '../composables/useCustomIndicators' import { useChartStore } from '../stores/chart' +import { useIndicatorTypesStore } from '../stores/indicatorTypes' import type { IChartingLibraryWidget } from '../types/tradingview' import { intervalToSeconds } from '../utils' import { wsManager } from '../composables/useWebSocket' @@ -51,6 +52,9 @@ function initChart() { try { datafeed = createTradingViewDatafeed() + const indicatorTypesStore = useIndicatorTypesStore() + console.log('[ChartView] indicator_types at widget init:', Object.keys(indicatorTypesStore.types)) + tvWidget = new window.TradingView.widget({ symbol: chartStore.symbol, // Use symbol from store datafeed: datafeed, @@ -58,104 +62,16 @@ function initChart() { container: chartContainer.value!, library_path: '/charting_library/', locale: 'en', - // Register the two generic custom study dispatch types. + // Register named studies (one per custom indicator type) plus generic fallbacks. // Must be provided here — TV has no dynamic study registration API. - custom_indicators_getter: getCustomIndicatorsGetter(), + // indicatorTypesStore is populated before widget creation (session ready guard). + custom_indicators_getter: getCustomIndicatorsGetter(() => indicatorTypesStore.types), disabled_features: [ 'use_localstorage_for_settings', 'header_symbol_search', 'symbol_search_hot_key' ], enabled_features: [], - // Restrict indicators to only those supported by both TA-Lib and TradingView. - // Custom AI-generated indicators (from custom_indicators_getter) must also be listed here. - studies_access: { - type: 'white', - tools: [ - // AI custom indicator dispatch studies - { name: 'dxo_customstudy_overlay' }, - { name: 'dxo_customstudy_pane' }, - // Overlap Studies (14) - { name: 'Moving Average' }, - { name: 'Moving Average Exponential' }, - { name: 'Weighted Moving Average' }, - { name: 'DEMA' }, - { name: 'TEMA' }, - { name: 'Triangular Moving Average' }, - { name: 'KAMA' }, - { name: 'MESA Adaptive Moving Average' }, - { name: 'T3' }, - { name: 'Bollinger Bands' }, - { name: 'Midpoint' }, - { name: 'Midprice' }, - { name: 'Parabolic SAR' }, - { name: 'Hilbert Transform - Instantaneous Trendline' }, - // Momentum Indicators (21) - { name: 'Relative Strength Index' }, - { name: 'Momentum' }, - { name: 'Rate of Change' }, - { name: 'TRIX' }, - { name: 'Chande Momentum Oscillator' }, - { name: 'Directional Movement Index' }, - { name: 'Average Directional Movement Index' }, - { name: 'Average Directional Movement Index Rating' }, - { name: 'Absolute Price Oscillator' }, - { name: 'Percentage Price Oscillator' }, - { name: 'MACD' }, - { name: 'Money Flow Index' }, - { name: 'Stochastic' }, - { name: 'Stochastic Fast' }, - { name: 'Stochastic RSI' }, - { name: 'Williams %R' }, - { name: 'Commodity Channel Index' }, - { name: 'Aroon' }, - { name: 'Aroon Oscillator' }, - { name: 'Balance Of Power' }, - { name: 'Ultimate Oscillator' }, - // Volume Indicators (3) - { name: 'Chaikin A/D Line' }, - { name: 'Chaikin A/D Oscillator' }, - { name: 'On Balance Volume' }, - // Volatility Indicators (3) - { name: 'Average True Range' }, - { name: 'Normalized Average True Range' }, - { name: 'True Range' }, - // Price Transform (4) - { name: 'Average Price' }, - { name: 'Median Price' }, - { name: 'Typical Price' }, - { name: 'Weighted Close Price' }, - // Cycle Indicators (5) - { name: 'Hilbert Transform - Dominant Cycle Period' }, - { name: 'Hilbert Transform - Dominant Cycle Phase' }, - { name: 'Hilbert Transform - Phasor Components' }, - { name: 'Hilbert Transform - SineWave' }, - { name: 'Hilbert Transform - Trend vs Cycle Mode' }, - // Statistic Functions (9) - { name: 'Beta' }, - { name: 'Pearson\'s Correlation Coefficient' }, - { name: 'Linear Regression' }, - { name: 'Linear Regression Angle' }, - { name: 'Linear Regression Intercept' }, - { name: 'Linear Regression Slope' }, - { name: 'Standard Deviation' }, - { name: 'Time Series Forecast' }, - { name: 'Variance' }, - // Custom Indicators (12) - { name: 'VWAP' }, - { name: 'VWMA' }, - { name: 'Hull Moving Average' }, - { name: 'SuperTrend' }, - { name: 'Donchian Channels' }, - { name: 'Keltner Channels' }, - { name: 'Chaikin Money Flow' }, - { name: 'Vortex Indicator' }, - { name: 'Awesome Oscillator' }, - { name: 'Accelerator Oscillator' }, - { name: 'Choppiness Index' }, - { name: 'Mass Index' } - ] - }, fullscreen: false, autosize: true, theme: 'Dark', diff --git a/web/src/components/ChatPanel.vue b/web/src/components/ChatPanel.vue index c59863ef..b623ceb2 100644 --- a/web/src/components/ChatPanel.vue +++ b/web/src/components/ChatPanel.vue @@ -326,6 +326,11 @@ const sendMessage = async (event: any) => { // Show typing indicator immediately (before first chunk arrives) isAgentProcessing.value = true + // Add thinking bubble in a macrotask so it runs in the same execution context + // as the WebSocket handler (where tool bubbles work). nextTick / sync both fail + // because vue-advanced-chat processes the send-message event asynchronously. + setTimeout(() => addToolCallBubble('Thinking...'), 0) + // Mark as distributed (single checkmark) after confirming WS send setTimeout(() => { const msgIndex = messages.value.findIndex(m => m._id === messageId) diff --git a/web/src/composables/useCustomIndicators.ts b/web/src/composables/useCustomIndicators.ts index 523f6a84..4080a0ce 100644 --- a/web/src/composables/useCustomIndicators.ts +++ b/web/src/composables/useCustomIndicators.ts @@ -8,28 +8,37 @@ * dynamic registration API (createCustomStudy does not exist on the widget * or chart APIs). * - * To support custom indicators that arrive at runtime (e.g. from the AI - * agent), we pre-register two generic dispatch studies in - * `custom_indicators_getter`: + * Study types are registered in two tiers: * - * dxo_customstudy_overlay — is_price_study: true (drawn on price pane) - * dxo_customstudy_pane — is_price_study: false (separate pane) + * 1. Named studies — one per custom indicator type stored in indicator_types: + * dxo_ind_custom_my_indicator + * These appear in TV's indicator search with their human-readable display + * names so users can discover and add them from the UI. * - * Each has a single text input `_cfg` (a config key) and MAX_PLOTS - * line plots. The constructor dispatches to `customStudyRegistry[cfgKey]` - * to look up the per-indicator configuration and data. + * 2. Generic dispatch studies — backward-compatibility fallbacks: + * dxo_customstudy_overlay (is_price_study: true) + * dxo_customstudy_pane (is_price_study: false) + * Used when a named study type is not yet registered (e.g. race between + * widget init and indicator_types store population). + * + * Each named study has a single text input `_cfg` (a config key) and MAX_PLOTS + * line plots. The constructor dispatches to `customStudyRegistry[cfgKey]` to + * look up the per-indicator configuration and data. * * These study type names MUST also appear in the `studies_access` whitelist * in ChartView.vue — TV treats unlisted studies as nonexistent. * * Registration flow * ----------------- - * 1. Widget constructor calls getCustomIndicatorsGetter() which registers - * the two generic study types. - * 2. When a custom_ indicator appears in the store, registerCustomStudy(): - * a. Stores the config in customStudyRegistry under a unique cfgKey. - * b. Calls chart.createStudy('dxo_customstudy_*', ..., { _cfg: cfgKey }). - * c. Calls study.setStudyTitle(indicator name) for a human-readable header. + * 1. Widget constructor calls getCustomIndicatorsGetter(getTypes) which registers + * named studies (one per type) plus the two generic fallback studies. + * 2a. Agent path: when a custom_ indicator appears in the indicators store, + * registerCustomStudy() calls chart.createStudy('dxo_ind_*', ..., {_cfg: cfgKey}). + * 2b. Picker path: user selects a named study from TV's search; TV creates it with + * empty _cfg; convertTVStudyToIndicator (useTradingViewIndicators) converts + * the dxo_ind_* event into an IndicatorInstance and adds it to the store; + * registerCustomStudy() then *adopts* the existing study by setting its _cfg + * input (no duplicate createStudy call). * 3. TV calls the study's init(ctx, inputs): * a. Reads symbol/period from ctx; builds the data cache key. * b. Fires an async evaluateIndicator WebSocket request. @@ -41,8 +50,9 @@ * `custom_indicators_getter` option when creating the TradingView widget. */ -import { watch } from 'vue' +import { watch, ref } from 'vue' import { useIndicatorStore, type IndicatorInstance, type CustomIndicatorMetadata } from '../stores/indicators' +import { useIndicatorTypesStore, type CustomIndicatorType } from '../stores/indicatorTypes' import { useChartStore } from '../stores/chart' import { wsManager, type MessageHandler } from './useWebSocket' import { intervalToSeconds } from '../utils' @@ -141,6 +151,10 @@ const customStudyRegistry = new Map() // Called by the constructor when async data arrives to trigger TV re-run. const refreshCallbacks = new Map void>() +// indicatorId → { fromTime, toTime } of the last evaluate_indicator request. +// Used to detect when the visible range has expanded beyond the fetched range. +const fetchedRanges = new Map() + // TradingView widget reference — set by useCustomIndicators() so the // constructor can query the current visible range. let _tvWidget: any = null @@ -156,144 +170,289 @@ const MULTI_LINE_COLORS = [ '#00BCD4', '#FF5722', '#795548', ] +// --------------------------------------------------------------------------- +// Study name helpers +// --------------------------------------------------------------------------- + +/** TV study type name for a given pandas_ta_name. */ +function tvStudyName(pandasTaName: string): string { + return `dxo_ind_${pandasTaName}` +} + +/** Build the constructor function. nOutputs controls how many values main() returns. */ +function makeStudyConstructor(nOutputs: number = MAX_PLOTS) { + return function (this: any) { + let _cfgKey = '' + let _dataKey = '' + let _fetchGen = 0 + + this.init = function (ctx: any, inputs: (i: number) => any) { + const cfgKey = inputs(0) as string + _cfgKey = cfgKey + _fetchGen++ + const myGen = _fetchGen + + const entry = customStudyRegistry.get(cfgKey) + if (!entry) return + + const symbol: string = ctx.symbol.ticker + const periodStr: string = ctx.symbol.period + const periodSeconds = intervalToSeconds(periodStr) + const paramsHash = JSON.stringify(entry.parameters) + const dk = cacheKey(entry.indicatorId, symbol, periodSeconds, paramsHash) + _dataKey = dk + + if (dataCache.has(dk)) return + + let fromTime: number + let toTime: number + const now = Math.floor(Date.now() / 1000) + toTime = now + fromTime = now - periodSeconds * 500 + if (_tvWidget) { + try { + const range = _tvWidget.activeChart().getVisibleRange() + if (range?.from && range?.to) { + const dur = Math.floor(range.to) - Math.floor(range.from) + fromTime = Math.floor(range.from) - Math.floor(dur * 0.5) + toTime = Math.floor(range.to) + } + } catch { /* chart not yet ready */ } + } + + const capturedDk = dk + const capturedCfgKey = cfgKey + + // 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) + .then((result) => { + if (myGen !== _fetchGen) return + dataCache.set(capturedDk, buildDataCache(result)) + const refreshKey = `${capturedCfgKey}__r` + customStudyRegistry.set(refreshKey, entry) + const cb = refreshCallbacks.get(entry.indicatorId) + if (cb) cb(refreshKey) + }) + .catch((err) => { + console.error('[CustomIndicators] Failed to fetch data for', entry.pandasTaName, err) + fetchedRanges.delete(entry.indicatorId) + }) + } + + this.main = function (ctx: any, _inputs: (i: number) => any) { + const ts: number = ctx.symbol.bartime() + if (!_cfgKey || !_dataKey) return new Array(nOutputs).fill(NaN) + const entry = customStudyRegistry.get(_cfgKey) + if (!entry) return new Array(nOutputs).fill(NaN) + const cache = dataCache.get(_dataKey) + if (!cache) return new Array(nOutputs).fill(NaN) + const row = cache.get(ts) + return Array.from({ length: nOutputs }, (_, i) => { + const col = entry.metadata.output_columns[i] + return col && row ? (row[col.name] as number) ?? NaN : NaN + }) + } + } +} + +// --------------------------------------------------------------------------- +// Study definition builders +// --------------------------------------------------------------------------- + +function makeStudyMetainfo(name: string, description: string, isPriceStudy: boolean): any { + const plots = Array.from({ length: MAX_PLOTS }, (_, i) => ({ id: `plot_${i}`, type: 'line' })) + const styles: Record = {} + const defaultStyles: Record = {} + for (let i = 0; i < MAX_PLOTS; i++) { + styles[`plot_${i}`] = { title: `Plot ${i}` } + defaultStyles[`plot_${i}`] = { + linestyle: 0, + linewidth: 1, + plottype: 0, + color: MULTI_LINE_COLORS[i % MULTI_LINE_COLORS.length], + visible: i === 0, + } + } + + return { + _metainfoVersion: 53, + id: `${name}@tv-basicstudies-1`, + scriptIdPart: '', + name, + description, + shortDescription: description, + is_hidden_study: false, + is_price_study: isPriceStudy, + isCustomIndicator: true, + format: { type: 'inherit' }, + inputs: [ + { id: '_cfg', name: 'Config Key', type: 'text', defval: '' }, + ], + plots, + styles, + defaults: { + inputs: { _cfg: '' }, + styles: defaultStyles, + }, + } +} + +function tvInputType(paramType: string): string { + switch (paramType) { + case 'int': return 'integer' + case 'float': return 'float' + case 'bool': return 'bool' + default: return 'text' + } +} + +/** Create metainfo for a named study with real parameter inputs and exact output count. */ +function makeNamedStudyMetainfo(name: string, type: CustomIndicatorType): any { + const meta = type.metadata + const nOutputs = meta.output_columns.length + const isPriceStudy = meta.pane === 'price' + + const plots = Array.from({ length: nOutputs }, (_, i) => ({ id: `plot_${i}`, type: 'line' })) + const styles: Record = {} + const defaultStyles: Record = {} + for (let i = 0; i < nOutputs; i++) { + const col = meta.output_columns[i] + styles[`plot_${i}`] = { title: col.display_name || col.name } + defaultStyles[`plot_${i}`] = { + linestyle: 0, + linewidth: col.plot?.linewidth ?? 1, + plottype: col.plot?.style ?? 0, + color: col.plot?.color ?? MULTI_LINE_COLORS[i % MULTI_LINE_COLORS.length], + visible: col.plot?.visible ?? true, + } + } + + // _cfg is the internal dispatch key — hidden from the user + const inputs: any[] = [ + { id: '_cfg', name: 'Config Key', type: 'text', defval: '', isHidden: true }, + ] + const defaultInputs: Record = { _cfg: '' } + + for (const [paramName, param] of Object.entries(meta.parameters)) { + const input: any = { + id: `param_${paramName}`, + name: paramName, + type: tvInputType(param.type), + defval: param.default, + } + if (param.min !== undefined) input.min = param.min + if (param.max !== undefined) input.max = param.max + if (param.description) input.tooltip = param.description + inputs.push(input) + defaultInputs[`param_${paramName}`] = param.default + } + + return { + _metainfoVersion: 53, + id: `${name}@tv-basicstudies-1`, + scriptIdPart: '', + name, + description: meta.display_name, + shortDescription: meta.display_name, + is_hidden_study: false, + is_price_study: isPriceStudy, + isCustomIndicator: true, + format: { type: 'inherit' }, + inputs, + plots, + styles, + defaults: { + inputs: defaultInputs, + styles: defaultStyles, + }, + } +} + +/** Create a generic fallback dispatch study (overlay or separate pane). Hidden from picker. */ +function makeGenericStudy(name: string, isPriceStudy: boolean): any { + const metainfo = makeStudyMetainfo(name, name, isPriceStudy) + metainfo.is_hidden_study = true + return { + name, + metainfo, + constructor: makeStudyConstructor(), + } +} + +/** Create a named study for a specific custom indicator type. */ +function makeNamedStudy(type: CustomIndicatorType): any { + const name = tvStudyName(type.pandas_ta_name) + const nOutputs = type.metadata.output_columns.length + return { + name, + metainfo: makeNamedStudyMetainfo(name, type), + constructor: makeStudyConstructor(nOutputs), + } +} + // --------------------------------------------------------------------------- // Custom indicators getter // Pass the result of this function as the widget option: -// custom_indicators_getter: getCustomIndicatorsGetter() +// custom_indicators_getter: getCustomIndicatorsGetter(getTypes) // // The study type names must also be listed in studies_access in ChartView.vue. // --------------------------------------------------------------------------- -export function getCustomIndicatorsGetter(): (_PineJS: any) => Promise { - function makeGenericStudy(name: string, isPriceStudy: boolean): any { - const plots = Array.from({ length: MAX_PLOTS }, (_, i) => ({ id: `plot_${i}`, type: 'line' })) - const styles: Record = {} - const defaultStyles: Record = {} - for (let i = 0; i < MAX_PLOTS; i++) { - styles[`plot_${i}`] = { title: `Plot ${i}` } - defaultStyles[`plot_${i}`] = { - linestyle: 0, - linewidth: 1, - plottype: 0, - color: MULTI_LINE_COLORS[i % MULTI_LINE_COLORS.length], - visible: i === 0, - } - } - - return { - name, - metainfo: { - _metainfoVersion: 51, - // Use @tv-custom-1 — @tv-basicstudies-1 is reserved for built-ins - // and TV throws "unexpected study id" if a custom indicator uses it. - id: `${name}@tv-custom-1`, - scriptIdPart: '', - name, - description: name, - shortDescription: name, - is_price_study: isPriceStudy, - isCustomIndicator: true, - format: { type: 'inherit' }, - // Single text input carries the per-instance config key. - inputs: [ - { id: '_cfg', name: 'Config Key', type: 'text', defval: '' }, - ], - plots, - styles, - defaults: { - inputs: { _cfg: '' }, - styles: defaultStyles, +/** Static test study — a plain close-price line to verify custom_indicators_getter works. */ +function makeTestStudy(PineJS: any): any { + return { + name: 'dxo_test_close', + metainfo: { + _metainfoVersion: 53, + id: 'dxo_test_close@tv-custom-1', + scriptIdPart: '', + name: 'dxo_test_close', + description: 'DXO Test (close price)', + shortDescription: 'DXO Test', + is_hidden_study: false, + is_price_study: true, + isCustomIndicator: true, + linkedToSeries: true, + format: { type: 'inherit' }, + inputs: [], + plots: [{ id: 'plot_0', type: 'line' }], + styles: { plot_0: { title: 'Close', joinPoints: false } }, + defaults: { + inputs: {}, + styles: { + plot_0: { linestyle: 0, linewidth: 2, color: '#FF0000', transparency: 0, visible: true }, }, }, - // ES5 constructor — TV instantiates this with `new` - constructor: function (this: any) { - // Per-instance mutable state stored on the constructor instance - let _cfgKey = '' // current config key (from inputs(0)) - let _dataKey = '' // data cache key (built from ctx symbol/period/params) - let _fetchGen = 0 // incremented each init(); used to cancel stale fetches - - this.init = function (ctx: any, inputs: (i: number) => any) { - const cfgKey = inputs(0) as string - _cfgKey = cfgKey - _fetchGen++ - const myGen = _fetchGen - - const entry = customStudyRegistry.get(cfgKey) - if (!entry) return - - // Derive symbol and period from the TV context object. - // ctx.symbol.ticker — symbol name without exchange prefix - // ctx.symbol.period — TV interval string ("15", "1D", etc.) - const symbol: string = ctx.symbol.ticker - const periodStr: string = ctx.symbol.period - const periodSeconds = intervalToSeconds(periodStr) - const paramsHash = JSON.stringify(entry.parameters) - const dk = cacheKey(entry.indicatorId, symbol, periodSeconds, paramsHash) - _dataKey = dk - - if (dataCache.has(dk)) return // Data already fetched for this symbol/period/params - - // Determine time range: prefer chart's visible range, fall back to 500-bar window - let fromTime: number - let toTime: number - const now = Math.floor(Date.now() / 1000) - toTime = now - fromTime = now - periodSeconds * 500 - if (_tvWidget) { - try { - const range = _tvWidget.activeChart().getVisibleRange() - if (range?.from && range?.to) { - const dur = Math.floor(range.to) - Math.floor(range.from) - fromTime = Math.floor(range.from) - Math.floor(dur * 0.5) - toTime = Math.floor(range.to) - } - } catch { /* chart not yet ready */ } - } - - // Capture mutable vars before async gap - const capturedDk = dk - const capturedCfgKey = cfgKey - - evaluateIndicator(symbol, fromTime, toTime, periodSeconds, entry.pandasTaName, entry.parameters) - .then((result) => { - if (myGen !== _fetchGen) return // Superseded by a newer init() call - dataCache.set(capturedDk, buildDataCache(result)) - // Create a sibling config key pointing to the same entry. - // Calling setInputValues() with this new key causes TV to - // re-invoke init()+main() with the now-populated cache. - const refreshKey = `${capturedCfgKey}__r` - customStudyRegistry.set(refreshKey, entry) - const cb = refreshCallbacks.get(entry.indicatorId) - if (cb) cb(refreshKey) - }) - .catch((err) => { - console.error('[CustomIndicators] Failed to fetch data for', entry.pandasTaName, err) - }) - } - - this.main = function (ctx: any, _inputs: (i: number) => any) { - // ctx.symbol.bartime() returns the bar timestamp in milliseconds (documented) - const ts: number = ctx.symbol.bartime() - if (!_cfgKey || !_dataKey) return new Array(MAX_PLOTS).fill(NaN) - const entry = customStudyRegistry.get(_cfgKey) - if (!entry) return new Array(MAX_PLOTS).fill(NaN) - const cache = dataCache.get(_dataKey) - if (!cache) return new Array(MAX_PLOTS).fill(NaN) - const row = cache.get(ts) - return Array.from({ length: MAX_PLOTS }, (_, i) => { - const col = entry.metadata.output_columns[i] - return col && row ? (row[col.name] as number) ?? NaN : NaN - }) - } - }, - } + }, + constructor: function (this: any) { + this.main = function (ctx: any, _inputs: any) { + this._context = ctx + return [PineJS.Std.close(ctx)] + } + }, } +} - return (_PineJS: any): Promise => { - return Promise.resolve([ +export function getCustomIndicatorsGetter( + getTypes: () => Record +): (_PineJS: any) => Promise { + return (PineJS: any): Promise => { + const types = getTypes() + const typeKeys = Object.keys(types) + console.log('[CustomIndicators] custom_indicators_getter called, types in store:', typeKeys) + + const namedStudies = Object.values(types).map(makeNamedStudy) + const testStudy = makeTestStudy(PineJS) + const studies = [ makeGenericStudy('dxo_customstudy_overlay', true), makeGenericStudy('dxo_customstudy_pane', false), - ]) + testStudy, + ...namedStudies, + ] + console.log('[CustomIndicators] Registering studies:', studies.map((s) => s.name)) + return Promise.resolve(studies) } } @@ -305,14 +464,16 @@ export function useCustomIndicators(tvWidget: any) { _tvWidget = tvWidget const indicatorStore = useIndicatorStore() + const indicatorTypesStore = useIndicatorTypesStore() const chartStore = useChartStore() // Maps indicator id → { cfgKey, tvStudyId, symbol } const registered = new Map() + // Tracks indicator IDs whose registerCustomStudy() is in-flight (createStudy not yet resolved). + const pendingRegistration = new Set() // Monotonic version counter per indicator for unique config keys const cfgVersions = new Map() // Last-seen parameter hash per indicator id for change detection. - // Needed because Pinia $patch mutates in place (oldValue === newValue). const lastParams = new Map() let isChartReady = false @@ -323,8 +484,6 @@ export function useCustomIndicators(tvWidget: any) { return `cfg_${indicatorId.replace(/[^a-zA-Z0-9]/g, '_')}_v${v}` } - // Apply per-indicator visual overrides after createStudy() returns. - // Uses per-column plot config (style, color, linewidth, visible) from metadata. function applyStudyOverrides(studyId: string, meta: CustomIndicatorMetadata) { try { const study = tvWidget.activeChart().getStudyById(studyId) @@ -346,17 +505,35 @@ export function useCustomIndicators(tvWidget: any) { overrides[`styles.plot_${i}.color`] = p?.color ?? MULTI_LINE_COLORS[i % MULTI_LINE_COLORS.length] } - // Note: TV band `value` is fixed at metainfo-declaration time and cannot be changed - // via overrides. Indicators that need horizontal reference lines at configurable - // values (e.g. RSI at 70/30) should instead include a constant-value output column - // rather than relying on meta.bands. - study.applyOverrides(overrides) } catch (err) { console.warn('[CustomIndicators] Could not apply overrides:', err) } } + function applyStudyTitle(studyId: string, meta: CustomIndicatorMetadata) { + try { + const study = tvWidget.activeChart().getStudyById(studyId) + if (!study) return + const displayName = meta.display_name + if (typeof study.setStudyTitle === 'function') { + study.setStudyTitle(displayName) + } + } catch { /* setStudyTitle not available in this TV build */ } + } + + // ------------------------------------------------------------------ + // Resolve the study type name to use when creating a new TV study + // ------------------------------------------------------------------ + function resolveStudyTypeName(pandasTaName: string, pane: string): string { + // TV's createStudy() matches by the `description` field in metainfo, not the internal `name`. + // Named studies have description = display_name (e.g. "TrendFlex"), not "dxo_ind_*". + const typeEntry = indicatorTypesStore.types[pandasTaName] + if (typeEntry) return typeEntry.metadata.display_name + // Generic fallbacks have name === description, so either works. + return pane === 'price' ? 'dxo_customstudy_overlay' : 'dxo_customstudy_pane' + } + // ------------------------------------------------------------------ // Register a custom indicator as a TV study instance // ------------------------------------------------------------------ @@ -367,10 +544,11 @@ export function useCustomIndicators(tvWidget: any) { return } + if (pendingRegistration.has(indicator.id)) return + pendingRegistration.add(indicator.id) + const symbol = indicator.symbol || chartStore.symbol const cfgKey = nextCfgKey(indicator.id) - const forceOverlay = meta.pane === 'price' - const studyTypeName = meta.pane === 'price' ? 'dxo_customstudy_overlay' : 'dxo_customstudy_pane' // Store per-instance config in the registry so the constructor can find it customStudyRegistry.set(cfgKey, { @@ -381,8 +559,6 @@ export function useCustomIndicators(tvWidget: any) { }) // Register the callback invoked by the constructor after async data loads. - // We change the study's _cfg input to a sibling key, which causes TV to - // re-run init()+main() and pick up the freshly populated cache. refreshCallbacks.set(indicator.id, (newCfgKey: string) => { const entry = registered.get(indicator.id) if (!entry?.tvStudyId) return @@ -398,33 +574,67 @@ export function useCustomIndicators(tvWidget: any) { }) try { - const tvStudyId = (await tvWidget.activeChart().createStudy( - studyTypeName, forceOverlay, false, - { _cfg: cfgKey } - )) as string | null + // Check if the study already exists on the chart (picker-added path): + // when a user picks a named study from TV's indicator search, TV creates + // it with empty _cfg and fires study_event. useTradingViewIndicators converts + // that to an IndicatorInstance with tv_study_id set. We must adopt the + // existing study rather than create a duplicate. + let tvStudyId: string | null = null + let adopted = false + + if (indicator.tv_study_id) { + try { + const existingStudy = tvWidget.activeChart().getStudyById(indicator.tv_study_id) + if (existingStudy) { + tvStudyId = indicator.tv_study_id + adopted = true + } + } catch { /* study not on chart */ } + } + + if (adopted && tvStudyId) { + // Picker path: adopt the existing study by setting its _cfg + param inputs. + const study = tvWidget.activeChart().getStudyById(tvStudyId) + if (study) { + const inputValues: Array<{ id: string; value: any }> = [{ id: '_cfg', value: cfgKey }] + for (const [paramName, value] of Object.entries(indicator.parameters)) { + inputValues.push({ id: `param_${paramName}`, value }) + } + study.setInputValues(inputValues) + } + console.log('[CustomIndicators] Adopted picker-added study:', indicator.pandas_ta_name, '(', tvStudyId, ')') + } else { + // Agent path: create a new study on the chart. + const forceOverlay = meta.pane === 'price' + const studyTypeName = resolveStudyTypeName(indicator.pandas_ta_name, meta.pane) + + const inputsDict: Record = { _cfg: cfgKey } + for (const [paramName, value] of Object.entries(indicator.parameters)) { + inputsDict[`param_${paramName}`] = value + } + + tvStudyId = (await tvWidget.activeChart().createStudy( + studyTypeName, forceOverlay, false, + inputsDict + )) as string | null + + console.log('[CustomIndicators] Created study:', indicator.pandas_ta_name, '→', studyTypeName, '(', tvStudyId, ')') + } registered.set(indicator.id, { cfgKey, tvStudyId: tvStudyId ?? null, symbol }) lastParams.set(indicator.id, JSON.stringify(indicator.parameters)) if (tvStudyId) { - // Set human-readable panel title (falls back to pandas_ta_name if no display name) - const displayName = meta.display_name || indicator.pandas_ta_name.replace(/^custom_/, '') - try { - const study = tvWidget.activeChart().getStudyById(tvStudyId) - if (study && typeof study.setStudyTitle === 'function') { - study.setStudyTitle(displayName) - } - } catch { /* setStudyTitle not available in this TV build */ } - + applyStudyTitle(tvStudyId, meta) applyStudyOverrides(tvStudyId, meta) if (tvStudyId !== indicator.tv_study_id) { indicatorStore.updateIndicator(indicator.id, { tv_study_id: tvStudyId }) } } - - console.log('[CustomIndicators] Registered:', indicator.pandas_ta_name, '→', studyTypeName, '(', tvStudyId, ')') } catch (err) { - console.error('[CustomIndicators] Failed to create TV custom study:', studyTypeName, err) + console.error('[CustomIndicators] Failed to register TV custom study:', indicator.pandas_ta_name, err) + } finally { + pendingRegistration.delete(indicator.id) } } @@ -437,7 +647,9 @@ export function useCustomIndicators(tvWidget: any) { registered.delete(indicatorId) lastParams.delete(indicatorId) refreshCallbacks.delete(indicatorId) + fetchedRanges.delete(indicatorId) + pendingRegistration.delete(indicatorId) if (entry.tvStudyId) { try { tvWidget.activeChart().removeStudy(entry.tvStudyId) } catch { /* already gone */ } } @@ -447,7 +659,6 @@ export function useCustomIndicators(tvWidget: any) { // Re-register when parameters/symbol/period change (forces new data fetch) // ------------------------------------------------------------------ async function refreshCustomStudy(indicator: IndicatorInstance) { - // Purge stale cache entries so init() fetches fresh data for (const key of Array.from(dataCache.keys())) { if (key.startsWith(`${indicator.id}_`)) { dataCache.delete(key) @@ -458,20 +669,36 @@ export function useCustomIndicators(tvWidget: any) { } // ------------------------------------------------------------------ - // Store watcher — respond to indicator additions, changes, removals - // - // NOTE: Pinia $patch mutates in place, so oldValue === newValue for - // backend-originated updates. We track state manually via lastParams. + // Chart ready — set isChartReady and track when TV data has loaded + // ------------------------------------------------------------------ + isChartReady = true + + // Reactive flag: true once TV has loaded chart data (earliest safe point to create studies). + // Using ref() so it can be a watched source alongside indicatorStore.indicators. + const isDataLoaded = ref(false) + + tvWidget.activeChart().onDataLoaded().subscribe(null, () => { + if (isDataLoaded.value) return + console.log('[CustomIndicators] onDataLoaded fired') + isDataLoaded.value = true + }) + + // ------------------------------------------------------------------ + // Combined watcher: fires when data loads OR store changes. + // immediate:true handles the case where indicators are already in the + // store before this composable is called. // ------------------------------------------------------------------ watch( - () => indicatorStore.indicators, - async (newIndicators) => { - if (!isChartReady) return + [isDataLoaded, () => indicatorStore.indicators], + async ([loaded, newIndicators]) => { + if (!isChartReady || !loaded) return - for (const [id, indicator] of Object.entries(newIndicators)) { + const indicators = newIndicators as Record> + + for (const [id, indicator] of Object.entries(indicators)) { if (!indicator.pandas_ta_name.startsWith('custom_')) continue - if (!registered.has(id)) { + if (!registered.has(id) && !pendingRegistration.has(id)) { lastParams.set(id, JSON.stringify(indicator.parameters)) await registerCustomStudy(indicator) } else { @@ -488,13 +715,13 @@ export function useCustomIndicators(tvWidget: any) { // Handle removals for (const id of registered.keys()) { - if (!(id in newIndicators)) { + if (!(id in indicators)) { lastParams.delete(id) removeCustomStudy(id) } } }, - { deep: true } + { immediate: true, deep: true } ) // Re-fetch when chart resolution changes @@ -512,37 +739,42 @@ export function useCustomIndicators(tvWidget: any) { } ) - // ------------------------------------------------------------------ - // Chart ready — apply any indicators already in the store - // ------------------------------------------------------------------ - // useCustomIndicators is always called from within tvWidget.onChartReady in ChartView, - // so the chart is already ready. - isChartReady = true - - // TV processes custom_indicators_getter asynchronously (Promise microtask), so the - // custom study types are not yet available at onChartReady time. Defer the initial - // registration of any pending indicators until chart data loads — by that point the - // getter Promise has resolved and the study types are registered in TV's internal - // study index (and the studies_access whitelist check passes). - let initialApplied = false - tvWidget.activeChart().onDataLoaded().subscribe(null, () => { - if (initialApplied) return - initialApplied = true - const pending = Object.values(indicatorStore.indicators).filter( - (ind) => ind.pandas_ta_name.startsWith('custom_') && !registered.has(ind.id) - ) - for (const indicator of pending) { - lastParams.set(indicator.id, JSON.stringify(indicator.parameters)) - registerCustomStudy(indicator) + // Re-fetch when visible range expands beyond the fetched range (e.g. user zooms out). + // Debounced to avoid hammering on every scroll tick. + // Due to lookback effects, we always re-fetch the full new range rather than just the gap. + let rangeWatchTimer: ReturnType | null = null + watch( + [() => chartStore.start_time, () => chartStore.end_time], + ([newStart, newEnd]) => { + if (!isChartReady) return + if (rangeWatchTimer) clearTimeout(rangeWatchTimer) + rangeWatchTimer = setTimeout(() => { + rangeWatchTimer = null + for (const [id, indicator] of Object.entries(indicatorStore.indicators)) { + if (!indicator.pandas_ta_name.startsWith('custom_')) continue + if (!registered.has(id) || pendingRegistration.has(id)) continue + const range = fetchedRanges.get(id) + if (!range) continue + const startExpanded = newStart != null && newStart < range.fromTime + // Allow a 10-bar buffer on the right before triggering (live bars naturally advance) + const endExpanded = newEnd != null && newEnd > range.toTime + chartStore.period * 10 + if (startExpanded || endExpanded) { + console.log('[CustomIndicators] Visible range expanded beyond fetch range, re-fetching:', indicator.pandas_ta_name, { newStart, rangeFrom: range.fromTime }) + refreshCustomStudy(indicator) + } + } + }, 500) } - }) + ) // Cleanup return () => { + if (rangeWatchTimer) { clearTimeout(rangeWatchTimer); rangeWatchTimer = null } for (const id of [...registered.keys()]) { removeCustomStudy(id) } registered.clear() + pendingRegistration.clear() cfgVersions.clear() lastParams.clear() if (_tvWidget === tvWidget) _tvWidget = null diff --git a/web/src/composables/useTradingViewDatafeed.ts b/web/src/composables/useTradingViewDatafeed.ts index 4c94499e..bef50e55 100644 --- a/web/src/composables/useTradingViewDatafeed.ts +++ b/web/src/composables/useTradingViewDatafeed.ts @@ -40,7 +40,8 @@ interface SymbolDenominators { export class WebSocketDatafeed implements IBasicDataFeed { private pendingRequests: Map = new Map() private subscriptions: Map = new Map() - private requestTimeout = 10000 // 10 seconds + private requestTimeout = 10000 // 10 seconds (used for all requests except getBars) + private barsRequestTimeout = 60000 // 60 seconds (cold-cache pipeline may take a while) private configuration: DatafeedConfiguration | null = null private messageHandler: MessageHandler private symbolDenominators: Map = new Map() // Track denominators per symbol @@ -56,7 +57,7 @@ export class WebSocketDatafeed implements IBasicDataFeed { return `tv_${Date.now()}_${Math.random().toString(36).substring(7)}` } - private sendRequest(message: any): Promise { + private sendRequest(message: any, timeoutMs?: number): Promise { const requestId = message.request_id || this.generateRequestId() message.request_id = requestId @@ -67,7 +68,7 @@ export class WebSocketDatafeed implements IBasicDataFeed { console.error('[TradingView Datafeed] Request timeout:', requestId, message.type) this.pendingRequests.delete(requestId) reject(new Error('Request timeout')) - }, this.requestTimeout) + }, timeoutMs ?? this.requestTimeout) this.pendingRequests.set(requestId, { resolve, reject, timeout }) @@ -244,14 +245,23 @@ export class WebSocketDatafeed implements IBasicDataFeed { const symbolKey = symbolInfo.ticker || symbolInfo.name const denoms = this.symbolDenominators.get(symbolKey) || { tick: 1, base: 1 } - this.sendRequest({ + const doRequest = () => this.sendRequest({ type: 'get_bars', symbol: symbolKey, period_seconds: intervalToSeconds(resolution), from_time: periodParams.from, to_time: periodParams.to, countback: periodParams.countBack - }) + }, this.barsRequestTimeout) + + doRequest() + .catch((err: Error) => { + if (err.message === 'Request timeout') { + console.warn('[TradingView Datafeed] getBars timed out, retrying once (data may now be in cache)...') + return doRequest() + } + throw err + }) .then((response) => { if (response.history) { console.log('[TradingView Datafeed] Raw bar sample:', response.history.bars?.[0]) diff --git a/web/src/composables/useTradingViewIndicators.ts b/web/src/composables/useTradingViewIndicators.ts index f13af069..ed9da4f0 100644 --- a/web/src/composables/useTradingViewIndicators.ts +++ b/web/src/composables/useTradingViewIndicators.ts @@ -1,6 +1,7 @@ import { watch } from 'vue' import type { IChartingLibraryWidget, IStudyApi } from '../types/tradingview' import { useIndicatorStore } from '../stores/indicators' +import { useIndicatorTypesStore } from '../stores/indicatorTypes' import { useChartStore } from '../stores/chart' import type { IndicatorInstance } from '../stores/indicators' @@ -230,6 +231,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { let isApplyingTVUpdate = false // Prevent circular updates when TV modifies indicators let isUpdatingStore = false // Prevent circular updates when we update the store from TV let isChartReady = false // Track if chart API is ready + let isUnmounting = false // Set during cleanup to suppress study_event: remove from widget destruction /** * Convert TradingView study to our IndicatorInstance format @@ -280,15 +282,63 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { } } - // Final fallback to property access - if (studyName === 'Unknown') { - studyName = tvStudy._study?.name?.() || - tvStudy._metaInfo?.description || - tvStudy._metaInfo?.shortDescription || 'Unknown' + // Fallback: metaInfo.name is the registered study name (e.g. dxo_ind_custom_trendflex) + if (studyName === 'Unknown' || (!studyName.startsWith('dxo_ind_') && !TV_TO_PANDAS_TA_NAMES[studyName])) { + const metaName = tvStudy._study?.name?.() || + tvStudy._metaInfo?.name || + tvStudy.metaInfo?.()?.name + if (metaName && typeof metaName === 'string') { + console.log('[Indicators] Overriding studyName with metaInfo.name:', metaName) + studyName = metaName + } + } + + // Last resort: if still unresolved, check all type descriptions + if (!studyName.startsWith('dxo_ind_') && !TV_TO_PANDAS_TA_NAMES[studyName]) { + const indicatorTypesStore = useIndicatorTypesStore() + const title = typeof tvStudy.title === 'function' ? tvStudy.title() : null + const desc = tvStudy._metaInfo?.description || tvStudy._metaInfo?.shortDescription + // Also match against studyName itself (TV returns display name from study().name()) + const matchByDesc = Object.values(indicatorTypesStore.types).find( + t => t.display_name === studyName || t.display_name === title || t.display_name === desc + ) + if (matchByDesc) { + studyName = `dxo_ind_${matchByDesc.pandas_ta_name}` + console.log('[Indicators] Resolved studyName by description match:', studyName) + } } console.log('[Indicators] Study name extracted:', studyName) + // Handle our named custom indicator studies (dxo_ind_ prefix) + if (studyName.startsWith('dxo_ind_')) { + const pandasTaNameFromStudy = studyName.slice('dxo_ind_'.length) + const indicatorTypesStore = useIndicatorTypesStore() + const typeEntry = indicatorTypesStore.types[pandasTaNameFromStudy] + if (!typeEntry) { + console.log('[Indicators] Custom indicator type not found in store:', pandasTaNameFromStudy) + return null + } + const defaultParams = Object.fromEntries( + Object.entries(typeEntry.metadata.parameters).map(([k, v]) => [k, v.default]) + ) + const now = Math.floor(Date.now() / 1000) + return { + id: studyId || `ind_${Date.now()}`, + pandas_ta_name: pandasTaNameFromStudy, + instance_name: typeEntry.display_name, + parameters: defaultParams, + tv_study_id: studyId, + tv_indicator_name: studyName, + visible: true, + pane: typeEntry.metadata.pane, + symbol, + created_at: now, + modified_at: now, + custom_metadata: typeEntry.metadata, + } as IndicatorInstance + } + const pandasTaName = TV_TO_PANDAS_TA_NAMES[studyName] console.log('[Indicators] pandas-ta mapping:', studyName, '->', pandasTaName) @@ -429,6 +479,17 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { // Wait for TradingView to finish initializing the study setTimeout(() => { try { + // Guard against feedback loop: if we created this study from the store + // (via createTVStudy or registerCustomStudy), the store entry already has + // tv_study_id set by the time this setTimeout fires. Skip in that case. + const alreadyTracked = Object.values(indicatorStore.indicators).find( + ind => ind.tv_study_id === actualStudyId + ) + if (alreadyTracked) { + console.log('[Indicators] Study already tracked in store, skipping create event:', alreadyTracked.id) + return + } + const study = chart.getStudyById(actualStudyId) if (study) { console.log('[Indicators] Retrieved new study after timeout') @@ -452,6 +513,11 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { else if (actualEventType === 'remove') { console.log('[Indicators] Indicator removed with ID:', actualStudyId) + if (isUnmounting) { + console.log('[Indicators] Ignoring study remove during widget destruction') + return + } + const existingIndicator = Object.values(indicatorStore.indicators).find( ind => ind.tv_study_id === actualStudyId ) @@ -545,6 +611,35 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { if (existingIndicator) { console.log('[Indicators] Found existing indicator:', existingIndicator.id) + // Custom indicators expose param_* inputs that map to their parameters. + // Extract those and update the store (useCustomIndicators will re-fetch data). + if (existingIndicator.pandas_ta_name.startsWith('custom_')) { + if (typeof study.getInputValues === 'function') { + const inputsArray = study.getInputValues() + if (Array.isArray(inputsArray)) { + const newParams: Record = {} + for (const input of inputsArray) { + if (input.id && input.id.startsWith('param_')) { + newParams[input.id.slice(6)] = input.value + } + } + if (Object.keys(newParams).length > 0) { + const hasChanged = Object.entries(newParams).some( + ([k, v]) => existingIndicator.parameters[k] !== v + ) + if (hasChanged) { + console.log('[Indicators] Custom indicator params changed:', newParams) + isUpdatingStore = true + indicatorStore.updateIndicator(existingIndicator.id, { parameters: newParams }) + isUpdatingStore = false + } + } + } + } + return + } + + // Get the study name using study().name() let studyName = 'Unknown' if (typeof study.study === 'function') { @@ -596,11 +691,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { console.log('[Indicators] Parameters unchanged (might be visual properties only)') } } else { - console.log('[Indicators] No existing indicator found, doing full sync...') - // Might be a new indicator, do full sync - if (!isUpdatingStore && !isApplyingTVUpdate) { - syncIndicatorsFromTV() - } + console.log('[Indicators] No existing indicator found for properties change, ignoring') } } } catch (error) { @@ -608,35 +699,25 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { } }, 0) } else { - console.log('[Indicators] No study ID in event, doing full sync...') - // Fallback to full sync - setTimeout(() => { - if (!isUpdatingStore && !isApplyingTVUpdate) { - syncIndicatorsFromTV() - } - }, 100) + console.log('[Indicators] study_properties_changed with no study ID, ignoring') } console.log('[Indicators] ===== STUDY_PROPERTIES_CHANGED COMPLETE =====') }) - // Initial sync on data load - const dataLoadedSubscription = chart.onDataLoaded().subscribe(null, () => { - console.log('[Indicators] Chart data loaded, performing initial sync') - syncIndicatorsFromTV() - }) + // Suppress study_event: remove during page unload (TV fires removes for all studies + // as part of its own cleanup, which would otherwise wipe the persistence store) + const onBeforeUnload = () => { isUnmounting = true } + window.addEventListener('beforeunload', onBeforeUnload) // Cleanup function return () => { console.log('[Indicators] Cleaning up event subscriptions') + isUnmounting = true + window.removeEventListener('beforeunload', onBeforeUnload) try { - // Unsubscribe from all widget events tvWidget.unsubscribe('study_event') tvWidget.unsubscribe('study_properties_changed') - - if (dataLoadedSubscription && typeof dataLoadedSubscription.unsubscribe === 'function') { - dataLoadedSubscription.unsubscribe() - } } catch (error) { console.error('[Indicators] Error during cleanup:', error) } @@ -647,74 +728,6 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) { } } - /** - * Scan TradingView for indicators and sync to store - */ - function syncIndicatorsFromTV() { - if (!isChartReady) return - - try { - const chart = tvWidget.activeChart() - if (!chart) return - - const currentSymbol = chartStore.symbol - const allStudies = chart.getAllStudies() - - if (!allStudies) return - - const seenStudyIds = new Set() - - isUpdatingStore = true - try { - for (const studyInfo of allStudies) { - seenStudyIds.add(studyInfo.id) - - // getAllStudies() returns simple objects {id, name} - // We need to get the full study object using getStudyById() - try { - const tvStudy = chart.getStudyById(studyInfo.id) - if (!tvStudy) continue - - const indicator = convertTVStudyToIndicator(tvStudy, currentSymbol, studyInfo.id) - if (!indicator) continue - - const existingIndicator = Object.values(indicatorStore.indicators).find( - ind => ind.tv_study_id === studyInfo.id - ) - - if (!existingIndicator) { - console.log('[Indicators] New indicator detected:', indicator) - indicatorStore.addIndicator(indicator) - } else if (JSON.stringify(existingIndicator.parameters) !== JSON.stringify(indicator.parameters)) { - console.log('[Indicators] Indicator parameters changed:', indicator.id) - indicatorStore.updateIndicator(existingIndicator.id, { - parameters: indicator.parameters, - tv_inputs: indicator.tv_inputs - }) - } - } catch (err) { - console.warn('[Indicators] Could not get study details for:', studyInfo.id, err) - } - } - - // Check for deleted indicators - const allStoreIndicators = indicatorStore.getAllIndicators() - for (const storeIndicator of allStoreIndicators) { - if (storeIndicator.symbol === currentSymbol && - storeIndicator.tv_study_id && - !seenStudyIds.has(storeIndicator.tv_study_id)) { - console.log('[Indicators] Indicator deleted:', storeIndicator.id) - indicatorStore.removeIndicator(storeIndicator.id) - } - } - } finally { - isUpdatingStore = false - } - } catch (error) { - console.error('[Indicators] Error syncing indicators from TV:', error) - } - } - /** * Setup watchers for IndicatorStore changes to apply to TradingView */ diff --git a/web/src/stores/indicatorTypes.ts b/web/src/stores/indicatorTypes.ts new file mode 100644 index 00000000..97fe32fa --- /dev/null +++ b/web/src/stores/indicatorTypes.ts @@ -0,0 +1,17 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import type { CustomIndicatorMetadata } from './indicators' + +export interface CustomIndicatorType { + pandas_ta_name: string + display_name: string + description?: string + metadata: CustomIndicatorMetadata + created_at: number + modified_at: number +} + +export const useIndicatorTypesStore = defineStore('indicator_types', () => { + const types = ref>({}) + return { types } +})