Implement multi-timeframe support for indicators

- Enhanced the `UserIndicator` class to include an optional `timeframe` attribute for custom indicator timeframes.
- Updated the `get_indicator_data` method in `MarketDataIntegrator` to fetch and calculate indicators based on the specified timeframe, ensuring proper data alignment and handling.
- Modified the `ChartBuilder` to pass the correct DataFrame for plotting indicators with different timeframes.
- Added UI elements in the indicator modal for selecting timeframes, improving user experience.
- Updated relevant JSON templates to include the new `timeframe` field for all indicators.
- Refactored the `prepare_chart_data` function to ensure it returns a DataFrame with a `DatetimeIndex` for consistent calculations.

This commit enhances the flexibility and usability of the indicator system, allowing users to analyze data across various timeframes.
This commit is contained in:
Vasily.onl
2025-06-06 15:06:17 +08:00
parent 58a754414a
commit b49e39dcb4
19 changed files with 417 additions and 247 deletions

View File

@@ -489,7 +489,12 @@ class ChartBuilder:
if all_indicator_configs:
indicator_data_map = self.data_integrator.get_indicator_data(
df, all_indicator_configs, indicator_manager
main_df=df,
main_timeframe=timeframe,
indicator_configs=all_indicator_configs,
indicator_manager=indicator_manager,
symbol=symbol,
exchange="okx"
)
for indicator_id, indicator_df in indicator_data_map.items():
@@ -499,7 +504,9 @@ class ChartBuilder:
continue
if indicator_df is not None and not indicator_df.empty:
final_df = pd.merge(final_df, indicator_df, on='timestamp', how='left')
# Add a suffix to the indicator's columns before joining to prevent overlap
# when multiple indicators of the same type are added.
final_df = final_df.join(indicator_df, how='left', rsuffix=f'_{indicator.id}')
# Determine target row for plotting
target_row = 1 # Default to overlay on the main chart
@@ -511,7 +518,7 @@ class ChartBuilder:
if indicator.type == 'bollinger_bands':
if all(c in indicator_df.columns for c in ['upper_band', 'lower_band', 'middle_band']):
# Prepare data for the filled area
x_vals = indicator_df['timestamp']
x_vals = indicator_df.index
y_upper = indicator_df['upper_band']
y_lower = indicator_df['lower_band']
@@ -522,7 +529,7 @@ class ChartBuilder:
# Add the transparent fill trace
fig.add_trace(go.Scatter(
x=pd.concat([x_vals, x_vals[::-1]]),
x=pd.concat([x_vals.to_series(), x_vals.to_series()[::-1]]),
y=pd.concat([y_upper, y_lower[::-1]]),
fill='toself',
fillcolor=fill_color,
@@ -540,7 +547,7 @@ class ChartBuilder:
for col in indicator_df.columns:
if col != 'timestamp':
fig.add_trace(go.Scatter(
x=indicator_df['timestamp'],
x=indicator_df.index,
y=indicator_df[col],
mode='lines',
name=f"{indicator.name} ({col})",

View File

@@ -460,8 +460,11 @@ class MarketDataIntegrator:
def get_indicator_data(
self,
main_df: pd.DataFrame,
main_timeframe: str,
indicator_configs: List['IndicatorLayerConfig'],
indicator_manager: 'IndicatorManager'
indicator_manager: 'IndicatorManager',
symbol: str,
exchange: str = "okx"
) -> Dict[str, pd.DataFrame]:
indicator_data_map = {}
@@ -477,21 +480,62 @@ class MarketDataIntegrator:
continue
try:
# The new `calculate` method in TechnicalIndicators handles DataFrame input
# Determine the timeframe and data to use
target_timeframe = indicator.timeframe
if target_timeframe and target_timeframe != main_timeframe:
# Custom timeframe: fetch new data
days_back = (main_df.index.max() - main_df.index.min()).days + 2 # Add buffer
raw_candles, _ = self.get_market_data_for_indicators(
symbol=symbol,
timeframe=target_timeframe,
days_back=days_back,
exchange=exchange
)
if not raw_candles:
self.logger.warning(f"No data for indicator '{indicator.name}' on timeframe {target_timeframe}")
continue
from components.charts.utils import prepare_chart_data
indicator_df = prepare_chart_data(raw_candles)
else:
# Use main chart's dataframe
indicator_df = main_df
# Calculate the indicator
indicator_result_pkg = self.indicators.calculate(
indicator.type,
main_df,
indicator_df,
**indicator.parameters
)
if indicator_result_pkg and 'data' in indicator_result_pkg and indicator_result_pkg['data']:
# The result is a list of IndicatorResult objects. Convert to DataFrame.
if indicator_result_pkg and indicator_result_pkg.get('data'):
indicator_results = indicator_result_pkg['data']
if not indicator_results:
self.logger.warning(f"Indicator '{indicator.name}' produced no results.")
continue
result_df = pd.DataFrame([
{'timestamp': r.timestamp, **r.values}
for r in indicator_results
])
indicator_data_map[indicator.id] = result_df
result_df['timestamp'] = pd.to_datetime(result_df['timestamp'])
result_df.set_index('timestamp', inplace=True)
# Ensure timezone consistency before reindexing
if result_df.index.tz is None:
result_df = result_df.tz_localize('UTC')
result_df = result_df.tz_convert(main_df.index.tz)
# Align data to main_df's index to handle different timeframes
if not result_df.index.equals(main_df.index):
aligned_df = result_df.reindex(main_df.index, method='ffill')
indicator_data_map[indicator.id] = aligned_df
else:
indicator_data_map[indicator.id] = result_df
else:
self.logger.warning(f"No data returned for indicator '{indicator.name}'")

View File

@@ -60,6 +60,7 @@ class UserIndicator:
display_type: str # DisplayType
parameters: Dict[str, Any]
styling: IndicatorStyling
timeframe: Optional[str] = None
visible: bool = True
created_date: str = ""
modified_date: str = ""
@@ -82,6 +83,7 @@ class UserIndicator:
'display_type': self.display_type,
'parameters': self.parameters,
'styling': asdict(self.styling),
'timeframe': self.timeframe,
'visible': self.visible,
'created_date': self.created_date,
'modified_date': self.modified_date
@@ -101,6 +103,7 @@ class UserIndicator:
display_type=data['display_type'],
parameters=data.get('parameters', {}),
styling=styling,
timeframe=data.get('timeframe'),
visible=data.get('visible', True),
created_date=data.get('created_date', ''),
modified_date=data.get('modified_date', '')
@@ -244,7 +247,7 @@ class IndicatorManager:
def create_indicator(self, name: str, indicator_type: str, parameters: Dict[str, Any],
description: str = "", color: str = "#007bff",
display_type: str = None) -> Optional[UserIndicator]:
display_type: str = None, timeframe: Optional[str] = None) -> Optional[UserIndicator]:
"""
Create a new indicator.
@@ -255,6 +258,7 @@ class IndicatorManager:
description: Optional description
color: Color for chart display
display_type: overlay or subplot (auto-detected if None)
timeframe: Optional timeframe for the indicator
Returns:
Created UserIndicator instance or None if error
@@ -278,7 +282,8 @@ class IndicatorManager:
type=indicator_type,
display_type=display_type,
parameters=parameters,
styling=styling
styling=styling,
timeframe=timeframe
)
# Save to file
@@ -309,16 +314,19 @@ class IndicatorManager:
return False
# Update fields
for field, value in updates.items():
if hasattr(indicator, field):
if field == 'styling' and isinstance(value, dict):
# Update styling fields
for style_field, style_value in value.items():
if hasattr(indicator.styling, style_field):
setattr(indicator.styling, style_field, style_value)
for key, value in updates.items():
if hasattr(indicator, key):
if key == 'styling' and isinstance(value, dict):
# Update nested styling fields
for style_key, style_value in value.items():
if hasattr(indicator.styling, style_key):
setattr(indicator.styling, style_key, style_value)
elif key == 'parameters' and isinstance(value, dict):
indicator.parameters.update(value)
else:
setattr(indicator, field, value)
setattr(indicator, key, value)
# Save updated indicator
return self.save_indicator(indicator)
except Exception as e:

View File

@@ -139,9 +139,10 @@ def prepare_chart_data(candles: List[Dict[str, Any]]) -> pd.DataFrame:
if col in df.columns:
df[col] = pd.to_numeric(df[col], errors='coerce')
# Sort by timestamp
df = df.sort_values('timestamp').reset_index(drop=True)
# Sort by timestamp and set it as the index, keeping the column
df = df.sort_values('timestamp')
df.index = pd.to_datetime(df['timestamp'])
# Handle missing volume data
if 'volume' not in df.columns:
df['volume'] = 0