diff --git a/tools/tide-chart/README.md b/tools/tide-chart/README.md
new file mode 100644
index 0000000..b6a8d50
--- /dev/null
+++ b/tools/tide-chart/README.md
@@ -0,0 +1,59 @@
+# Tide Chart
+
+> Interactive dashboard comparing 24-hour probability cones for 5 equities using Synth forecasting data.
+
+## Overview
+
+Tide Chart overlays probabilistic price forecasts for SPY, NVDA, TSLA, AAPL, and GOOGL into a single comparison view. It normalizes all forecasts to percentage change, enabling direct comparison across different price levels, and generates a ranked summary table with key metrics.
+
+The tool addresses three questions from the forecast data:
+- **Directional alignment** - Are all equities moving the same way?
+- **Relative magnitude** - Which equity has the widest expected range?
+- **Asymmetric skew** - Is the upside or downside tail larger, individually and relative to SPY?
+
+## How It Works
+
+1. Fetches `get_prediction_percentiles` and `get_volatility` for each of the 5 equities (24h horizon)
+2. Normalizes all 289 time steps from raw price to `% change = (percentile - current_price) / current_price * 100`
+3. Computes metrics from the final time step (end of 24h window):
+ - **Median Move** - 50th percentile % change
+ - **Upside/Downside** - 95th and 5th percentile distances
+ - **Directional Skew** - upside minus downside (positive = bullish asymmetry)
+ - **Range** - total 5th-to-95th percentile width
+ - **Relative to SPY** - each metric minus SPY's value
+4. Ranks equities by median expected move (table columns are sortable by click)
+5. Generates an interactive Plotly HTML dashboard and opens it in the browser
+
+## Synth Endpoints Used
+
+- `get_prediction_percentiles(asset, horizon="24h")` - Provides 289 time-step probabilistic forecast with 9 percentile levels (0.5% to 99.5%). Used for the probability cone overlay and all derived metrics.
+- `get_volatility(asset, horizon="24h")` - Provides forecasted average volatility. Displayed in the ranking table as an independent risk measure.
+
+## Usage
+
+```bash
+# Install dependencies
+pip install -r requirements.txt
+
+# Run the tool (opens dashboard in browser)
+python main.py
+
+# Run tests
+python -m pytest tests/ -v
+```
+
+## Example Output
+
+The dashboard contains two sections:
+
+**Probability Cone Comparison** - Interactive Plotly chart with semi-transparent bands (5th-95th percentile) and median lines for each equity. Hover to see exact values at any time step.
+
+**Equity Rankings** - Sortable table showing price, median move (% and $), forecasted volatility, directional skew (% and $), probability range (% and $), median vs SPY, and skew vs SPY. Click any column header to re-sort. Values are color-coded green (positive) or red (negative), with nominal dollar amounts shown alongside percentages for immediate context.
+
+## Technical Details
+
+- **Language:** Python 3.10+
+- **Dependencies:** plotly (for chart generation)
+- **Synth Assets Used:** SPY, NVDA, TSLA, AAPL, GOOGL
+- **Output:** Single HTML file (requires internet for Plotly CDN and fonts; no server needed)
+- **Mock Mode:** Works without API key using bundled mock data
diff --git a/tools/tide-chart/chart.py b/tools/tide-chart/chart.py
new file mode 100644
index 0000000..7c36fac
--- /dev/null
+++ b/tools/tide-chart/chart.py
@@ -0,0 +1,151 @@
+"""
+Data processing module for the Tide Chart dashboard.
+
+Fetches prediction percentiles and volatility for 5 equities,
+normalizes to percentage change, calculates comparison metrics,
+and ranks equities by forecast outlook.
+"""
+
+import sys
+import os
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
+
+from synth_client import SynthClient
+
+EQUITIES = ["SPY", "NVDA", "TSLA", "AAPL", "GOOGL"]
+PERCENTILE_KEYS = ["0.005", "0.05", "0.2", "0.35", "0.5", "0.65", "0.8", "0.95", "0.995"]
+
+
+def fetch_all_data(client):
+ """Fetch prediction percentiles and volatility for all 5 equities.
+
+ Returns:
+ dict: {asset: {"percentiles": ..., "volatility": ..., "current_price": float}}
+ """
+ data = {}
+ for asset in EQUITIES:
+ forecast = client.get_prediction_percentiles(asset, horizon="24h")
+ vol = client.get_volatility(asset, horizon="24h")
+ data[asset] = {
+ "current_price": forecast["current_price"],
+ "percentiles": forecast["forecast_future"]["percentiles"],
+ "average_volatility": vol["forecast_future"]["average_volatility"],
+ }
+ return data
+
+
+def normalize_percentiles(percentiles, current_price):
+ """Convert raw price percentiles to percentage change from current price.
+
+ Args:
+ percentiles: List of dicts (289 time steps), each with percentile keys.
+ current_price: Current asset price.
+
+ Returns:
+ List of dicts with same keys but values as % change.
+ """
+ normalized = []
+ for step in percentiles:
+ norm_step = {}
+ for key in PERCENTILE_KEYS:
+ if key in step:
+ norm_step[key] = (step[key] - current_price) / current_price * 100
+ normalized.append(norm_step)
+ return normalized
+
+
+def calculate_metrics(data):
+ """Calculate comparison metrics for each equity.
+
+ Uses the final time step (end of 24h window) for metric computation.
+
+ Args:
+ data: Dict from fetch_all_data().
+
+ Returns:
+ dict: {asset: {median_move, upside, downside, skew, range_pct,
+ volatility, current_price}}
+ """
+ metrics = {}
+ for asset, info in data.items():
+ current_price = info["current_price"]
+ final = info["percentiles"][-1]
+
+ median_move = (final["0.5"] - current_price) / current_price * 100
+ upside = (final["0.95"] - current_price) / current_price * 100
+ downside = (current_price - final["0.05"]) / current_price * 100
+ skew = upside - downside
+ range_pct = upside + downside
+
+ # Nominal (dollar) values
+ median_move_nominal = final["0.5"] - current_price
+ upside_nominal = final["0.95"] - current_price
+ downside_nominal = current_price - final["0.05"]
+
+ metrics[asset] = {
+ "median_move": median_move,
+ "upside": upside,
+ "downside": downside,
+ "skew": skew,
+ "range_pct": range_pct,
+ "volatility": info["average_volatility"],
+ "current_price": current_price,
+ "median_move_nominal": median_move_nominal,
+ "upside_nominal": upside_nominal,
+ "downside_nominal": downside_nominal,
+ "skew_nominal": upside_nominal - downside_nominal,
+ "range_nominal": upside_nominal + downside_nominal,
+ "price_high": current_price + upside_nominal,
+ "price_low": current_price - downside_nominal,
+ }
+ return metrics
+
+
+def add_relative_to_spy(metrics):
+ """Add relative-to-SPY fields for each equity.
+
+ Args:
+ metrics: Dict from calculate_metrics().
+
+ Returns:
+ Same dict with added relative_median and relative_skew fields.
+ """
+ spy = metrics["SPY"]
+ for asset, m in metrics.items():
+ m["relative_median"] = m["median_move"] - spy["median_move"]
+ m["relative_skew"] = m["skew"] - spy["skew"]
+ return metrics
+
+
+def rank_equities(metrics, sort_by="median_move", ascending=False):
+ """Rank equities by a given metric.
+
+ Args:
+ metrics: Dict from calculate_metrics() with relative fields.
+ sort_by: Metric key to sort by.
+ ascending: Sort direction.
+
+ Returns:
+ List of (asset, metrics_dict) tuples, sorted by sort_by.
+ """
+ items = list(metrics.items())
+ items.sort(key=lambda x: x[1][sort_by], reverse=not ascending)
+ return items
+
+
+def get_normalized_series(data):
+ """Get full normalized time series for all equities (for charting).
+
+ Args:
+ data: Dict from fetch_all_data().
+
+ Returns:
+ dict: {asset: list of normalized percentile dicts}
+ """
+ series = {}
+ for asset, info in data.items():
+ series[asset] = normalize_percentiles(
+ info["percentiles"], info["current_price"]
+ )
+ return series
diff --git a/tools/tide-chart/main.py b/tools/tide-chart/main.py
new file mode 100644
index 0000000..a351f68
--- /dev/null
+++ b/tools/tide-chart/main.py
@@ -0,0 +1,836 @@
+"""
+Tide Chart - Equity Forecast Comparison Dashboard.
+
+Generates an interactive HTML dashboard comparing 24h probability cones
+for 5 equities (SPY, NVDA, TSLA, AAPL, GOOGL) using Synth API data.
+Opens the dashboard in the default browser.
+"""
+
+import sys
+import os
+import json
+import webbrowser
+import tempfile
+from datetime import datetime, timedelta, timezone
+from zoneinfo import ZoneInfo
+
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
+
+from synth_client import SynthClient
+from chart import (
+ fetch_all_data,
+ calculate_metrics,
+ add_relative_to_spy,
+ rank_equities,
+ get_normalized_series,
+)
+
+EQUITY_COLORS = {
+ "SPY": {"primary": "#e8d44d", "rgb": "232,212,77"},
+ "NVDA": {"primary": "#3db8e8", "rgb": "61,184,232"},
+ "TSLA": {"primary": "#e85a6e", "rgb": "232,90,110"},
+ "AAPL": {"primary": "#9b6de8", "rgb": "155,109,232"},
+ "GOOGL": {"primary": "#4dc87a", "rgb": "77,200,122"},
+}
+
+EQUITY_LABELS = {
+ "SPY": "S&P 500",
+ "NVDA": "NVIDIA",
+ "TSLA": "Tesla",
+ "AAPL": "Apple",
+ "GOOGL": "Alphabet",
+}
+
+
+def generate_dashboard_html(normalized_series, metrics, ranked):
+ """Generate a self-contained HTML dashboard.
+
+ Args:
+ normalized_series: {asset: list of normalized percentile dicts} (289 steps)
+ metrics: {asset: {median_move, upside, downside, skew, range_pct,
+ volatility, current_price, relative_median, relative_skew}}
+ ranked: List of (asset, metrics_dict) sorted by median_move.
+
+ Returns:
+ str: Complete HTML document string.
+ """
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
+
+ # Generate ET time axis (289 steps x 5 min = 24h)
+ et = ZoneInfo("America/New_York")
+ now_et = datetime.now(et)
+ time_points = [
+ (now_et + timedelta(minutes=i * 5)).strftime("%Y-%m-%dT%H:%M")
+ for i in range(289)
+ ]
+
+ # Build Plotly traces for probability cones
+ traces = []
+ for asset in ["SPY", "NVDA", "TSLA", "AAPL", "GOOGL"]:
+ series = normalized_series[asset]
+ steps = time_points
+ color = EQUITY_COLORS[asset]
+ label = EQUITY_LABELS[asset]
+
+ upper = [s.get("0.95", 0) for s in series]
+ lower = [s.get("0.05", 0) for s in series]
+ median = [s.get("0.5", 0) for s in series]
+
+ # Upper bound (invisible line for fill)
+ traces.append({
+ "x": steps,
+ "y": upper,
+ "type": "scatter",
+ "mode": "lines",
+ "line": {"width": 0},
+ "showlegend": False,
+ "legendgroup": asset,
+ "name": f"{asset} 95th",
+ "hoverinfo": "skip",
+ })
+
+ # Lower bound with fill to upper
+ traces.append({
+ "x": steps,
+ "y": lower,
+ "type": "scatter",
+ "mode": "lines",
+ "line": {"width": 0},
+ "fill": "tonexty",
+ "fillcolor": f"rgba({color['rgb']},0.12)",
+ "showlegend": False,
+ "legendgroup": asset,
+ "name": f"{asset} 5th",
+ "hoverinfo": "skip",
+ })
+
+ # Median line - pre-format hover text (d3-format unreliable in unified hover)
+ current_price = metrics[asset]["current_price"]
+ hover_text = []
+ for v in median:
+ nom = v * current_price / 100
+ sign_pct = "+" if v >= 0 else ""
+ sign_nom = "+" if nom >= 0 else "-"
+ hover_text.append(f"{sign_pct}{v:.2f}% ({sign_nom}${abs(nom):,.2f})")
+ traces.append({
+ "x": steps,
+ "y": median,
+ "customdata": hover_text,
+ "type": "scatter",
+ "mode": "lines",
+ "line": {"color": color["primary"], "width": 2},
+ "legendgroup": asset,
+ "name": f"{label} ({asset})",
+ "hovertemplate": (
+ f"{label}
"
+ "%{{x|%I:%M %p}}
"
+ "Median: %{{customdata}}"
+ "
Equity probability cone comparison — {timestamp}
+| # | +Asset | +Price | +Median Move\u25B4\u25BE | +Volatility\u25B4\u25BE | +Skew\u25B4\u25BE | +Range\u25B4\u25BE | +24h Bounds\u25B4\u25BE | +vs SPY\u25B4\u25BE | +Skew vs SPY\u25B4\u25BE | +
|---|