Files
2025-08-29 11:10:28 +02:00

435 lines
15 KiB
Python

# viz/fx_correlation.py
import streamlit as st
import pandas as pd
import numpy as np
import yfinance as yf
from datetime import datetime, timedelta
import plotly.express as px
import plotly.graph_objects as go
from itertools import combinations
# === CONFIG (Same as your other tools) ===
CURRENCY_LIST = ['USD','CAD', 'EUR', 'GBP', 'CHF', 'NOK', 'SGD','JPY', 'AUD', 'NZD']
def generate_major_pairs():
"""Generate list of major FX pairs that actually exist in YFinance"""
# Start with known working major pairs
major_pairs = [
# USD pairs (these definitely work)
'EURUSD=X', 'GBPUSD=X', 'AUDUSD=X', 'NZDUSD=X',
'USDCAD=X', 'USDCHF=X', 'USDJPY=X', 'USDSGD=X',
# Major crosses (tested to work)
'EURGBP=X', 'EURJPY=X', 'EURCHF=X', 'EURAUD=X',
'GBPJPY=X', 'GBPCHF=X', 'GBPAUD=X',
'AUDJPY=X', 'AUDCAD=X', 'AUDCHF=X',
'NZDJPY=X', 'NZDCAD=X', 'NZDCHF=X',
'CADJPY=X', 'CADCHF=X',
'CHFJPY=X'
]
return major_pairs
def get_historical_data(ticker, days=30):
"""Get historical price data for correlation calculation"""
try:
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
data = yf.download(
ticker,
start=start_date,
end=end_date,
interval="1d",
progress=False,
auto_adjust=False
)
if len(data) < 5: # Need minimum data points
return None
# Calculate daily returns (percentage change)
# Ensure we get a proper Series, not DataFrame
close_prices = data['Close']
if isinstance(close_prices, pd.DataFrame):
close_prices = close_prices.iloc[:, 0] # Get first column if DataFrame
returns = close_prices.pct_change().dropna()
# Verify we have a proper Series
if isinstance(returns, pd.DataFrame):
returns = returns.iloc[:, 0] # Convert to Series if still DataFrame
return returns
except Exception as e:
print(f"[⚠️] Error fetching {ticker}: {e}")
return None
def calculate_correlation_matrix(pairs, time_period=30):
"""Calculate correlation matrix for all FX pairs"""
# Progress tracking
progress_bar = st.progress(0)
status_text = st.empty()
# Dictionary to store returns data
returns_data = {}
failed_pairs = []
status_text.text("📊 Fetching historical data...")
# Fetch data for all pairs
for i, pair in enumerate(pairs):
pair_name = pair.replace('=X', '')
status_text.text(f"Fetching {pair_name}...")
returns = get_historical_data(pair, time_period)
if returns is not None and len(returns) >= 5: # Need minimum 5 data points
returns_data[pair_name] = returns
print(f"✅ {pair_name}: {len(returns)} data points")
else:
failed_pairs.append(pair_name)
print(f"❌ {pair_name}: Failed or insufficient data")
progress_bar.progress((i + 1) / len(pairs))
# Clear progress indicators
progress_bar.empty()
# Debug info
st.write(f"**Debug:** Successfully fetched {len(returns_data)} pairs, {len(failed_pairs)} failed")
if failed_pairs:
st.write(f"**Failed pairs:** {', '.join(failed_pairs[:5])}")
if len(returns_data) < 2:
st.error("❌ Need at least 2 currency pairs with valid data")
return None, None
status_text.text("🧮 Calculating correlations...")
# Find common date range across all pairs
common_dates = None
for pair_name, returns in returns_data.items():
if common_dates is None:
common_dates = returns.index
else:
common_dates = common_dates.intersection(returns.index)
st.write(f"**Debug:** Found {len(common_dates)} common trading days")
if len(common_dates) < 5:
st.error("❌ Not enough common trading days across pairs")
return None, None
# Align all returns to common dates and verify data structure
aligned_returns = {}
for pair_name, returns in returns_data.items():
aligned_data = returns.loc[common_dates]
if len(aligned_data) > 0 and not aligned_data.empty:
aligned_returns[pair_name] = aligned_data
st.write(f"**Debug:** {len(aligned_returns)} pairs aligned successfully")
if len(aligned_returns) < 2:
st.error("❌ Not enough pairs after alignment")
return None, None
# Create DataFrame with explicit index
try:
returns_df = pd.DataFrame(aligned_returns, index=common_dates)
st.write(f"**Debug:** DataFrame created: {returns_df.shape}")
# Calculate correlation matrix
correlation_matrix = returns_df.corr()
# Calculate summary stats
summary_stats = analyze_correlations(correlation_matrix)
status_text.empty()
return correlation_matrix, summary_stats
except Exception as e:
st.error(f"❌ Error creating correlation matrix: {e}")
status_text.empty()
return None, None
def analyze_correlations(corr_matrix):
"""Analyze correlation matrix for trading insights"""
# Get upper triangle (avoid duplicate pairs)
mask = np.triu(np.ones_like(corr_matrix), k=1).astype(bool)
upper_triangle = corr_matrix.where(mask)
# Find strongest correlations
correlations_list = []
for i in range(len(corr_matrix.columns)):
for j in range(i+1, len(corr_matrix.columns)):
pair1 = corr_matrix.columns[i]
pair2 = corr_matrix.columns[j]
corr_value = corr_matrix.iloc[i, j]
if not pd.isna(corr_value):
correlations_list.append({
'Pair_1': pair1,
'Pair_2': pair2,
'Correlation': corr_value,
'Abs_Correlation': abs(corr_value)
})
correlations_df = pd.DataFrame(correlations_list)
if len(correlations_df) == 0:
return None
# Sort by absolute correlation strength
correlations_df = correlations_df.sort_values('Abs_Correlation', ascending=False)
return {
'strongest_positive': correlations_df[correlations_df['Correlation'] > 0].head(5),
'strongest_negative': correlations_df[correlations_df['Correlation'] < 0].head(5),
'very_correlated': correlations_df[correlations_df['Abs_Correlation'] >= 0.75],
'uncorrelated': correlations_df[correlations_df['Abs_Correlation'] <= 0.25].head(5),
'avg_correlation': correlations_df['Abs_Correlation'].mean()
}
def create_correlation_heatmap(corr_matrix, time_period):
"""Create beautiful correlation heatmap"""
# Custom colorscale: Red (negative) -> White (neutral) -> Green (positive)
colorscale = [
[0.0, "#F8696B"], # Strong negative (Red)
[0.25, "#FFB6C1"], # Weak negative (Light Red)
[0.5, "#FFFFFF"], # No correlation (White)
[0.75, "#90EE90"], # Weak positive (Light Green)
[1.0, "#63BE7B"] # Strong positive (Green)
]
# Create annotations for correlation values
annotations = []
for i, row in enumerate(corr_matrix.index):
for j, col in enumerate(corr_matrix.columns):
value = corr_matrix.iloc[i, j]
if not pd.isna(value):
# Color text based on correlation strength for readability
text_color = "white" if abs(value) > 0.6 else "black"
annotations.append(
dict(
x=j, y=i,
text=f"{value:.2f}",
showarrow=False,
font=dict(color=text_color, size=10, family="Arial Black")
)
)
fig = go.Figure(data=go.Heatmap(
z=corr_matrix.values,
x=corr_matrix.columns,
y=corr_matrix.index,
colorscale=colorscale,
zmid=0, # Center the colorscale at 0
zmin=-1,
zmax=1,
showscale=True,
colorbar=dict(
title="Correlation",
title_font=dict(color="white", size=12), # Updated property name
tickfont=dict(color="white"),
tickmode="array",
tickvals=[-1, -0.75, -0.5, -0.25, 0, 0.25, 0.5, 0.75, 1],
ticktext=["-1.0", "-0.75", "-0.5", "-0.25", "0", "0.25", "0.5", "0.75", "1.0"]
),
hoverongaps=False,
hovertemplate='<b>%{y} vs %{x}</b><br>Correlation: %{z:.3f}<br><extra></extra>'
))
fig.add_annotation(
text="",
showarrow=False,
x=0, y=0
)
fig.update_layout(
annotations=annotations,
title={
'text': f"🔗 FX Pair Correlation Matrix - {time_period} Days",
'x': 0.5,
'font': {'size': 18, 'color': 'white', 'family': 'Arial Black'}
},
xaxis_title="Currency Pairs",
yaxis_title="Currency Pairs",
font=dict(size=10, color='white'),
plot_bgcolor='rgba(0,0,0,0)',
paper_bgcolor='rgba(0,0,0,0)',
height=800,
margin=dict(l=100, r=100, t=100, b=100)
)
# Rotate x-axis labels for better readability
fig.update_xaxes(
tickangle=45,
tickfont=dict(size=10, color='white', family='Arial Black')
)
fig.update_yaxes(
tickfont=dict(size=10, color='white', family='Arial Black')
)
return fig
def display_correlation_insights(summary_stats):
"""Display trading insights from correlation analysis"""
if summary_stats is None:
return
st.subheader("📈 Correlation Insights")
col1, col2 = st.columns(2)
with col1:
st.markdown("**🟢 Strongest Positive Correlations**")
if len(summary_stats['strongest_positive']) > 0:
for _, row in summary_stats['strongest_positive'].head(3).iterrows():
st.write(f"• {row['Pair_1']}{row['Pair_2']}: **{row['Correlation']:.3f}**")
else:
st.write("No strong positive correlations found")
with col2:
st.markdown("**🔴 Strongest Negative Correlations**")
if len(summary_stats['strongest_negative']) > 0:
for _, row in summary_stats['strongest_negative'].head(3).iterrows():
st.write(f"• {row['Pair_1']}{row['Pair_2']}: **{row['Correlation']:.3f}**")
else:
st.write("No strong negative correlations found")
# Very correlated pairs (±0.75+)
if len(summary_stats['very_correlated']) > 0:
st.markdown("**⚡ Very High Correlations (±0.75+)**")
very_corr_df = summary_stats['very_correlated'].head(5)
st.dataframe(
very_corr_df[['Pair_1', 'Pair_2', 'Correlation']].round(3),
use_container_width=True,
hide_index=True
)
def fx_correlation():
st.title("🔗 FX Pair Correlation Analysis")
# Time period selector
col1, col2, col3 = st.columns([1, 2, 1])
with col1:
time_period = st.selectbox(
"📅 Time Period",
[7, 14, 30, 60, 90],
index=2, # Default to 30 days
help="Number of days for correlation calculation"
)
with col2:
refresh_data = st.button("🔄 Refresh Correlation Data", help="Recalculate correlations")
with col3:
st.info(f"🕐 {datetime.now().strftime('%H:%M UTC')}")
# Generate pairs list
pairs_list = generate_major_pairs()
# Cache key based on time period
cache_key = f'correlation_cache_{time_period}d'
timestamp_key = f'correlation_timestamp_{time_period}d'
# Generate or use cached data
if refresh_data or cache_key not in st.session_state:
st.info(f"🚀 Calculating {time_period}-day correlations...")
with st.spinner("Analyzing currency pair relationships..."):
correlation_matrix, summary_stats = calculate_correlation_matrix(pairs_list, time_period)
if correlation_matrix is not None:
st.session_state[cache_key] = (correlation_matrix, summary_stats)
st.session_state[timestamp_key] = datetime.now()
else:
st.error("❌ Could not calculate correlations - insufficient data")
return
else:
correlation_matrix, summary_stats = st.session_state[cache_key]
cache_time = st.session_state.get(timestamp_key, datetime.now())
st.caption(f"📋 Cached data from: {cache_time.strftime('%H:%M:%S')}")
# Display results
if correlation_matrix is not None:
# Main correlation heatmap
fig = create_correlation_heatmap(correlation_matrix, time_period)
st.plotly_chart(fig, use_container_width=True)
# Display insights
display_correlation_insights(summary_stats)
# Summary statistics
st.subheader("📊 Market Overview")
col1, col2, col3, col4 = st.columns(4)
if summary_stats:
with col1:
avg_corr = summary_stats['avg_correlation']
st.metric("📈 Avg Correlation", f"{avg_corr:.3f}")
with col2:
very_corr_count = len(summary_stats['very_correlated'])
st.metric("⚡ Very Correlated", f"{very_corr_count} pairs")
with col3:
uncorr_count = len(summary_stats['uncorrelated'])
st.metric("➡️ Uncorrelated", f"{uncorr_count} pairs")
with col4:
total_pairs = len(correlation_matrix.columns) * (len(correlation_matrix.columns) - 1) // 2
st.metric("🔢 Total Pairs", f"{total_pairs}")
# Export functionality
st.subheader("💾 Export Data")
col1, col2 = st.columns(2)
with col1:
csv_data = correlation_matrix.to_csv().encode('utf-8')
st.download_button(
"📥 Download Correlation Matrix",
csv_data,
file_name=f"fx_correlation_{time_period}d_{datetime.now().strftime('%Y%m%d_%H%M')}.csv",
mime="text/csv"
)
with col2:
if st.checkbox("📋 Show Raw Data"):
st.dataframe(
correlation_matrix.round(3).style.background_gradient(
cmap="RdYlGn",
axis=None,
vmin=-1,
vmax=1
),
use_container_width=True
)
# Educational info
st.markdown("---")
st.markdown("""
**📖 Understanding FX Correlations:**
**🟢 Positive Correlation (+0.75 to +1.0):** Pairs move in same direction
- *Example: EURUSD & GBPUSD often rise/fall together*
**🔴 Negative Correlation (-0.75 to -1.0):** Pairs move in opposite directions
- *Example: EURUSD & USDCHF typically move inversely*
**⚪ No Correlation (±0.25):** Pairs move independently
- *Good for portfolio diversification*
**💡 Trading Applications:**
- **Risk Management:** Avoid taking multiple positions in highly correlated pairs
- **Hedging:** Use negatively correlated pairs to offset risk
- **Confirmation:** Strong correlations can confirm trade signals
""")