custom indicators fixed
This commit is contained in:
40
bin/bump-sandbox
Executable file
40
bin/bump-sandbox
Executable 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
22
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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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<void>; // 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -98,6 +98,11 @@ export const DEFAULT_STORES: StoreConfig[] = [
|
||||
persistent: true,
|
||||
initialState: () => ({}),
|
||||
},
|
||||
{
|
||||
name: 'indicator_types',
|
||||
persistent: true,
|
||||
initialState: () => ({}),
|
||||
},
|
||||
{
|
||||
name: 'channelState',
|
||||
persistent: false,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<string, CustomStudyEntry>()
|
||||
// Called by the constructor when async data arrives to trigger TV re-run.
|
||||
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
|
||||
// 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<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()
|
||||
// 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<any[]> {
|
||||
function makeGenericStudy(name: 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 {
|
||||
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<any[]> => {
|
||||
return Promise.resolve([
|
||||
export function getCustomIndicatorsGetter(
|
||||
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_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<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
|
||||
const cfgVersions = new Map<string, number>()
|
||||
// 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>()
|
||||
|
||||
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<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,
|
||||
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<string, InstanceType<any>>
|
||||
|
||||
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<typeof setTimeout> | 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
|
||||
|
||||
@@ -40,7 +40,8 @@ interface SymbolDenominators {
|
||||
export class WebSocketDatafeed implements IBasicDataFeed {
|
||||
private pendingRequests: Map<string, PendingRequest> = 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 messageHandler: MessageHandler
|
||||
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)}`
|
||||
}
|
||||
|
||||
private sendRequest<T>(message: any): Promise<T> {
|
||||
private sendRequest<T>(message: any, timeoutMs?: number): Promise<T> {
|
||||
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<any>({
|
||||
const doRequest = () => this.sendRequest<any>({
|
||||
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])
|
||||
|
||||
@@ -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<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()
|
||||
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<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
|
||||
*/
|
||||
|
||||
17
web/src/stores/indicatorTypes.ts
Normal file
17
web/src/stores/indicatorTypes.ts
Normal 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 }
|
||||
})
|
||||
Reference in New Issue
Block a user