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)
|
# 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}"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
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