custom indicators fixed

This commit is contained in:
2026-04-09 17:00:43 -04:00
parent a70dcd954f
commit fd431516cc
17 changed files with 778 additions and 440 deletions

40
bin/bump-sandbox Executable file
View File

@@ -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"

22
bin/dev
View File

@@ -153,7 +153,9 @@ rebuild_images() {
# Build lifecycle-sidecar (Go binary) # Build lifecycle-sidecar (Go binary)
if [ "$service" == "all" ] || [ "$service" == "lifecycle-sidecar" ] || [ "$service" == "sidecar" ]; then if [ "$service" == "all" ] || [ "$service" == "lifecycle-sidecar" ] || [ "$service" == "sidecar" ]; then
echo -e "${GREEN}→${NC} Building lifecycle-sidecar..." 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 fi
# Build web (Vue.js application) # Build web (Vue.js application)
@@ -165,7 +167,9 @@ rebuild_images() {
# Build sandbox (Python client library) # Build sandbox (Python client library)
if [ "$service" == "all" ] || [ "$service" == "sandbox" ]; then if [ "$service" == "all" ] || [ "$service" == "sandbox" ]; then
echo -e "${GREEN}→${NC} Building sandbox..." 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 fi
# Save the tags for deployment (all services, preserving any we didn't rebuild) # Save the tags for deployment (all services, preserving any we didn't rebuild)
@@ -749,20 +753,6 @@ case "$COMMAND" in
# Handle sandbox separately # Handle sandbox separately
if [ "$sandbox_requested" == "1" ]; then 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..." echo -e "${GREEN}→${NC} Deleting user container deployments in sandbox namespace..."
kubectl delete deployments --all -n sandbox 2>/dev/null || true kubectl delete deployments --all -n sandbox 2>/dev/null || true
echo -e "${GREEN}✓ User containers will be recreated by gateway on next login${NC}" echo -e "${GREEN}✓ User containers will be recreated by gateway on next login${NC}"

View File

@@ -56,8 +56,8 @@ data:
namespace: sandbox namespace: sandbox
service_namespace: default service_namespace: default
in_cluster: true in_cluster: true
sandbox_image: dexorder/ai-sandbox:dev20260408140409 sandbox_image: dexorder/ai-sandbox:dev20260409143116
sidecar_image: dexorder/ai-lifecycle-sidecar:dev20260407185216 sidecar_image: dexorder/ai-lifecycle-sidecar:dev20260408103634
storage_class: standard storage_class: standard
image_pull_policy: Never # For minikube dev - use local images image_pull_policy: Never # For minikube dev - use local images

View File

@@ -405,22 +405,25 @@ export class DuckDBClient {
return []; 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 = ` const sql = `
SELECT timestamp, ticker, period_seconds, open, high, low, close, volume
FROM (
SELECT SELECT
timestamp, timestamp, ticker, period_seconds, open, high, low, close, volume, ingested_at,
ticker, ROW_NUMBER() OVER (
period_seconds, PARTITION BY timestamp
open, ORDER BY ingested_at DESC
high, ) AS rn
low,
close,
volume
FROM iceberg_scan('${tablePath}') FROM iceberg_scan('${tablePath}')
WHERE ticker = ? WHERE ticker = ?
AND period_seconds = ? AND period_seconds = ?
AND timestamp >= ? AND timestamp >= ?
AND timestamp < ? AND timestamp < ?
)
WHERE rn = 1
ORDER BY timestamp ASC ORDER BY timestamp ASC
`; `;

View File

@@ -31,7 +31,7 @@ export interface ZMQRelayConfig {
relayRequestEndpoint: string; // e.g., "tcp://relay:5559" relayRequestEndpoint: string; // e.g., "tcp://relay:5559"
relayNotificationEndpoint: string; // e.g., "tcp://relay:5558" relayNotificationEndpoint: string; // e.g., "tcp://relay:5558"
clientId?: string; // Optional client ID, will generate if not provided 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<void>; // Callback when symbol metadata updates onMetadataUpdate?: () => Promise<void>; // Callback when symbol metadata updates
} }
@@ -65,7 +65,7 @@ export class ZMQRelayClient {
relayRequestEndpoint: config.relayRequestEndpoint, relayRequestEndpoint: config.relayRequestEndpoint,
relayNotificationEndpoint: config.relayNotificationEndpoint, relayNotificationEndpoint: config.relayNotificationEndpoint,
clientId: config.clientId || `gateway-${randomUUID().slice(0, 8)}`, clientId: config.clientId || `gateway-${randomUUID().slice(0, 8)}`,
requestTimeout: config.requestTimeout || 30000, requestTimeout: config.requestTimeout || 60000,
onMetadataUpdate: config.onMetadataUpdate || (async () => {}), onMetadataUpdate: config.onMetadataUpdate || (async () => {}),
}; };
this.logger = logger; this.logger = logger;

View File

@@ -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. Your text responses should be markdown, using emojiis, color, and formatting to create a visually appealing response.
# User Information
**User License:** {{licenseType}} **User License:** {{licenseType}}
**Available Features:** **Available Features:**
{{features}} {{features}}
---
# Platform Capabilities # 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:
Dexorder does not support long-running computations like paramater optimizations or training machine learning models. * tick-by-tick trading or high-frequency strategies.
Dexorder does not support portfolio optimization or trading strategies that require a large number of symbols. * 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 # Important Instructions
## Investment Advice ## 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 ## 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 - For ANY research questions, deep analysis, statistical analysis, charting requests, or market data queries that require computation, you MUST use the 'research' tool

View File

@@ -98,6 +98,11 @@ export const DEFAULT_STORES: StoreConfig[] = [
persistent: true, persistent: true,
initialState: () => ({}), initialState: () => ({}),
}, },
{
name: 'indicator_types',
persistent: true,
initialState: () => ({}),
},
{ {
name: 'channelState', name: 'channelState',
persistent: false, persistent: false,

View File

@@ -194,6 +194,8 @@ export class WorkspaceManager {
const storeConfig = this.stores.find((s) => s.name === storeName); const storeConfig = this.stores.find((s) => s.name === storeName);
if (storeConfig?.persistent) { if (storeConfig?.persistent) {
this.dirtyStores.add(storeName); this.dirtyStores.add(storeName);
// Persist immediately so changes survive page reloads (not just graceful shutdown)
await this.saveDirtyStores();
} }
// Send response if needed // Send response if needed

View File

@@ -92,6 +92,12 @@ class IcebergClient:
# Reload table metadata to pick up snapshots committed after this client was initialized # Reload table metadata to pick up snapshots committed after this client was initialized
self.table = self.catalog.load_table(f"{self.namespace}.ohlc") 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( scan = self.table.scan(
row_filter=And( row_filter=And(
EqualTo("ticker", ticker), EqualTo("ticker", ticker),
@@ -101,12 +107,22 @@ class IcebergClient:
) )
) )
if columns is not None: if fetch_columns is not None:
scan = scan.select(*columns) scan = scan.select(*fetch_columns)
df = scan.to_pandas() df = scan.to_pandas()
if not df.empty: 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") df = df.sort_values("timestamp")
# Convert integer nanosecond timestamps to DatetimeIndex # Convert integer nanosecond timestamps to DatetimeIndex
df.index = pd.to_datetime(df["timestamp"], unit="ns", utc=True) df.index = pd.to_datetime(df["timestamp"], unit="ns", utc=True)

View File

@@ -15,6 +15,7 @@ import logging
import os import os
import signal import signal
import sys import sys
import time
from pathlib import Path from pathlib import Path
from typing import Optional 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.events import EventType, UserEvent, DeliverySpec
from dexorder.impl.charting_api_impl import ChartingAPIImpl from dexorder.impl.charting_api_impl import ChartingAPIImpl
from dexorder.impl.data_api_impl import DataAPIImpl 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.workspace_tools import get_workspace_store
from dexorder.tools.evaluate_indicator import evaluate_indicator from dexorder.tools.evaluate_indicator import evaluate_indicator
from dexorder.tools.backtest_strategy import backtest_strategy from dexorder.tools.backtest_strategy import backtest_strategy
@@ -57,6 +58,75 @@ def get_data_dir() -> Path:
return DATA_DIR 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 # Configuration
# ============================================================================= # =============================================================================
@@ -156,6 +226,9 @@ def create_mcp_server(config: Config, event_publisher: EventPublisher) -> Server
category_manager = get_category_manager(config.data_dir) category_manager = get_category_manager(config.data_dir)
logging.info(f"Category manager initialized at {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() @server.list_resources()
async def list_resources(): async def list_resources():
"""List available 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") logging.info(f"python_write '{arguments.get('name')}': returning {len(content)} items, {image_count} images")
else: else:
logging.info(f"python_write '{arguments.get('name')}': no execution result (category={arguments.get('category')})") 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 return content
elif name == "python_edit": elif name == "python_edit":
result = category_manager.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") logging.info(f"python_edit '{arguments.get('name')}': returning {len(content)} items, {image_count} images")
else: else:
logging.info(f"python_edit '{arguments.get('name')}': no execution result") 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 return content
elif name == "python_read": elif name == "python_read":
return category_manager.read( return category_manager.read(

View File

@@ -8,6 +8,7 @@ import LoginScreen from './components/LoginScreen.vue'
import { useChartStore } from './stores/chart' import { useChartStore } from './stores/chart'
import { useShapeStore } from './stores/shapes' import { useShapeStore } from './stores/shapes'
import { useIndicatorStore } from './stores/indicators' import { useIndicatorStore } from './stores/indicators'
import { useIndicatorTypesStore } from './stores/indicatorTypes'
import { useChannelStore } from './stores/channel' import { useChannelStore } from './stores/channel'
import { useStateSync } from './composables/useStateSync' import { useStateSync } from './composables/useStateSync'
import { wsManager } from './composables/useWebSocket' import { wsManager } from './composables/useWebSocket'
@@ -93,11 +94,13 @@ const initializeApp = async () => {
const chartStore = useChartStore() const chartStore = useChartStore()
const shapeStore = useShapeStore() const shapeStore = useShapeStore()
const indicatorStore = useIndicatorStore() const indicatorStore = useIndicatorStore()
const indicatorTypesStore = useIndicatorTypesStore()
const channelStore = useChannelStore() const channelStore = useChannelStore()
const stateSync = useStateSync({ const stateSync = useStateSync({
chartState: chartStore, chartState: chartStore,
shapes: shapeStore, shapes: shapeStore,
indicators: indicatorStore, indicators: indicatorStore,
indicator_types: indicatorTypesStore,
channelState: channelStore channelState: channelStore
}) })
stateSyncCleanup = stateSync.cleanup stateSyncCleanup = stateSync.cleanup

View File

@@ -6,6 +6,7 @@ import { useTradingViewShapes } from '../composables/useTradingViewShapes'
import { useTradingViewIndicators } from '../composables/useTradingViewIndicators' import { useTradingViewIndicators } from '../composables/useTradingViewIndicators'
import { useCustomIndicators, getCustomIndicatorsGetter } from '../composables/useCustomIndicators' import { useCustomIndicators, getCustomIndicatorsGetter } from '../composables/useCustomIndicators'
import { useChartStore } from '../stores/chart' import { useChartStore } from '../stores/chart'
import { useIndicatorTypesStore } from '../stores/indicatorTypes'
import type { IChartingLibraryWidget } from '../types/tradingview' import type { IChartingLibraryWidget } from '../types/tradingview'
import { intervalToSeconds } from '../utils' import { intervalToSeconds } from '../utils'
import { wsManager } from '../composables/useWebSocket' import { wsManager } from '../composables/useWebSocket'
@@ -51,6 +52,9 @@ function initChart() {
try { try {
datafeed = createTradingViewDatafeed() datafeed = createTradingViewDatafeed()
const indicatorTypesStore = useIndicatorTypesStore()
console.log('[ChartView] indicator_types at widget init:', Object.keys(indicatorTypesStore.types))
tvWidget = new window.TradingView.widget({ tvWidget = new window.TradingView.widget({
symbol: chartStore.symbol, // Use symbol from store symbol: chartStore.symbol, // Use symbol from store
datafeed: datafeed, datafeed: datafeed,
@@ -58,104 +62,16 @@ function initChart() {
container: chartContainer.value!, container: chartContainer.value!,
library_path: '/charting_library/', library_path: '/charting_library/',
locale: 'en', 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. // 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: [ disabled_features: [
'use_localstorage_for_settings', 'use_localstorage_for_settings',
'header_symbol_search', 'header_symbol_search',
'symbol_search_hot_key' 'symbol_search_hot_key'
], ],
enabled_features: [], 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, fullscreen: false,
autosize: true, autosize: true,
theme: 'Dark', theme: 'Dark',

View File

@@ -326,6 +326,11 @@ const sendMessage = async (event: any) => {
// Show typing indicator immediately (before first chunk arrives) // Show typing indicator immediately (before first chunk arrives)
isAgentProcessing.value = true 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 // Mark as distributed (single checkmark) after confirming WS send
setTimeout(() => { setTimeout(() => {
const msgIndex = messages.value.findIndex(m => m._id === messageId) const msgIndex = messages.value.findIndex(m => m._id === messageId)

View File

@@ -8,28 +8,37 @@
* dynamic registration API (createCustomStudy does not exist on the widget * dynamic registration API (createCustomStudy does not exist on the widget
* or chart APIs). * or chart APIs).
* *
* To support custom indicators that arrive at runtime (e.g. from the AI * Study types are registered in two tiers:
* agent), we pre-register two generic dispatch studies in
* `custom_indicators_getter`:
* *
* dxo_customstudy_overlay — is_price_study: true (drawn on price pane) * 1. Named studies — one per custom indicator type stored in indicator_types:
* dxo_customstudy_pane — is_price_study: false (separate pane) * 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 * 2. Generic dispatch studies — backward-compatibility fallbacks:
* line plots. The constructor dispatches to `customStudyRegistry[cfgKey]` * dxo_customstudy_overlay (is_price_study: true)
* to look up the per-indicator configuration and data. * 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 * These study type names MUST also appear in the `studies_access` whitelist
* in ChartView.vue — TV treats unlisted studies as nonexistent. * in ChartView.vue — TV treats unlisted studies as nonexistent.
* *
* Registration flow * Registration flow
* ----------------- * -----------------
* 1. Widget constructor calls getCustomIndicatorsGetter() which registers * 1. Widget constructor calls getCustomIndicatorsGetter(getTypes) which registers
* the two generic study types. * named studies (one per type) plus the two generic fallback studies.
* 2. When a custom_ indicator appears in the store, registerCustomStudy(): * 2a. Agent path: when a custom_ indicator appears in the indicators store,
* a. Stores the config in customStudyRegistry under a unique cfgKey. * registerCustomStudy() calls chart.createStudy('dxo_ind_*', ..., {_cfg: cfgKey}).
* b. Calls chart.createStudy('dxo_customstudy_*', ..., { _cfg: cfgKey }). * 2b. Picker path: user selects a named study from TV's search; TV creates it with
* c. Calls study.setStudyTitle(indicator name) for a human-readable header. * 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): * 3. TV calls the study's init(ctx, inputs):
* a. Reads symbol/period from ctx; builds the data cache key. * a. Reads symbol/period from ctx; builds the data cache key.
* b. Fires an async evaluateIndicator WebSocket request. * b. Fires an async evaluateIndicator WebSocket request.
@@ -41,8 +50,9 @@
* `custom_indicators_getter` option when creating the TradingView widget. * `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 { useIndicatorStore, type IndicatorInstance, type CustomIndicatorMetadata } from '../stores/indicators'
import { useIndicatorTypesStore, type CustomIndicatorType } from '../stores/indicatorTypes'
import { useChartStore } from '../stores/chart' import { useChartStore } from '../stores/chart'
import { wsManager, type MessageHandler } from './useWebSocket' import { wsManager, type MessageHandler } from './useWebSocket'
import { intervalToSeconds } from '../utils' import { intervalToSeconds } from '../utils'
@@ -141,6 +151,10 @@ const customStudyRegistry = new Map<string, CustomStudyEntry>()
// Called by the constructor when async data arrives to trigger TV re-run. // Called by the constructor when async data arrives to trigger TV re-run.
const refreshCallbacks = new Map<string, (newCfgKey: string) => void>() const refreshCallbacks = new Map<string, (newCfgKey: string) => 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<string, { fromTime: number; toTime: number }>()
// TradingView widget reference — set by useCustomIndicators() so the // TradingView widget reference — set by useCustomIndicators() so the
// constructor can query the current visible range. // constructor can query the current visible range.
let _tvWidget: any = null let _tvWidget: any = null
@@ -157,60 +171,20 @@ const MULTI_LINE_COLORS = [
] ]
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Custom indicators getter // Study name helpers
// Pass the result of this function as the widget option:
// custom_indicators_getter: getCustomIndicatorsGetter()
//
// The study type names must also be listed in studies_access in ChartView.vue.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function getCustomIndicatorsGetter(): (_PineJS: any) => Promise<any[]> { /** TV study type name for a given pandas_ta_name. */
function makeGenericStudy(name: string, isPriceStudy: boolean): any { function tvStudyName(pandasTaName: string): string {
const plots = Array.from({ length: MAX_PLOTS }, (_, i) => ({ id: `plot_${i}`, type: 'line' })) return `dxo_ind_${pandasTaName}`
const styles: Record<string, any> = {}
const defaultStyles: Record<string, any> = {}
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 { /** Build the constructor function. nOutputs controls how many values main() returns. */
name, function makeStudyConstructor(nOutputs: number = MAX_PLOTS) {
metainfo: { return function (this: any) {
_metainfoVersion: 51, let _cfgKey = ''
// Use @tv-custom-1 — @tv-basicstudies-1 is reserved for built-ins let _dataKey = ''
// and TV throws "unexpected study id" if a custom indicator uses it. let _fetchGen = 0
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,
},
},
// 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) { this.init = function (ctx: any, inputs: (i: number) => any) {
const cfgKey = inputs(0) as string const cfgKey = inputs(0) as string
@@ -221,9 +195,6 @@ export function getCustomIndicatorsGetter(): (_PineJS: any) => Promise<any[]> {
const entry = customStudyRegistry.get(cfgKey) const entry = customStudyRegistry.get(cfgKey)
if (!entry) return 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 symbol: string = ctx.symbol.ticker
const periodStr: string = ctx.symbol.period const periodStr: string = ctx.symbol.period
const periodSeconds = intervalToSeconds(periodStr) const periodSeconds = intervalToSeconds(periodStr)
@@ -231,9 +202,8 @@ export function getCustomIndicatorsGetter(): (_PineJS: any) => Promise<any[]> {
const dk = cacheKey(entry.indicatorId, symbol, periodSeconds, paramsHash) const dk = cacheKey(entry.indicatorId, symbol, periodSeconds, paramsHash)
_dataKey = dk _dataKey = dk
if (dataCache.has(dk)) return // Data already fetched for this symbol/period/params if (dataCache.has(dk)) return
// Determine time range: prefer chart's visible range, fall back to 500-bar window
let fromTime: number let fromTime: number
let toTime: number let toTime: number
const now = Math.floor(Date.now() / 1000) const now = Math.floor(Date.now() / 1000)
@@ -250,17 +220,16 @@ export function getCustomIndicatorsGetter(): (_PineJS: any) => Promise<any[]> {
} catch { /* chart not yet ready */ } } catch { /* chart not yet ready */ }
} }
// Capture mutable vars before async gap
const capturedDk = dk const capturedDk = dk
const capturedCfgKey = cfgKey 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) evaluateIndicator(symbol, fromTime, toTime, periodSeconds, entry.pandasTaName, entry.parameters)
.then((result) => { .then((result) => {
if (myGen !== _fetchGen) return // Superseded by a newer init() call if (myGen !== _fetchGen) return
dataCache.set(capturedDk, buildDataCache(result)) 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` const refreshKey = `${capturedCfgKey}__r`
customStudyRegistry.set(refreshKey, entry) customStudyRegistry.set(refreshKey, entry)
const cb = refreshCallbacks.get(entry.indicatorId) const cb = refreshCallbacks.get(entry.indicatorId)
@@ -268,32 +237,222 @@ export function getCustomIndicatorsGetter(): (_PineJS: any) => Promise<any[]> {
}) })
.catch((err) => { .catch((err) => {
console.error('[CustomIndicators] Failed to fetch data for', entry.pandasTaName, 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) { 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() const ts: number = ctx.symbol.bartime()
if (!_cfgKey || !_dataKey) return new Array(MAX_PLOTS).fill(NaN) if (!_cfgKey || !_dataKey) return new Array(nOutputs).fill(NaN)
const entry = customStudyRegistry.get(_cfgKey) const entry = customStudyRegistry.get(_cfgKey)
if (!entry) return new Array(MAX_PLOTS).fill(NaN) if (!entry) return new Array(nOutputs).fill(NaN)
const cache = dataCache.get(_dataKey) const cache = dataCache.get(_dataKey)
if (!cache) return new Array(MAX_PLOTS).fill(NaN) if (!cache) return new Array(nOutputs).fill(NaN)
const row = cache.get(ts) const row = cache.get(ts)
return Array.from({ length: MAX_PLOTS }, (_, i) => { return Array.from({ length: nOutputs }, (_, i) => {
const col = entry.metadata.output_columns[i] const col = entry.metadata.output_columns[i]
return col && row ? (row[col.name] as number) ?? NaN : NaN 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<string, any> = {}
const defaultStyles: Record<string, any> = {}
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<string, any> = {}
const defaultStyles: Record<string, any> = {}
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<string, any> = { _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(getTypes)
//
// The study type names must also be listed in studies_access in ChartView.vue.
// ---------------------------------------------------------------------------
/** 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 },
},
},
},
constructor: function (this: any) {
this.main = function (ctx: any, _inputs: any) {
this._context = ctx
return [PineJS.Std.close(ctx)]
}
}, },
} }
} }
return (_PineJS: any): Promise<any[]> => { export function getCustomIndicatorsGetter(
return Promise.resolve([ getTypes: () => Record<string, CustomIndicatorType>
): (_PineJS: any) => Promise<any[]> {
return (PineJS: any): Promise<any[]> => {
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_overlay', true),
makeGenericStudy('dxo_customstudy_pane', false), 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 _tvWidget = tvWidget
const indicatorStore = useIndicatorStore() const indicatorStore = useIndicatorStore()
const indicatorTypesStore = useIndicatorTypesStore()
const chartStore = useChartStore() const chartStore = useChartStore()
// Maps indicator id → { cfgKey, tvStudyId, symbol } // Maps indicator id → { cfgKey, tvStudyId, symbol }
const registered = new Map<string, { cfgKey: string; tvStudyId: string | null; symbol: string }>() const registered = new Map<string, { cfgKey: string; tvStudyId: string | null; symbol: string }>()
// Tracks indicator IDs whose registerCustomStudy() is in-flight (createStudy not yet resolved).
const pendingRegistration = new Set<string>()
// Monotonic version counter per indicator for unique config keys // Monotonic version counter per indicator for unique config keys
const cfgVersions = new Map<string, number>() const cfgVersions = new Map<string, number>()
// Last-seen parameter hash per indicator id for change detection. // Last-seen parameter hash per indicator id for change detection.
// Needed because Pinia $patch mutates in place (oldValue === newValue).
const lastParams = new Map<string, string>() const lastParams = new Map<string, string>()
let isChartReady = false let isChartReady = false
@@ -323,8 +484,6 @@ export function useCustomIndicators(tvWidget: any) {
return `cfg_${indicatorId.replace(/[^a-zA-Z0-9]/g, '_')}_v${v}` 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) { function applyStudyOverrides(studyId: string, meta: CustomIndicatorMetadata) {
try { try {
const study = tvWidget.activeChart().getStudyById(studyId) 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] 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) study.applyOverrides(overrides)
} catch (err) { } catch (err) {
console.warn('[CustomIndicators] Could not apply overrides:', 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 // Register a custom indicator as a TV study instance
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@@ -367,10 +544,11 @@ export function useCustomIndicators(tvWidget: any) {
return return
} }
if (pendingRegistration.has(indicator.id)) return
pendingRegistration.add(indicator.id)
const symbol = indicator.symbol || chartStore.symbol const symbol = indicator.symbol || chartStore.symbol
const cfgKey = nextCfgKey(indicator.id) 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 // Store per-instance config in the registry so the constructor can find it
customStudyRegistry.set(cfgKey, { customStudyRegistry.set(cfgKey, {
@@ -381,8 +559,6 @@ export function useCustomIndicators(tvWidget: any) {
}) })
// Register the callback invoked by the constructor after async data loads. // 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) => { refreshCallbacks.set(indicator.id, (newCfgKey: string) => {
const entry = registered.get(indicator.id) const entry = registered.get(indicator.id)
if (!entry?.tvStudyId) return if (!entry?.tvStudyId) return
@@ -398,33 +574,67 @@ export function useCustomIndicators(tvWidget: any) {
}) })
try { try {
const tvStudyId = (await tvWidget.activeChart().createStudy( // 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<string, any> = { _cfg: cfgKey }
for (const [paramName, value] of Object.entries(indicator.parameters)) {
inputsDict[`param_${paramName}`] = value
}
tvStudyId = (await tvWidget.activeChart().createStudy(
studyTypeName, forceOverlay, false, studyTypeName, forceOverlay, false,
{ _cfg: cfgKey } inputsDict
)) as string | null )) as string | null
console.log('[CustomIndicators] Created study:', indicator.pandas_ta_name, '→', studyTypeName, '(', tvStudyId, ')')
}
registered.set(indicator.id, { cfgKey, tvStudyId: tvStudyId ?? null, symbol }) registered.set(indicator.id, { cfgKey, tvStudyId: tvStudyId ?? null, symbol })
lastParams.set(indicator.id, JSON.stringify(indicator.parameters)) lastParams.set(indicator.id, JSON.stringify(indicator.parameters))
if (tvStudyId) { if (tvStudyId) {
// Set human-readable panel title (falls back to pandas_ta_name if no display name) applyStudyTitle(tvStudyId, meta)
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 */ }
applyStudyOverrides(tvStudyId, meta) applyStudyOverrides(tvStudyId, meta)
if (tvStudyId !== indicator.tv_study_id) { if (tvStudyId !== indicator.tv_study_id) {
indicatorStore.updateIndicator(indicator.id, { tv_study_id: tvStudyId }) indicatorStore.updateIndicator(indicator.id, { tv_study_id: tvStudyId })
} }
} }
console.log('[CustomIndicators] Registered:', indicator.pandas_ta_name, '→', studyTypeName, '(', tvStudyId, ')')
} catch (err) { } 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) registered.delete(indicatorId)
lastParams.delete(indicatorId) lastParams.delete(indicatorId)
refreshCallbacks.delete(indicatorId) refreshCallbacks.delete(indicatorId)
fetchedRanges.delete(indicatorId)
pendingRegistration.delete(indicatorId)
if (entry.tvStudyId) { if (entry.tvStudyId) {
try { tvWidget.activeChart().removeStudy(entry.tvStudyId) } catch { /* already gone */ } 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) // Re-register when parameters/symbol/period change (forces new data fetch)
// ------------------------------------------------------------------ // ------------------------------------------------------------------
async function refreshCustomStudy(indicator: IndicatorInstance) { async function refreshCustomStudy(indicator: IndicatorInstance) {
// Purge stale cache entries so init() fetches fresh data
for (const key of Array.from(dataCache.keys())) { for (const key of Array.from(dataCache.keys())) {
if (key.startsWith(`${indicator.id}_`)) { if (key.startsWith(`${indicator.id}_`)) {
dataCache.delete(key) dataCache.delete(key)
@@ -458,20 +669,36 @@ export function useCustomIndicators(tvWidget: any) {
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// Store watcher — respond to indicator additions, changes, removals // Chart ready — set isChartReady and track when TV data has loaded
// // ------------------------------------------------------------------
// NOTE: Pinia $patch mutates in place, so oldValue === newValue for isChartReady = true
// backend-originated updates. We track state manually via lastParams.
// 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( watch(
() => indicatorStore.indicators, [isDataLoaded, () => indicatorStore.indicators],
async (newIndicators) => { async ([loaded, newIndicators]) => {
if (!isChartReady) return if (!isChartReady || !loaded) return
for (const [id, indicator] of Object.entries(newIndicators)) { const indicators = newIndicators as Record<string, InstanceType<any>>
for (const [id, indicator] of Object.entries(indicators)) {
if (!indicator.pandas_ta_name.startsWith('custom_')) continue 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)) lastParams.set(id, JSON.stringify(indicator.parameters))
await registerCustomStudy(indicator) await registerCustomStudy(indicator)
} else { } else {
@@ -488,13 +715,13 @@ export function useCustomIndicators(tvWidget: any) {
// Handle removals // Handle removals
for (const id of registered.keys()) { for (const id of registered.keys()) {
if (!(id in newIndicators)) { if (!(id in indicators)) {
lastParams.delete(id) lastParams.delete(id)
removeCustomStudy(id) removeCustomStudy(id)
} }
} }
}, },
{ deep: true } { immediate: true, deep: true }
) )
// Re-fetch when chart resolution changes // Re-fetch when chart resolution changes
@@ -512,37 +739,42 @@ export function useCustomIndicators(tvWidget: any) {
} }
) )
// ------------------------------------------------------------------ // Re-fetch when visible range expands beyond the fetched range (e.g. user zooms out).
// Chart ready — apply any indicators already in the store // 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.
// useCustomIndicators is always called from within tvWidget.onChartReady in ChartView, let rangeWatchTimer: ReturnType<typeof setTimeout> | null = null
// so the chart is already ready. watch(
isChartReady = true [() => chartStore.start_time, () => chartStore.end_time],
([newStart, newEnd]) => {
// TV processes custom_indicators_getter asynchronously (Promise microtask), so the if (!isChartReady) return
// custom study types are not yet available at onChartReady time. Defer the initial if (rangeWatchTimer) clearTimeout(rangeWatchTimer)
// registration of any pending indicators until chart data loads — by that point the rangeWatchTimer = setTimeout(() => {
// getter Promise has resolved and the study types are registered in TV's internal rangeWatchTimer = null
// study index (and the studies_access whitelist check passes). for (const [id, indicator] of Object.entries(indicatorStore.indicators)) {
let initialApplied = false if (!indicator.pandas_ta_name.startsWith('custom_')) continue
tvWidget.activeChart().onDataLoaded().subscribe(null, () => { if (!registered.has(id) || pendingRegistration.has(id)) continue
if (initialApplied) return const range = fetchedRanges.get(id)
initialApplied = true if (!range) continue
const pending = Object.values(indicatorStore.indicators).filter( const startExpanded = newStart != null && newStart < range.fromTime
(ind) => ind.pandas_ta_name.startsWith('custom_') && !registered.has(ind.id) // Allow a 10-bar buffer on the right before triggering (live bars naturally advance)
) const endExpanded = newEnd != null && newEnd > range.toTime + chartStore.period * 10
for (const indicator of pending) { if (startExpanded || endExpanded) {
lastParams.set(indicator.id, JSON.stringify(indicator.parameters)) console.log('[CustomIndicators] Visible range expanded beyond fetch range, re-fetching:', indicator.pandas_ta_name, { newStart, rangeFrom: range.fromTime })
registerCustomStudy(indicator) refreshCustomStudy(indicator)
} }
}) }
}, 500)
}
)
// Cleanup // Cleanup
return () => { return () => {
if (rangeWatchTimer) { clearTimeout(rangeWatchTimer); rangeWatchTimer = null }
for (const id of [...registered.keys()]) { for (const id of [...registered.keys()]) {
removeCustomStudy(id) removeCustomStudy(id)
} }
registered.clear() registered.clear()
pendingRegistration.clear()
cfgVersions.clear() cfgVersions.clear()
lastParams.clear() lastParams.clear()
if (_tvWidget === tvWidget) _tvWidget = null if (_tvWidget === tvWidget) _tvWidget = null

View File

@@ -40,7 +40,8 @@ interface SymbolDenominators {
export class WebSocketDatafeed implements IBasicDataFeed { export class WebSocketDatafeed implements IBasicDataFeed {
private pendingRequests: Map<string, PendingRequest> = new Map() private pendingRequests: Map<string, PendingRequest> = new Map()
private subscriptions: Map<string, Subscription> = new Map() private subscriptions: Map<string, Subscription> = 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 configuration: DatafeedConfiguration | null = null
private messageHandler: MessageHandler private messageHandler: MessageHandler
private symbolDenominators: Map<string, SymbolDenominators> = new Map() // Track denominators per symbol private symbolDenominators: Map<string, SymbolDenominators> = 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)}` return `tv_${Date.now()}_${Math.random().toString(36).substring(7)}`
} }
private sendRequest<T>(message: any): Promise<T> { private sendRequest<T>(message: any, timeoutMs?: number): Promise<T> {
const requestId = message.request_id || this.generateRequestId() const requestId = message.request_id || this.generateRequestId()
message.request_id = requestId message.request_id = requestId
@@ -67,7 +68,7 @@ export class WebSocketDatafeed implements IBasicDataFeed {
console.error('[TradingView Datafeed] Request timeout:', requestId, message.type) console.error('[TradingView Datafeed] Request timeout:', requestId, message.type)
this.pendingRequests.delete(requestId) this.pendingRequests.delete(requestId)
reject(new Error('Request timeout')) reject(new Error('Request timeout'))
}, this.requestTimeout) }, timeoutMs ?? this.requestTimeout)
this.pendingRequests.set(requestId, { resolve, reject, timeout }) this.pendingRequests.set(requestId, { resolve, reject, timeout })
@@ -244,13 +245,22 @@ export class WebSocketDatafeed implements IBasicDataFeed {
const symbolKey = symbolInfo.ticker || symbolInfo.name const symbolKey = symbolInfo.ticker || symbolInfo.name
const denoms = this.symbolDenominators.get(symbolKey) || { tick: 1, base: 1 } const denoms = this.symbolDenominators.get(symbolKey) || { tick: 1, base: 1 }
this.sendRequest<any>({ const doRequest = () => this.sendRequest<any>({
type: 'get_bars', type: 'get_bars',
symbol: symbolKey, symbol: symbolKey,
period_seconds: intervalToSeconds(resolution), period_seconds: intervalToSeconds(resolution),
from_time: periodParams.from, from_time: periodParams.from,
to_time: periodParams.to, to_time: periodParams.to,
countback: periodParams.countBack 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) => { .then((response) => {
if (response.history) { if (response.history) {

View File

@@ -1,6 +1,7 @@
import { watch } from 'vue' import { watch } from 'vue'
import type { IChartingLibraryWidget, IStudyApi } from '../types/tradingview' import type { IChartingLibraryWidget, IStudyApi } from '../types/tradingview'
import { useIndicatorStore } from '../stores/indicators' import { useIndicatorStore } from '../stores/indicators'
import { useIndicatorTypesStore } from '../stores/indicatorTypes'
import { useChartStore } from '../stores/chart' import { useChartStore } from '../stores/chart'
import type { IndicatorInstance } from '../stores/indicators' 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 isApplyingTVUpdate = false // Prevent circular updates when TV modifies indicators
let isUpdatingStore = false // Prevent circular updates when we update the store from TV let isUpdatingStore = false // Prevent circular updates when we update the store from TV
let isChartReady = false // Track if chart API is ready 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 * Convert TradingView study to our IndicatorInstance format
@@ -280,15 +282,63 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
} }
} }
// Final fallback to property access // Fallback: metaInfo.name is the registered study name (e.g. dxo_ind_custom_trendflex)
if (studyName === 'Unknown') { if (studyName === 'Unknown' || (!studyName.startsWith('dxo_ind_') && !TV_TO_PANDAS_TA_NAMES[studyName])) {
studyName = tvStudy._study?.name?.() || const metaName = tvStudy._study?.name?.() ||
tvStudy._metaInfo?.description || tvStudy._metaInfo?.name ||
tvStudy._metaInfo?.shortDescription || 'Unknown' 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) 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] const pandasTaName = TV_TO_PANDAS_TA_NAMES[studyName]
console.log('[Indicators] pandas-ta mapping:', studyName, '->', pandasTaName) 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 // Wait for TradingView to finish initializing the study
setTimeout(() => { setTimeout(() => {
try { 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) const study = chart.getStudyById(actualStudyId)
if (study) { if (study) {
console.log('[Indicators] Retrieved new study after timeout') console.log('[Indicators] Retrieved new study after timeout')
@@ -452,6 +513,11 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
else if (actualEventType === 'remove') { else if (actualEventType === 'remove') {
console.log('[Indicators] Indicator removed with ID:', actualStudyId) 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( const existingIndicator = Object.values(indicatorStore.indicators).find(
ind => ind.tv_study_id === actualStudyId ind => ind.tv_study_id === actualStudyId
) )
@@ -545,6 +611,35 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
if (existingIndicator) { if (existingIndicator) {
console.log('[Indicators] Found existing indicator:', existingIndicator.id) 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<string, any> = {}
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() // Get the study name using study().name()
let studyName = 'Unknown' let studyName = 'Unknown'
if (typeof study.study === 'function') { if (typeof study.study === 'function') {
@@ -596,11 +691,7 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
console.log('[Indicators] Parameters unchanged (might be visual properties only)') console.log('[Indicators] Parameters unchanged (might be visual properties only)')
} }
} else { } else {
console.log('[Indicators] No existing indicator found, doing full sync...') console.log('[Indicators] No existing indicator found for properties change, ignoring')
// Might be a new indicator, do full sync
if (!isUpdatingStore && !isApplyingTVUpdate) {
syncIndicatorsFromTV()
}
} }
} }
} catch (error) { } catch (error) {
@@ -608,35 +699,25 @@ export function useTradingViewIndicators(tvWidget: IChartingLibraryWidget) {
} }
}, 0) }, 0)
} else { } else {
console.log('[Indicators] No study ID in event, doing full sync...') console.log('[Indicators] study_properties_changed with no study ID, ignoring')
// Fallback to full sync
setTimeout(() => {
if (!isUpdatingStore && !isApplyingTVUpdate) {
syncIndicatorsFromTV()
}
}, 100)
} }
console.log('[Indicators] ===== STUDY_PROPERTIES_CHANGED COMPLETE =====') console.log('[Indicators] ===== STUDY_PROPERTIES_CHANGED COMPLETE =====')
}) })
// Initial sync on data load // Suppress study_event: remove during page unload (TV fires removes for all studies
const dataLoadedSubscription = chart.onDataLoaded().subscribe(null, () => { // as part of its own cleanup, which would otherwise wipe the persistence store)
console.log('[Indicators] Chart data loaded, performing initial sync') const onBeforeUnload = () => { isUnmounting = true }
syncIndicatorsFromTV() window.addEventListener('beforeunload', onBeforeUnload)
})
// Cleanup function // Cleanup function
return () => { return () => {
console.log('[Indicators] Cleaning up event subscriptions') console.log('[Indicators] Cleaning up event subscriptions')
isUnmounting = true
window.removeEventListener('beforeunload', onBeforeUnload)
try { try {
// Unsubscribe from all widget events
tvWidget.unsubscribe('study_event') tvWidget.unsubscribe('study_event')
tvWidget.unsubscribe('study_properties_changed') tvWidget.unsubscribe('study_properties_changed')
if (dataLoadedSubscription && typeof dataLoadedSubscription.unsubscribe === 'function') {
dataLoadedSubscription.unsubscribe()
}
} catch (error) { } catch (error) {
console.error('[Indicators] Error during cleanup:', 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<string>()
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 * Setup watchers for IndicatorStore changes to apply to TradingView
*/ */

View File

@@ -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<Record<string, CustomIndicatorType>>({})
return { types }
})