- Deleted `app_new.py`, which was previously the main entry point for the dashboard application, to streamline the codebase. - Consolidated the application initialization and callback registration logic into `main.py`, enhancing modularity and maintainability. - Updated the logging and error handling practices in `main.py` to ensure consistent application behavior and improved debugging capabilities. These changes simplify the application structure, aligning with project standards for modularity and maintainability.
427 lines
17 KiB
Python
427 lines
17 KiB
Python
"""
|
|
Data analysis components for comprehensive market data analysis.
|
|
"""
|
|
|
|
from dash import html, dcc
|
|
import dash_bootstrap_components as dbc
|
|
import pandas as pd
|
|
import numpy as np
|
|
from datetime import datetime, timezone, timedelta
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
from utils.logger import get_logger
|
|
from database.connection import DatabaseManager
|
|
from database.operations import DatabaseOperationError, get_database_operations
|
|
from config.constants.chart_constants import CHART_COLORS, UI_TEXT
|
|
|
|
logger = get_logger("data_analysis")
|
|
|
|
|
|
class VolumeAnalyzer:
|
|
"""Analyze trading volume patterns and trends."""
|
|
|
|
def __init__(self):
|
|
pass
|
|
|
|
def get_volume_statistics(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
"""Calculate comprehensive volume statistics from a DataFrame."""
|
|
try:
|
|
if df.empty or 'volume' not in df.columns:
|
|
return {'error': 'DataFrame is empty or missing volume column'}
|
|
|
|
df = df.copy()
|
|
df = self._ensure_numeric_cols(df)
|
|
|
|
stats = {}
|
|
stats.update(self._calculate_basic_volume_stats(df))
|
|
stats.update(self._analyze_volume_trend(df))
|
|
stats.update(self._identify_high_volume_periods(df, stats['avg_volume'], stats['volume_std']))
|
|
stats.update(self._calculate_volume_price_correlation(df))
|
|
stats.update(self._calculate_avg_trade_size(df))
|
|
stats.update(self._calculate_volume_percentiles(df))
|
|
|
|
return stats
|
|
|
|
except Exception as e:
|
|
logger.error(f"Volume analysis error: {e}")
|
|
return {'error': str(e)}
|
|
|
|
def _ensure_numeric_cols(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
numeric_cols = ['open', 'high', 'low', 'close', 'volume']
|
|
for col in numeric_cols:
|
|
if col in df.columns:
|
|
df[col] = df[col].astype(float)
|
|
if 'trades_count' in df.columns:
|
|
df['trades_count'] = df['trades_count'].astype(float)
|
|
return df
|
|
|
|
def _calculate_basic_volume_stats(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
return {
|
|
'total_volume': df['volume'].sum(),
|
|
'avg_volume': df['volume'].mean(),
|
|
'volume_std': df['volume'].std(),
|
|
'max_volume': df['volume'].max(),
|
|
'min_volume': df['volume'].min()
|
|
}
|
|
|
|
def _analyze_volume_trend(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
recent_volume = df['volume'].tail(10).mean()
|
|
older_volume = df['volume'].head(10).mean()
|
|
volume_trend = "Increasing" if recent_volume > older_volume else "Decreasing"
|
|
return {'volume_trend': volume_trend}
|
|
|
|
def _identify_high_volume_periods(self, df: pd.DataFrame, avg_volume: float, volume_std: float) -> Dict[str, Any]:
|
|
high_volume_threshold = avg_volume + (2 * volume_std)
|
|
high_volume_periods = len(df[df['volume'] > high_volume_threshold])
|
|
return {'high_volume_periods': high_volume_periods}
|
|
|
|
def _calculate_volume_price_correlation(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
price_change = df['close'] - df['open']
|
|
volume_price_corr = df['volume'].corr(price_change.abs())
|
|
return {'volume_price_correlation': volume_price_corr}
|
|
|
|
def _calculate_avg_trade_size(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
if 'trades_count' in df.columns:
|
|
df['avg_trade_size'] = df['volume'] / df['trades_count'].replace(0, 1)
|
|
avg_trade_size = df['avg_trade_size'].mean()
|
|
else:
|
|
avg_trade_size = None
|
|
return {'avg_trade_size': avg_trade_size}
|
|
|
|
def _calculate_volume_percentiles(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
return {
|
|
'volume_percentiles': {
|
|
'25th': df['volume'].quantile(0.25),
|
|
'50th': df['volume'].quantile(0.50),
|
|
'75th': df['volume'].quantile(0.75),
|
|
'95th': df['volume'].quantile(0.95)
|
|
}
|
|
}
|
|
|
|
|
|
class PriceMovementAnalyzer:
|
|
"""Analyze price movement patterns and statistics."""
|
|
|
|
def __init__(self):
|
|
pass
|
|
|
|
def get_price_movement_statistics(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
"""Calculate comprehensive price movement statistics from a DataFrame."""
|
|
try:
|
|
if df.empty or not all(col in df.columns for col in ['open', 'high', 'low', 'close']):
|
|
return {'error': 'DataFrame is empty or missing required price columns'}
|
|
|
|
df = df.copy()
|
|
df = self._ensure_numeric_cols(df)
|
|
|
|
stats = {}
|
|
stats.update(self._calculate_basic_price_stats(df))
|
|
stats.update(self._calculate_returns_and_volatility(df))
|
|
stats.update(self._analyze_price_range(df))
|
|
stats.update(self._analyze_directional_movement(df))
|
|
stats.update(self._calculate_price_extremes(df))
|
|
stats.update(self._calculate_momentum(df))
|
|
stats.update(self._calculate_trend_strength(df))
|
|
stats.update(self._calculate_return_percentiles(df))
|
|
|
|
return stats
|
|
|
|
except Exception as e:
|
|
logger.error(f"Price movement analysis error: {e}")
|
|
return {'error': str(e)}
|
|
|
|
def _ensure_numeric_cols(self, df: pd.DataFrame) -> pd.DataFrame:
|
|
numeric_cols = ['open', 'high', 'low', 'close', 'volume']
|
|
for col in numeric_cols:
|
|
if col in df.columns:
|
|
df[col] = df[col].astype(float)
|
|
return df
|
|
|
|
def _calculate_basic_price_stats(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
current_price = df['close'].iloc[-1]
|
|
period_start_price = df['open'].iloc[0]
|
|
period_return = ((current_price - period_start_price) / period_start_price) * 100
|
|
return {'current_price': current_price, 'period_return': period_return}
|
|
|
|
def _calculate_returns_and_volatility(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
df['returns'] = df['close'].pct_change() * 100
|
|
df['returns'] = df['returns'].fillna(0)
|
|
volatility = df['returns'].std()
|
|
avg_return = df['returns'].mean()
|
|
return {'volatility': volatility, 'avg_return': avg_return, 'returns': df['returns']}
|
|
|
|
def _analyze_price_range(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
df['range'] = df['high'] - df['low']
|
|
df['range_pct'] = (df['range'] / df['open']) * 100
|
|
avg_range_pct = df['range_pct'].mean()
|
|
return {'avg_range_pct': avg_range_pct}
|
|
|
|
def _analyze_directional_movement(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
bullish_periods = len(df[df['close'] > df['open']])
|
|
bearish_periods = len(df[df['close'] < df['open']])
|
|
neutral_periods = len(df[df['close'] == df['open']])
|
|
total_periods = len(df)
|
|
bullish_ratio = (bullish_periods / total_periods) * 100 if total_periods > 0 else 0
|
|
return {
|
|
'bullish_periods': bullish_periods,
|
|
'bearish_periods': bearish_periods,
|
|
'neutral_periods': neutral_periods,
|
|
'bullish_ratio': bullish_ratio,
|
|
'positive_returns': len(df[df['returns'] > 0]),
|
|
'negative_returns': len(df[df['returns'] < 0])
|
|
}
|
|
|
|
def _calculate_price_extremes(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
period_high = df['high'].max()
|
|
period_low = df['low'].min()
|
|
return {'period_high': period_high, 'period_low': period_low}
|
|
|
|
def _calculate_momentum(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
current_price = df['close'].iloc[-1]
|
|
momentum_periods = min(10, len(df) - 1)
|
|
if momentum_periods > 0:
|
|
momentum = ((current_price - df['close'].iloc[-momentum_periods-1]) / df['close'].iloc[-momentum_periods-1]) * 100
|
|
else:
|
|
momentum = 0
|
|
return {'momentum': momentum}
|
|
|
|
def _calculate_trend_strength(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
if len(df) > 2:
|
|
x = np.arange(len(df))
|
|
slope, _ = np.polyfit(x, df['close'], 1)
|
|
trend_strength = slope / df['close'].mean() * 100
|
|
else:
|
|
trend_strength = 0
|
|
return {'trend_strength': trend_strength}
|
|
|
|
def _calculate_return_percentiles(self, df: pd.DataFrame) -> Dict[str, Any]:
|
|
return {
|
|
'return_percentiles': {
|
|
'5th': df['returns'].quantile(0.05),
|
|
'25th': df['returns'].quantile(0.25),
|
|
'75th': df['returns'].quantile(0.75),
|
|
'95th': df['returns'].quantile(0.95)
|
|
},
|
|
'max_gain': df['returns'].max(),
|
|
'max_loss': df['returns'].min()
|
|
}
|
|
|
|
|
|
def format_number(value: float, decimals: int = 2) -> str:
|
|
"""Formats a number to a string with specified decimals."""
|
|
if value is None:
|
|
return "N/A"
|
|
return f"{value:,.{decimals}f}"
|
|
|
|
|
|
def _create_stat_card(icon, title, value, color="primary") -> dbc.Card: # Extracted helper
|
|
return dbc.Card(
|
|
dbc.CardBody(
|
|
[
|
|
html.H4(title, className="card-title"),
|
|
html.P(value, className="card-text"),
|
|
html.I(className=f"fas fa-{icon} text-{color}"),
|
|
]
|
|
),
|
|
className=f"text-center m-1 bg-light border-{color}"
|
|
)
|
|
|
|
|
|
def create_volume_stats_display(stats: Dict[str, Any]) -> html.Div:
|
|
"""Creates a display for volume statistics."""
|
|
if 'error' in stats:
|
|
return html.Div(f"Error: {stats['error']}", className="alert alert-danger")
|
|
|
|
return html.Div(
|
|
[
|
|
html.H3("Volume Statistics", className="mb-3 text-primary"),
|
|
dbc.Row([
|
|
dbc.Col(_create_stat_card("chart-bar", "Total Volume", format_number(stats.get('total_volume')), "success"), md=6),
|
|
dbc.Col(_create_stat_card("calculator", "Avg. Volume", format_number(stats.get('avg_volume')), "info"), md=6),
|
|
]),
|
|
dbc.Row([
|
|
dbc.Col(_create_stat_card("arrow-trend-up", "Volume Trend", stats.get('volume_trend'), "warning"), md=6),
|
|
dbc.Col(_create_stat_card("hand-holding-usd", "Avg. Trade Size", format_number(stats.get('avg_trade_size')), "secondary"), md=6),
|
|
]),
|
|
dbc.Row([
|
|
dbc.Col(_create_stat_card("ranking-star", "High Vol. Periods", stats.get('high_volume_periods')), md=6),
|
|
dbc.Col(_create_stat_card("arrows-left-right", "Vol-Price Corr.", format_number(stats.get('volume_price_correlation'), 4), "primary"), md=6),
|
|
]),
|
|
]
|
|
)
|
|
|
|
|
|
def create_price_stats_display(stats: Dict[str, Any]) -> html.Div:
|
|
"""Creates a display for price movement statistics."""
|
|
if 'error' in stats:
|
|
return html.Div(f"Error: {stats['error']}", className="alert alert-danger")
|
|
|
|
return html.Div(
|
|
[
|
|
html.H3("Price Movement Statistics", className="mb-3 text-success"),
|
|
dbc.Row([
|
|
dbc.Col(_create_stat_card("dollar-sign", "Current Price", format_number(stats.get('current_price')), "success"), md=6),
|
|
dbc.Col(_create_stat_card("percent", "Period Return", f"{format_number(stats.get('period_return'))}%"), md=6),
|
|
]),
|
|
dbc.Row([
|
|
dbc.Col(_create_stat_card("wave-square", "Volatility", f"{format_number(stats.get('volatility'))}%"), md=6),
|
|
dbc.Col(_create_stat_card("chart-line", "Avg. Daily Return", f"{format_number(stats.get('avg_return'))}%"), md=6),
|
|
]),
|
|
dbc.Row([
|
|
dbc.Col(_create_stat_card("arrows-up-down-left-right", "Avg. Range %", f"{format_number(stats.get('avg_range_pct'))}%"), md=6),
|
|
dbc.Col(_create_stat_card("arrow-up", "Bullish Ratio", f"{format_number(stats.get('bullish_ratio'))}%"), md=6),
|
|
]),
|
|
]
|
|
)
|
|
|
|
|
|
def get_market_statistics(df: pd.DataFrame, symbol: str, timeframe: str) -> html.Div:
|
|
"""
|
|
Generates a display of key market statistics from the provided DataFrame.
|
|
"""
|
|
if df.empty:
|
|
return html.Div([html.P("No market data available for statistics.")], className="alert alert-info mt-4")
|
|
|
|
# Basic Market Overview
|
|
first_timestamp = df.index.min()
|
|
last_timestamp = df.index.max()
|
|
num_candles = len(df)
|
|
|
|
# Price Changes
|
|
first_close = df['close'].iloc[0]
|
|
last_close = df['close'].iloc[-1]
|
|
price_change_abs = last_close - first_close
|
|
price_change_pct = (price_change_abs / first_close) * 100 if first_close != 0 else 0
|
|
|
|
# Highs and Lows
|
|
period_high = df['high'].max()
|
|
period_low = df['low'].min()
|
|
|
|
# Average True Range (ATR) - A measure of volatility
|
|
# Requires TA-Lib or manual calculation. For simplicity, we'll use a basic range for now.
|
|
# Ideally, integrate a proper TA library.
|
|
df['tr'] = np.maximum(df['high'] - df['low'],
|
|
np.maximum(abs(df['high'] - df['close'].shift()),
|
|
abs(df['low'] - df['close'].shift())))
|
|
atr = df['tr'].mean() if not df['tr'].empty else 0
|
|
|
|
# Trading Volume Analysis
|
|
total_volume = df['volume'].sum()
|
|
average_volume = df['volume'].mean()
|
|
|
|
# Market Cap (placeholder - requires external data)
|
|
market_cap_info = "N/A (requires external API)"
|
|
|
|
# Order Book Depth (placeholder - requires real-time order book data)
|
|
order_book_depth = "N/A (requires real-time data)"
|
|
|
|
stats_content = html.Div([
|
|
html.H3(f"Market Statistics for {symbol} ({timeframe})", className="mb-3 text-info"),
|
|
_create_basic_market_overview(
|
|
first_timestamp, last_timestamp, num_candles,
|
|
first_close, last_close, price_change_abs, price_change_pct,
|
|
total_volume, average_volume, atr
|
|
),
|
|
html.Hr(className="my-4"),
|
|
_create_advanced_market_stats(
|
|
period_high, period_low, market_cap_info, order_book_depth
|
|
)
|
|
], className="mb-4")
|
|
|
|
return stats_content
|
|
|
|
def _create_basic_market_overview(
|
|
first_timestamp: datetime, last_timestamp: datetime, num_candles: int,
|
|
first_close: float, last_close: float, price_change_abs: float, price_change_pct: float,
|
|
total_volume: float, average_volume: float, atr: float
|
|
) -> dbc.Row:
|
|
return dbc.Row([
|
|
dbc.Col(
|
|
dbc.Card(
|
|
dbc.CardBody(
|
|
[
|
|
html.H4("Time Period", className="card-title"),
|
|
html.P(f"From: {first_timestamp.strftime('%Y-%m-%d %H:%M')}"),
|
|
html.P(f"To: {last_timestamp.strftime('%Y-%m-%d %H:%M')}"),
|
|
html.P(f"Candles: {num_candles}"),
|
|
]
|
|
),
|
|
className="text-center m-1 bg-light border-info"
|
|
),
|
|
md=4
|
|
),
|
|
dbc.Col(
|
|
dbc.Card(
|
|
dbc.CardBody(
|
|
[
|
|
html.H4("Price Movement", className="card-title"),
|
|
html.P(f"Initial Price: {format_number(first_close)}"),
|
|
html.P(f"Final Price: {format_number(last_close)}"),
|
|
html.P(f"Change: {format_number(price_change_abs)} ({format_number(price_change_pct)}%)",
|
|
style={'color': 'green' if price_change_pct >= 0 else 'red'}),
|
|
]
|
|
),
|
|
className="text-center m-1 bg-light border-info"
|
|
),
|
|
md=4
|
|
),
|
|
dbc.Col(
|
|
dbc.Card(
|
|
dbc.CardBody(
|
|
[
|
|
html.H4("Volume & Volatility", className="card-title"),
|
|
html.P(f"Total Volume: {format_number(total_volume)}"),
|
|
html.P(f"Average Volume: {format_number(average_volume)}"),
|
|
html.P(f"Average True Range: {format_number(atr, 4)}"),
|
|
]
|
|
),
|
|
className="text-center m-1 bg-light border-info"
|
|
),
|
|
md=4
|
|
),
|
|
])
|
|
|
|
def _create_advanced_market_stats(
|
|
period_high: float, period_low: float, market_cap_info: str, order_book_depth: str
|
|
) -> dbc.Row:
|
|
return dbc.Row([
|
|
dbc.Col(
|
|
dbc.Card(
|
|
dbc.CardBody(
|
|
[
|
|
html.H4("Period Extremes", className="card-title"),
|
|
html.P(f"Period High: {format_number(period_high)}"),
|
|
html.P(f"Period Low: {format_number(period_low)}"),
|
|
]
|
|
),
|
|
className="text-center m-1 bg-light border-warning"
|
|
),
|
|
md=4
|
|
),
|
|
dbc.Col(
|
|
dbc.Card(
|
|
dbc.CardBody(
|
|
[
|
|
html.H4("Liquidity/Depth", className="card-title"),
|
|
html.P(f"Market Cap: {market_cap_info}"),
|
|
html.P(f"Order Book Depth: {order_book_depth}"),
|
|
]
|
|
),
|
|
className="text-center m-1 bg-light border-warning"
|
|
),
|
|
md=4
|
|
),
|
|
dbc.Col(
|
|
dbc.Card(
|
|
dbc.CardBody(
|
|
[
|
|
html.H4("Custom Indicators", className="card-title"),
|
|
html.P("RSI: N/A"), # Placeholder
|
|
html.P("MACD: N/A"), # Placeholder
|
|
]
|
|
),
|
|
className="text-center m-1 bg-light border-warning"
|
|
),
|
|
md=4
|
|
)
|
|
]) |