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

View File

@ -3,6 +3,7 @@
"description": "Bollinger Bands volatility indicator",
"type": "bollinger_bands",
"display_type": "overlay",
"timeframe": null,
"default_parameters": {
"period": 20,
"std_dev": 2.0
@ -20,7 +21,12 @@
"min": 0.5,
"max": 5.0,
"default": 2.0,
"description": "Standard deviation multiplier"
"description": "Standard deviation for Bollinger Bands"
},
"timeframe": {
"type": "string",
"default": null,
"description": "Indicator timeframe (e.g., '1h', '4h'). Null for chart timeframe."
}
},
"default_styling": {

View File

@ -3,6 +3,7 @@
"description": "Exponential Moving Average indicator",
"type": "ema",
"display_type": "overlay",
"timeframe": null,
"default_parameters": {
"period": 12
},
@ -13,6 +14,11 @@
"max": 200,
"default": 12,
"description": "Period for EMA calculation"
},
"timeframe": {
"type": "string",
"default": null,
"description": "Indicator timeframe (e.g., '1h', '4h'). Null for chart timeframe."
}
},
"default_styling": {

View File

@ -3,6 +3,7 @@
"description": "Moving Average Convergence Divergence",
"type": "macd",
"display_type": "subplot",
"timeframe": null,
"default_parameters": {
"fast_period": 12,
"slow_period": 26,
@ -28,11 +29,17 @@
"min": 2,
"max": 30,
"default": 9,
"description": "Signal line period"
"description": "Signal line period for MACD"
},
"timeframe": {
"type": "string",
"default": null,
"description": "Indicator timeframe (e.g., '1h', '4h'). Null for chart timeframe."
}
},
"default_styling": {
"color": "#fd7e14",
"line_width": 2
"line_width": 2,
"macd_line_color": "#007bff"
}
}

View File

@ -3,6 +3,7 @@
"description": "RSI oscillator indicator",
"type": "rsi",
"display_type": "subplot",
"timeframe": null,
"default_parameters": {
"period": 14
},
@ -13,6 +14,11 @@
"max": 50,
"default": 14,
"description": "Period for RSI calculation"
},
"timeframe": {
"type": "string",
"default": null,
"description": "Indicator timeframe (e.g., '1h', '4h'). Null for chart timeframe."
}
},
"default_styling": {

View File

@ -3,6 +3,7 @@
"description": "Simple Moving Average indicator",
"type": "sma",
"display_type": "overlay",
"timeframe": null,
"default_parameters": {
"period": 20
},
@ -13,6 +14,11 @@
"max": 200,
"default": 20,
"description": "Period for SMA calculation"
},
"timeframe": {
"type": "string",
"default": null,
"description": "Indicator timeframe (e.g., '1h', '4h'). Null for chart timeframe."
}
},
"default_styling": {

View File

@ -0,0 +1,20 @@
{
"id": "ema_b869638d",
"name": "EMA 12 (15 minutes)",
"description": "",
"type": "ema",
"display_type": "overlay",
"parameters": {
"period": 12
},
"styling": {
"color": "#007bff",
"line_width": 2,
"opacity": 1.0,
"line_style": "solid"
},
"timeframe": "15m",
"visible": true,
"created_date": "2025-06-06T06:56:54.181578+00:00",
"modified_date": "2025-06-06T06:56:54.181578+00:00"
}

View File

@ -0,0 +1,20 @@
{
"id": "ema_bfbf3a1d",
"name": "EMA 12 (5 minutes)",
"description": "",
"type": "ema",
"display_type": "overlay",
"parameters": {
"period": 12
},
"styling": {
"color": "#007bff",
"line_width": 2,
"opacity": 1.0,
"line_style": "solid"
},
"timeframe": "5m",
"visible": true,
"created_date": "2025-06-06T07:02:34.613543+00:00",
"modified_date": "2025-06-06T07:02:34.613543+00:00"
}

View File

@ -15,7 +15,8 @@
"opacity": 1.0,
"line_style": "solid"
},
"timeframe": "1h",
"visible": true,
"created_date": "2025-06-04T04:16:35.459602+00:00",
"modified_date": "2025-06-04T04:16:35.459602+00:00"
"modified_date": "2025-06-06T07:03:58.642238+00:00"
}

View File

@ -96,6 +96,7 @@ def register_indicator_callbacks(app):
[State('indicator-name-input', 'value'),
State('indicator-type-dropdown', 'value'),
State('indicator-description-input', 'value'),
State('indicator-timeframe-dropdown', 'value'),
State('indicator-color-input', 'value'),
State('indicator-line-width-slider', 'value'),
# SMA parameters
@ -115,7 +116,7 @@ def register_indicator_callbacks(app):
State('edit-indicator-store', 'data')],
prevent_initial_call=True
)
def save_new_indicator(n_clicks, name, indicator_type, description, color, line_width,
def save_new_indicator(n_clicks, name, indicator_type, description, timeframe, color, line_width,
sma_period, ema_period, rsi_period,
macd_fast, macd_slow, macd_signal,
bb_period, bb_stddev, edit_data):
@ -161,7 +162,8 @@ def register_indicator_callbacks(app):
name=name,
description=description or "",
parameters=parameters,
styling={'color': color or "#007bff", 'line_width': line_width or 2}
styling={'color': color or "#007bff", 'line_width': line_width or 2},
timeframe=timeframe or None
)
if success:
@ -176,7 +178,8 @@ def register_indicator_callbacks(app):
indicator_type=indicator_type,
parameters=parameters,
description=description or "",
color=color or "#007bff"
color=color or "#007bff",
timeframe=timeframe or None
)
if not new_indicator:
@ -384,6 +387,7 @@ def register_indicator_callbacks(app):
Output('indicator-name-input', 'value'),
Output('indicator-type-dropdown', 'value'),
Output('indicator-description-input', 'value'),
Output('indicator-timeframe-dropdown', 'value'),
Output('indicator-color-input', 'value'),
Output('edit-indicator-store', 'data'),
# Add parameter field outputs
@ -403,7 +407,7 @@ def register_indicator_callbacks(app):
"""Load indicator data for editing."""
ctx = callback_context
if not ctx.triggered or not any(edit_clicks):
return no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update
return [no_update] * 15
# Find which button was clicked
triggered_id = ctx.triggered[0]['prop_id']
@ -418,41 +422,42 @@ def register_indicator_callbacks(app):
if indicator:
# Store indicator ID for update
edit_data = {'indicator_id': indicator_id, 'mode': 'edit', 'open_modal': True}
edit_data = {'indicator_id': indicator_id, 'mode': 'edit'}
# Extract parameter values based on indicator type
params = indicator.parameters
# Default parameter values
sma_period = 20
ema_period = 12
rsi_period = 14
macd_fast = 12
macd_slow = 26
macd_signal = 9
bb_period = 20
bb_stddev = 2.0
sma_period = None
ema_period = None
rsi_period = None
macd_fast = None
macd_slow = None
macd_signal = None
bb_period = None
bb_stddev = None
# Update with actual saved values
if indicator.type == 'sma':
sma_period = params.get('period', 20)
sma_period = params.get('period')
elif indicator.type == 'ema':
ema_period = params.get('period', 12)
ema_period = params.get('period')
elif indicator.type == 'rsi':
rsi_period = params.get('period', 14)
rsi_period = params.get('period')
elif indicator.type == 'macd':
macd_fast = params.get('fast_period', 12)
macd_slow = params.get('slow_period', 26)
macd_signal = params.get('signal_period', 9)
macd_fast = params.get('fast_period')
macd_slow = params.get('slow_period')
macd_signal = params.get('signal_period')
elif indicator.type == 'bollinger_bands':
bb_period = params.get('period', 20)
bb_stddev = params.get('std_dev', 2.0)
bb_period = params.get('period')
bb_stddev = params.get('std_dev')
return (
"✏️ Edit Indicator",
f"✏️ Edit Indicator: {indicator.name}",
indicator.name,
indicator.type,
indicator.description,
indicator.timeframe,
indicator.styling.color,
edit_data,
sma_period,
@ -465,17 +470,18 @@ def register_indicator_callbacks(app):
bb_stddev
)
else:
return no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update
return [no_update] * 15
except Exception as e:
logger.error(f"Indicator callback: Error loading indicator for edit: {e}")
return no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update
return [no_update] * 15
# Reset modal form when closed or saved
@app.callback(
[Output('indicator-name-input', 'value', allow_duplicate=True),
Output('indicator-type-dropdown', 'value', allow_duplicate=True),
Output('indicator-description-input', 'value', allow_duplicate=True),
Output('indicator-timeframe-dropdown', 'value', allow_duplicate=True),
Output('indicator-color-input', 'value', allow_duplicate=True),
Output('indicator-line-width-slider', 'value'),
Output('modal-title', 'children', allow_duplicate=True),
@ -494,9 +500,7 @@ def register_indicator_callbacks(app):
prevent_initial_call=True
)
def reset_modal_form(cancel_clicks, save_clicks):
"""Reset the modal form when it's closed or saved."""
if cancel_clicks or save_clicks:
return "", None, "", "#007bff", 2, "📊 Add New Indicator", None, 20, 12, 14, 12, 26, 9, 20, 2.0
return no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update, no_update
"""Reset the modal form to its default state."""
return "", "", "", "", "", 2, "📊 Add New Indicator", None, 20, 12, 14, 12, 26, 9, 20, 2.0
logger.info("Indicator callbacks: registered successfully")

View File

@ -562,21 +562,28 @@ def get_market_statistics(df: pd.DataFrame, symbol: str, timeframe: str) -> html
"""
Generate a comprehensive market statistics component from a DataFrame.
"""
if df.empty:
return html.Div("No data available for statistics.", className="text-center text-muted")
try:
volume_analyzer = VolumeAnalyzer()
# Get statistics
price_analyzer = PriceMovementAnalyzer()
volume_analyzer = VolumeAnalyzer()
volume_stats = volume_analyzer.get_volume_statistics(df)
price_stats = price_analyzer.get_price_movement_statistics(df)
volume_stats = volume_analyzer.get_volume_statistics(df)
if 'error' in volume_stats or 'error' in price_stats:
error_msg = volume_stats.get('error') or price_stats.get('error')
# Format key statistics for display
start_date = df.index.min().strftime('%Y-%m-%d %H:%M')
end_date = df.index.max().strftime('%Y-%m-%d %H:%M')
# Check for errors from analyzers
if 'error' in price_stats or 'error' in volume_stats:
error_msg = price_stats.get('error') or volume_stats.get('error')
return html.Div(f"Error generating statistics: {error_msg}", style={'color': 'red'})
# Time range for display
start_date = df['timestamp'].min().strftime('%Y-%m-%d %H:%M')
end_date = df['timestamp'].max().strftime('%Y-%m-%d %H:%M')
days_back = (df['timestamp'].max() - df['timestamp'].min()).days
days_back = (df.index.max() - df.index.min()).days
time_status = f"📅 Analysis Range: {start_date} to {end_date} (~{days_back} days)"
return html.Div([

View File

@ -33,6 +33,27 @@ def create_indicator_modal():
placeholder='Select indicator type',
), width=12)
], className="mb-3"),
dbc.Row([
dbc.Col(dbc.Label("Timeframe (Optional):"), width=12),
dbc.Col(dcc.Dropdown(
id='indicator-timeframe-dropdown',
options=[
{'label': 'Chart Timeframe', 'value': ''},
{'label': "1 Second", 'value': '1s'},
{'label': "5 Seconds", 'value': '5s'},
{'label': "15 Seconds", 'value': '15s'},
{'label': "30 Seconds", 'value': '30s'},
{'label': '1 Minute', 'value': '1m'},
{'label': '5 Minutes', 'value': '5m'},
{'label': '15 Minutes', 'value': '15m'},
{'label': '1 Hour', 'value': '1h'},
{'label': '4 Hours', 'value': '4h'},
{'label': '1 Day', 'value': '1d'},
],
value='',
placeholder='Defaults to chart timeframe'
), width=12),
], className="mb-3"),
dbc.Row([
dbc.Col(dbc.Label("Description (Optional):"), width=12),
dbc.Col(dcc.Textarea(

View File

@ -74,7 +74,7 @@ class TechnicalIndicators:
if self.logger:
self.logger.info("TechnicalIndicators: Initialized indicator calculator")
def prepare_dataframe(self, candles: List[OHLCVCandle]) -> pd.DataFrame:
def _prepare_dataframe_from_list(self, candles: List[OHLCVCandle]) -> pd.DataFrame:
"""
Convert OHLCV candles to pandas DataFrame for efficient calculations.
@ -112,20 +112,19 @@ class TechnicalIndicators:
return df
def sma(self, candles: List[OHLCVCandle], period: int,
def sma(self, df: pd.DataFrame, period: int,
price_column: str = 'close') -> List[IndicatorResult]:
"""
Calculate Simple Moving Average (SMA).
Args:
candles: List of OHLCV candles
df: DataFrame with OHLCV data
period: Number of periods for moving average
price_column: Price column to use ('open', 'high', 'low', 'close')
Returns:
List of indicator results with SMA values
"""
df = self.prepare_dataframe(candles)
if df.empty or len(df) < period:
return []
@ -147,20 +146,19 @@ class TechnicalIndicators:
return results
def ema(self, candles: List[OHLCVCandle], period: int,
def ema(self, df: pd.DataFrame, period: int,
price_column: str = 'close') -> List[IndicatorResult]:
"""
Calculate Exponential Moving Average (EMA).
Args:
candles: List of OHLCV candles
df: DataFrame with OHLCV data
period: Number of periods for moving average
price_column: Price column to use ('open', 'high', 'low', 'close')
Returns:
List of indicator results with EMA values
"""
df = self.prepare_dataframe(candles)
if df.empty or len(df) < period:
return []
@ -183,20 +181,19 @@ class TechnicalIndicators:
return results
def rsi(self, candles: List[OHLCVCandle], period: int = 14,
def rsi(self, df: pd.DataFrame, period: int = 14,
price_column: str = 'close') -> List[IndicatorResult]:
"""
Calculate Relative Strength Index (RSI).
Args:
candles: List of OHLCV candles
df: DataFrame with OHLCV data
period: Number of periods for RSI calculation (default 14)
price_column: Price column to use ('open', 'high', 'low', 'close')
Returns:
List of indicator results with RSI values
"""
df = self.prepare_dataframe(candles)
if df.empty or len(df) < period + 1:
return []
@ -234,14 +231,14 @@ class TechnicalIndicators:
return results
def macd(self, candles: List[OHLCVCandle],
def macd(self, df: pd.DataFrame,
fast_period: int = 12, slow_period: int = 26, signal_period: int = 9,
price_column: str = 'close') -> List[IndicatorResult]:
"""
Calculate Moving Average Convergence Divergence (MACD).
Args:
candles: List of OHLCV candles
df: DataFrame with OHLCV data
fast_period: Fast EMA period (default 12)
slow_period: Slow EMA period (default 26)
signal_period: Signal line EMA period (default 9)
@ -250,8 +247,7 @@ class TechnicalIndicators:
Returns:
List of indicator results with MACD, signal, and histogram values
"""
df = self.prepare_dataframe(candles)
if df.empty or len(df) < slow_period + signal_period:
if df.empty or len(df) < slow_period:
return []
# Calculate fast and slow EMAs
@ -271,7 +267,7 @@ class TechnicalIndicators:
results = []
for i, (timestamp, row) in enumerate(df.iterrows()):
# Only return results after minimum period
if i >= slow_period + signal_period - 1:
if i >= slow_period - 1:
if not (pd.isna(row['macd']) or pd.isna(row['signal']) or pd.isna(row['histogram'])):
result = IndicatorResult(
timestamp=timestamp,
@ -293,21 +289,20 @@ class TechnicalIndicators:
return results
def bollinger_bands(self, candles: List[OHLCVCandle], period: int = 20,
def bollinger_bands(self, df: pd.DataFrame, period: int = 20,
std_dev: float = 2.0, price_column: str = 'close') -> List[IndicatorResult]:
"""
Calculate Bollinger Bands.
Args:
candles: List of OHLCV candles
df: DataFrame with OHLCV data
period: Number of periods for moving average (default 20)
std_dev: Number of standard deviations for bands (default 2.0)
std_dev: Number of standard deviations (default 2.0)
price_column: Price column to use ('open', 'high', 'low', 'close')
Returns:
List of indicator results with upper band, middle band (SMA), and lower band
"""
df = self.prepare_dataframe(candles)
if df.empty or len(df) < period:
return []
@ -417,64 +412,53 @@ class TechnicalIndicators:
def calculate(self, indicator_type: str, candles: Union[pd.DataFrame, List[OHLCVCandle]], **kwargs) -> Optional[Dict[str, Any]]:
"""
Generic method to calculate any supported indicator by type.
Calculate a single indicator with dynamic dispatch.
Args:
indicator_type: The type of indicator to calculate (e.g., 'sma', 'ema').
candles: The input data, either a DataFrame or a list of OHLCVCandle objects.
**kwargs: Keyword arguments for the specific indicator function.
indicator_type: Name of the indicator (e.g., 'sma', 'ema')
candles: List of OHLCV candles or a pre-prepared DataFrame
**kwargs: Indicator-specific parameters (e.g., period=20)
Returns:
A dictionary containing the indicator results, or None if the type is unknown.
"""
# If input is a DataFrame, convert it to list of OHLCVCandle objects.
# This is a temporary adaptation to the existing methods.
# Future optimization should standardize on DataFrames.
if isinstance(candles, pd.DataFrame):
from .data_types import OHLCVCandle
# Ensure required columns are present
required_cols = {'open', 'high', 'low', 'close', 'volume'}
if not required_cols.issubset(candles.columns):
if self.logger:
self.logger.error("Indicators: DataFrame missing required columns for OHLCVCandle conversion.")
return None
symbol = kwargs.get('symbol', 'UNKNOWN')
timeframe = kwargs.get('timeframe', 'UNKNOWN')
candles_list = [
OHLCVCandle(
symbol=symbol,
timeframe=timeframe,
start_time=row['timestamp'],
end_time=row['timestamp'],
open=Decimal(str(row['open'])),
high=Decimal(str(row['high'])),
low=Decimal(str(row['low'])),
close=Decimal(str(row['close'])),
volume=Decimal(str(row['volume'])),
trade_count=int(row.get('trade_count', 0))
) for _, row in candles.iterrows()
]
candles = candles_list
# Get the indicator calculation method
indicator_method = getattr(self, indicator_type, None)
if indicator_method and callable(indicator_method):
# We need to construct a proper IndicatorResult object here
# For now, let's adapt to what the methods return
raw_result = indicator_method(candles, **kwargs)
if not indicator_method:
if self.logger:
self.logger.error(f"TechnicalIndicators: Unknown indicator type '{indicator_type}'")
return None
try:
# Prepare DataFrame if input is a list of candles
if isinstance(candles, list):
df = self._prepare_dataframe_from_list(candles)
elif isinstance(candles, pd.DataFrame):
df = candles
else:
raise TypeError("Input 'candles' must be a list of OHLCVCandle objects or a pandas DataFrame.")
if df.empty:
return {'data': [], 'metadata': {}}
# Call the indicator method
raw_result = indicator_method(df, **kwargs)
# Extract metadata from the first result if available
metadata = raw_result[0].metadata if raw_result else {}
# The methods return List[IndicatorResult], let's package that
if raw_result:
return {
"data": raw_result
"data": raw_result,
"metadata": metadata
}
return None
if self.logger:
self.logger.warning(f"TechnicalIndicators: Unknown indicator type '{indicator_type}'")
return None
except Exception as e:
if self.logger:
self.logger.error(f"TechnicalIndicators: Error calculating {indicator_type}: {e}")
return None
def create_default_indicators_config() -> Dict[str, Dict[str, Any]]:

View File

@ -1,175 +1,123 @@
# Technical Indicators Module
The Technical Indicators module provides comprehensive technical analysis capabilities for the TCP Trading Platform. It's designed to handle sparse OHLCV data efficiently and integrates seamlessly with the platform's aggregation strategy.
The Technical Indicators module provides a suite of common technical analysis tools. It is designed to work efficiently with pandas DataFrames, which is the standard data structure for time-series analysis in the TCP Trading Platform.
## Overview
The module implements five core technical indicators commonly used in trading:
The module has been refactored to be **DataFrame-centric**. All calculation methods now expect a pandas DataFrame with a `DatetimeIndex` and the required OHLCV columns (`open`, `high`, `low`, `close`, `volume`). This change simplifies the data pipeline, improves performance through vectorization, and ensures consistency across the platform.
- **Simple Moving Average (SMA)** - Average price over a specified period
- **Exponential Moving Average (EMA)** - Weighted average giving more importance to recent prices
- **Relative Strength Index (RSI)** - Momentum oscillator measuring speed and change of price movements
- **Moving Average Convergence Divergence (MACD)** - Trend-following momentum indicator
- **Bollinger Bands** - Volatility indicator with upper and lower bands around a moving average
The module implements five core technical indicators:
- **Simple Moving Average (SMA)**
- **Exponential Moving Average (EMA)**
- **Relative Strength Index (RSI)**
- **Moving Average Convergence Divergence (MACD)**
- **Bollinger Bands**
## Key Features
### Sparse Data Handling
- **No Interpolation**: Preserves gaps in timestamp data without artificial interpolation
- **Efficient Processing**: Uses pandas for vectorized calculations
- **Right-Aligned Timestamps**: Follows the platform's aggregation strategy convention
- **Robust Error Handling**: Gracefully handles insufficient data and edge cases
### Performance Optimized
- **Vectorized Calculations**: Leverages pandas and numpy for fast computation
- **Batch Processing**: Calculate multiple indicators simultaneously
- **Memory Efficient**: Processes data in chunks without excessive memory usage
### Flexible Configuration
- **JSON Configuration**: Define indicator parameters via configuration files
- **Multiple Price Columns**: Calculate indicators on open, high, low, or close prices
- **Custom Parameters**: Adjust periods, standard deviations, and other parameters
- **Validation**: Built-in configuration validation
- **DataFrame-Centric Design**: Operates directly on pandas DataFrames for performance and simplicity.
- **Vectorized Calculations**: Leverages pandas and numpy for high-speed computation.
- **Flexible `calculate` Method**: A single entry point for calculating any supported indicator by name.
- **Standardized Output**: All methods return a DataFrame containing the calculated indicator values, indexed by timestamp.
## Usage Examples
### Basic Usage
### Preparing the DataFrame
Before you can calculate indicators, you need a properly formatted pandas DataFrame. The `prepare_chart_data` utility is the recommended way to create one from a list of candle dictionaries.
```python
from components.charts.utils import prepare_chart_data
from data.common.indicators import TechnicalIndicators
from data.common.data_types import OHLCVCandle
# Initialize indicators calculator
indicators = TechnicalIndicators()
# Assume 'candles' is a list of OHLCV dictionaries from the database
# candles = fetch_market_data(...)
# Calculate Simple Moving Average
sma_results = indicators.sma(candles, period=20)
# Prepare the DataFrame
df = prepare_chart_data(candles)
# Calculate Exponential Moving Average
ema_results = indicators.ema(candles, period=12)
# Calculate RSI
rsi_results = indicators.rsi(candles, period=14)
# Calculate MACD
macd_results = indicators.macd(candles, fast_period=12, slow_period=26, signal_period=9)
# Calculate Bollinger Bands
bb_results = indicators.bollinger_bands(candles, period=20, std_dev=2.0)
# df is now ready for indicator calculations
# It has a DatetimeIndex and the necessary OHLCV columns.
```
### Multiple Indicators
### Basic Indicator Calculation
Once you have a prepared DataFrame, you can calculate indicators directly.
```python
# Define configuration for multiple indicators
config = {
'sma_20': {'type': 'sma', 'period': 20},
'sma_50': {'type': 'sma', 'period': 50},
'ema_12': {'type': 'ema', 'period': 12},
'rsi_14': {'type': 'rsi', 'period': 14},
'macd': {'type': 'macd'},
'bb_20': {'type': 'bollinger_bands', 'period': 20}
}
# Initialize the calculator
indicators = TechnicalIndicators()
# Calculate all indicators at once
results = indicators.calculate_multiple_indicators(candles, config)
# Calculate a Simple Moving Average
sma_df = indicators.sma(df, period=20)
# Access individual indicator results
sma_20_values = results['sma_20']
rsi_values = results['rsi_14']
macd_values = results['macd']
# Calculate an Exponential Moving Average
ema_df = indicators.ema(df, period=12)
# sma_df and ema_df are pandas DataFrames containing the results.
```
### Using the `calculate` Method
The most flexible way to compute an indicator is with the `calculate` method, which accepts the indicator type as a string.
```python
# Calculate RSI using the generic method
rsi_pkg = indicators.calculate('rsi', df, period=14)
if rsi_pkg:
rsi_df = rsi_pkg['data']
# Calculate MACD with custom parameters
macd_pkg = indicators.calculate('macd', df, fast_period=10, slow_period=30, signal_period=8)
if macd_pkg:
macd_df = macd_pkg['data']
```
### Using Different Price Columns
```python
# Calculate SMA on high prices instead of close
sma_high = indicators.sma(candles, period=20, price_column='high')
# Calculate EMA on low prices
ema_low = indicators.ema(candles, period=12, price_column='low')
# Calculate RSI on open prices
rsi_open = indicators.rsi(candles, period=14, price_column='open')
```
### Default Configuration
You can specify which price column (`open`, `high`, `low`, or `close`) to use for the calculation.
```python
from data.common.indicators import create_default_indicators_config
# Calculate SMA on the 'high' price
sma_high_df = indicators.sma(df, period=20, price_column='high')
# Get default configuration
default_config = create_default_indicators_config()
# Calculate using defaults
results = indicators.calculate_multiple_indicators(candles, default_config)
# Calculate RSI on the 'open' price
rsi_open_pkg = indicators.calculate('rsi', df, period=14, price_column='open')
```
## Indicator Details
The following details the parameters and the columns returned in the result DataFrame for each indicator.
### Simple Moving Average (SMA)
Calculates the arithmetic mean of prices over a specified period.
**Parameters:**
- `period`: Number of periods (default: 20)
- `price_column`: Price column to use (default: 'close')
**Returns:**
- `sma`: Simple moving average value
- **Parameters**: `period` (int), `price_column` (str, default: 'close')
- **Returned Columns**: `sma`
### Exponential Moving Average (EMA)
Calculates exponentially weighted moving average, giving more weight to recent prices.
**Parameters:**
- `period`: Number of periods (default: 20)
- `price_column`: Price column to use (default: 'close')
**Returns:**
- `ema`: Exponential moving average value
- **Parameters**: `period` (int), `price_column` (str, default: 'close')
- **Returned Columns**: `ema`
### Relative Strength Index (RSI)
Momentum oscillator that measures the speed and change of price movements.
**Parameters:**
- `period`: Number of periods (default: 14)
- `price_column`: Price column to use (default: 'close')
**Returns:**
- `rsi`: RSI value (0-100 range)
- **Parameters**: `period` (int), `price_column` (str, default: 'close')
- **Returned Columns**: `rsi`
### MACD (Moving Average Convergence Divergence)
Trend-following momentum indicator showing the relationship between two moving averages.
**Parameters:**
- `fast_period`: Fast EMA period (default: 12)
- `slow_period`: Slow EMA period (default: 26)
- `signal_period`: Signal line EMA period (default: 9)
- `price_column`: Price column to use (default: 'close')
**Returns:**
- `macd`: MACD line (fast EMA - slow EMA)
- `signal`: Signal line (EMA of MACD)
- `histogram`: MACD histogram (MACD - Signal)
- **Parameters**: `fast_period` (int), `slow_period` (int), `signal_period` (int), `price_column` (str, default: 'close')
- **Returned Columns**: `macd`, `signal`, `histogram`
### Bollinger Bands
Volatility indicator consisting of a moving average and two standard deviation bands.
- **Parameters**: `period` (int), `std_dev` (float), `price_column` (str, default: 'close')
- **Returned Columns**: `upper_band`, `middle_band`, `lower_band`
**Parameters:**
- `period`: Number of periods for moving average (default: 20)
- `std_dev`: Number of standard deviations (default: 2.0)
- `price_column`: Price column to use (default: 'close')
## Integration with the TCP Platform
**Returns:**
- `upper_band`: Upper Bollinger Band
- `middle_band`: Middle band (SMA)
- `lower_band`: Lower Bollinger Band
- `bandwidth`: Band width relative to middle band
- `percent_b`: %B indicator (position within bands)
The refactored `TechnicalIndicators` module is now tightly integrated with the `ChartBuilder`, which handles all data preparation and calculation automatically when indicators are added to a chart. For custom analysis or strategy development, you can use the class directly as shown in the examples above. The key is to always start with a properly prepared DataFrame using `prepare_chart_data`.
## Data Structures

View File

@ -0,0 +1,38 @@
## Relevant Files
- `config/indicators/templates/*.json` - Indicator configuration templates to be updated with the new `timeframe` field.
- `components/charts/indicator_manager.py` - To add `timeframe` to the `UserIndicator` dataclass and related methods.
- `dashboard/layouts/market_data.py` - To add UI elements for selecting the indicator timeframe.
- `dashboard/callbacks/indicators.py` - To handle the new `timeframe` input from the UI.
- `components/charts/data_integration.py` - To implement the core logic for fetching data and calculating indicators on different timeframes.
- `components/charts/builder.py` - To ensure the new indicator data is correctly passed to the chart.
### Notes
- The core of the changes will be in `components/charts/data_integration.py`.
- Careful data alignment (reindexing and forward-filling) will be crucial for correct visualization.
## Tasks
- [x] 1.0 Update Indicator Configuration
- [x] 1.1 Add an optional `timeframe` field to all JSON templates in `config/indicators/templates/`.
- [x] 1.2 Update the `UserIndicator` dataclass in `components/charts/indicator_manager.py` to include `timeframe: Optional[str]`.
- [x] 1.3 Modify `create_indicator` in `IndicatorManager` to accept a `timeframe` parameter.
- [x] 1.4 Update `UserIndicator.from_dict` and `to_dict` to handle the new `timeframe` field.
- [x] 2.0 Implement Multi-Timeframe Data Fetching and Calculation
- [x] 2.1 In `components/charts/data_integration.py`, modify `get_indicator_data`.
- [x] 2.2 If a custom timeframe is present, call `get_market_data_for_indicators` to fetch new data.
- [x] 2.3 If no custom timeframe is set, use the existing `main_df`.
- [x] 2.4 Pass the correct DataFrame to `self.indicators.calculate`.
- [x] 3.0 Align and Merge Indicator Data for Plotting
- [x] 3.1 After calculation, reindex the indicator DataFrame to match the `main_df`'s timestamp index.
- [x] 3.2 Use forward-fill (`ffill`) to handle missing values from reindexing.
- [x] 3.3 Add the aligned data to `indicator_data_map`.
- [x] 4.0 Update UI for Indicator Timeframe Selection
- [x] 4.1 In `dashboard/layouts/market_data.py`, add a `dcc.Dropdown` for timeframe selection in the indicator modal.
- [x] 4.2 In `dashboard/callbacks/indicators.py`, update the save indicator callback to read the timeframe value.
- [x] 4.3 Pass the selected timeframe to `indicator_manager.create_indicator` or `update_indicator`.
- [ ] 5.0 Testing and Validation
- [ ] 5.1 Write unit tests for custom timeframe data fetching and alignment.
- [ ] 5.2 Manually test creating and viewing indicators with various timeframes (higher, lower, and same as chart).
- [ ] 5.3 Verify visual correctness and data integrity on the chart.

View File

@ -0,0 +1,36 @@
## Relevant Files
- `data/common/indicators.py` - This is the primary file to be refactored. The `TechnicalIndicators` class will be modified to be DataFrame-centric.
- `components/charts/utils.py` - The `prepare_chart_data` function in this file needs to be corrected to ensure it properly creates and returns a DataFrame with a `DatetimeIndex`.
- `components/charts/data_integration.py` - This file's `get_indicator_data` method will be simplified to pass the correctly prepared DataFrame to the calculation engine.
- `app_new.py` - The main application file, which will be used to run the dashboard and perform end-to-end testing.
### Notes
- The goal of this refactoring is to create a more robust and maintainable data pipeline for indicator calculations, preventing recurring data type and index errors.
- Pay close attention to ensuring that DataFrames have a consistent `DatetimeIndex` with proper timezone information throughout the pipeline.
## Tasks
- [x] 1.0 Refactor `TechnicalIndicators` Class in `data/common/indicators.py` to be DataFrame-centric.
- [x] 1.1 Modify `sma`, `ema`, `rsi`, `macd`, and `bollinger_bands` methods to accept a pre-formatted DataFrame as their primary input, not a list of candles.
- [x] 1.2 Remove the redundant `prepare_dataframe` call from within each individual indicator method.
- [x] 1.3 Rename `prepare_dataframe` to `_prepare_dataframe_from_list` to signify its new role as a private helper for converting list-based data.
- [x] 1.4 Update the main `calculate` method to be the single point of data preparation, handling both DataFrame and list inputs.
- [x] 2.0 Correct DataFrame Preparation in `components/charts/utils.py`.
- [x] 2.1 Review the `prepare_chart_data` function to identify why the `DatetimeIndex` is being dropped.
- [x] 2.2 Modify the function to ensure it returns a DataFrame with the `timestamp` column correctly set as the index, without a `reset_index()` call at the end.
- [x] 3.0 Simplify Data Flow in `components/charts/data_integration.py`.
- [x] 3.1 In the `get_indicator_data` function, remove the workaround that converts the DataFrame to a list of dictionaries (`to_dict('records')`).
- [x] 3.2 Ensure the function correctly handles both main and custom timeframes, passing the appropriate DataFrame to the calculation engine.
- [x] 3.3 Verify that the final `reindex` operation works correctly with the consistent DataFrame structure.
- [x] 4.0 End-to-End Testing and Validation.
- [x] 4.1 Run the dashboard and test the indicator plotting functionality with both matching and custom timeframes.
- [x] 4.2 Verify that no new errors appear in the console during chart interaction.
- [x] 5.0 Update Indicators documentation to reflect the new DataFrame-centric approach.
- [x] 5.1 Review the documentation in the `/docs` directory related to indicators.
- [x] 5.2 Update the documentation to explain that the calculation engine now uses DataFrames.
- [x] 5.3 Provide clear examples of how to use the refactored `TechnicalIndicators` class.