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}}" + "" + ), + }) + + traces_json = json.dumps(traces) + + # Build rank table rows + table_rows = "" + for rank_idx, (asset, m) in enumerate(ranked, 1): + color = EQUITY_COLORS[asset]["primary"] + label = EQUITY_LABELS[asset] + + def fmt_val(val, nominal=None, suffix="%"): + sign = "+" if val > 0 else "" + css_class = "positive" if val > 0 else "negative" if val < 0 else "neutral" + pct_str = f"{sign}{val:.3f}{suffix}" + if nominal is not None: + nom_sign = "+" if nominal > 0 else "-" if nominal < 0 else "" + nom_str = f"{nom_sign}${abs(nominal):,.2f}" + return f'{pct_str} ({nom_str})' + return f'{pct_str}' + + rel_median = "-" if asset == "SPY" else fmt_val(m["relative_median"]) + rel_skew = "-" if asset == "SPY" else fmt_val(m["relative_skew"]) + + table_rows += f""" + + {rank_idx} + + + {label} + {asset} + + ${m['current_price']:,.2f} + {fmt_val(m['median_move'], m['median_move_nominal'])} + {m['volatility']:.2f} + {fmt_val(m['skew'], m['skew_nominal'])} + {m['range_pct']:.3f}% (${m['range_nominal']:,.2f}) + ${m['price_low']:,.2f} - ${m['price_high']:,.2f} + {rel_median} + {rel_skew} + """ + + # Build directional alignment indicator + directions = [m["median_move"] for m in metrics.values()] + alignment_text = "All Bullish" if all(d > 0 for d in directions) else \ + "All Bearish" if all(d < 0 for d in directions) else "Mixed" + alignment_class = "bullish" if all(d > 0 for d in directions) else \ + "bearish" if all(d < 0 for d in directions) else "mixed" + + # Widest range equity + widest = max(metrics.items(), key=lambda x: x[1]["range_pct"]) + widest_name = f"{EQUITY_LABELS[widest[0]]} ({widest[1]['range_pct']:.2f}%)" + + # Most skewed equity + most_skewed = max(metrics.items(), key=lambda x: abs(x[1]["skew"])) + skew_dir = "upside" if most_skewed[1]["skew"] > 0 else "downside" + skew_name = f"{EQUITY_LABELS[most_skewed[0]]} ({skew_dir})" + + html = f""" + + + + +Tide Chart - Equity Forecast Comparison + + + + +
+ +
+
+

Tide Chart

+ 24h Forecast +
+

Equity probability cone comparison — {timestamp}

+
+ +
+
+
Directional Alignment
+
{alignment_text}
+
+
+
Widest Range
+
{widest_name}
+
+
+
Most Asymmetric
+
{skew_name}
+
+
+ +
+
+ Probability Cones (5th - 95th Percentile) + +
+
+
click legend to toggle assets · scroll to zoom · drag to pan · double-click to reset
+
+ +
+
+ Equity Rankings + +
+ + + + + + + + + + + + + + + + + {table_rows} + +
#AssetPriceMedian Move\u25B4\u25BEVolatility\u25B4\u25BESkew\u25B4\u25BERange\u25B4\u25BE24h Bounds\u25B4\u25BEvs SPY\u25B4\u25BESkew vs SPY\u25B4\u25BE
+
+ + + +
+ + + +""" + + return html + + +def main(): + """Fetch data, build dashboard, open in browser.""" + import warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + client = SynthClient() + + print("Fetching equity data...") + data = fetch_all_data(client) + + print("Calculating metrics...") + metrics = calculate_metrics(data) + metrics = add_relative_to_spy(metrics) + ranked = rank_equities(metrics, sort_by="median_move") + normalized = get_normalized_series(data) + + print("Generating dashboard...") + html = generate_dashboard_html(normalized, metrics, ranked) + + out_path = os.path.join(tempfile.gettempdir(), "tide_chart.html") + with open(out_path, "w", encoding="utf-8") as f: + f.write(html) + + print(f"Dashboard saved to {out_path}") + webbrowser.open(f"file://{out_path}") + + +if __name__ == "__main__": + main() diff --git a/tools/tide-chart/requirements.txt b/tools/tide-chart/requirements.txt new file mode 100644 index 0000000..73e33e5 --- /dev/null +++ b/tools/tide-chart/requirements.txt @@ -0,0 +1,2 @@ +plotly>=5.0.0 +requests>=2.28.0 diff --git a/tools/tide-chart/tests/test_tool.py b/tools/tide-chart/tests/test_tool.py new file mode 100644 index 0000000..095d465 --- /dev/null +++ b/tools/tide-chart/tests/test_tool.py @@ -0,0 +1,293 @@ +""" +Tests for the Tide Chart tool. + +All tests run against mock data (no API key needed). +They verify data fetching, normalization, metric calculation, +ranking, and dashboard generation. +""" + +import sys +import os +import warnings + +# Add project root to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../../..")) +# Add tool directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from synth_client import SynthClient +from chart import ( + EQUITIES, + PERCENTILE_KEYS, + fetch_all_data, + normalize_percentiles, + calculate_metrics, + add_relative_to_spy, + rank_equities, + get_normalized_series, +) +from main import generate_dashboard_html + + +def _make_client(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return SynthClient() + + +def test_client_loads_in_mock_mode(): + """Verify the client initializes in mock mode without an API key.""" + client = _make_client() + assert client.mock_mode is True + + +def test_fetch_all_equities_data(): + """Verify fetch_all_data returns data for all 5 equities.""" + client = _make_client() + data = fetch_all_data(client) + + assert len(data) == 5 + for asset in EQUITIES: + assert asset in data + assert "current_price" in data[asset] + assert "percentiles" in data[asset] + assert "average_volatility" in data[asset] + assert isinstance(data[asset]["current_price"], (int, float)) + assert isinstance(data[asset]["percentiles"], list) + assert len(data[asset]["percentiles"]) == 289 + + +def test_normalize_percentiles(): + """Verify normalization converts prices to % change correctly.""" + current_price = 100.0 + percentiles = [ + {"0.05": 95.0, "0.5": 100.0, "0.95": 110.0}, + {"0.05": 90.0, "0.5": 102.0, "0.95": 115.0}, + ] + result = normalize_percentiles(percentiles, current_price) + + assert len(result) == 2 + # First step + assert result[0]["0.05"] == -5.0 # (95-100)/100*100 + assert result[0]["0.5"] == 0.0 # (100-100)/100*100 + assert result[0]["0.95"] == 10.0 # (110-100)/100*100 + # Second step + assert result[1]["0.05"] == -10.0 + assert result[1]["0.5"] == 2.0 + assert result[1]["0.95"] == 15.0 + + +def test_calculate_metrics_median_move(): + """Verify median move calculation from final percentile.""" + client = _make_client() + data = fetch_all_data(client) + metrics = calculate_metrics(data) + + for asset in EQUITIES: + m = metrics[asset] + final = data[asset]["percentiles"][-1] + cp = data[asset]["current_price"] + expected_median = (final["0.5"] - cp) / cp * 100 + assert abs(m["median_move"] - expected_median) < 1e-10 + + +def test_calculate_metrics_skew(): + """Verify skew = upside - downside.""" + client = _make_client() + data = fetch_all_data(client) + metrics = calculate_metrics(data) + + for asset in EQUITIES: + m = metrics[asset] + assert abs(m["skew"] - (m["upside"] - m["downside"])) < 1e-10 + + +def test_calculate_metrics_range(): + """Verify range = upside + downside.""" + client = _make_client() + data = fetch_all_data(client) + metrics = calculate_metrics(data) + + for asset in EQUITIES: + m = metrics[asset] + assert abs(m["range_pct"] - (m["upside"] + m["downside"])) < 1e-10 + + +def test_relative_to_spy(): + """Verify relative-to-SPY calculations.""" + client = _make_client() + data = fetch_all_data(client) + metrics = calculate_metrics(data) + metrics = add_relative_to_spy(metrics) + + spy_median = metrics["SPY"]["median_move"] + spy_skew = metrics["SPY"]["skew"] + + # SPY relative to itself should be 0 + assert metrics["SPY"]["relative_median"] == 0.0 + assert metrics["SPY"]["relative_skew"] == 0.0 + + for asset in ["NVDA", "TSLA", "AAPL", "GOOGL"]: + m = metrics[asset] + expected_rel_median = m["median_move"] - spy_median + expected_rel_skew = m["skew"] - spy_skew + assert abs(m["relative_median"] - expected_rel_median) < 1e-10 + assert abs(m["relative_skew"] - expected_rel_skew) < 1e-10 + + +def test_rank_equities_sorting(): + """Verify equities are ranked by median_move descending.""" + client = _make_client() + data = fetch_all_data(client) + metrics = calculate_metrics(data) + metrics = add_relative_to_spy(metrics) + ranked = rank_equities(metrics, sort_by="median_move") + + assert len(ranked) == 5 + for i in range(len(ranked) - 1): + assert ranked[i][1]["median_move"] >= ranked[i + 1][1]["median_move"] + + +def test_rank_equities_ascending(): + """Verify ascending sort works.""" + client = _make_client() + data = fetch_all_data(client) + metrics = calculate_metrics(data) + metrics = add_relative_to_spy(metrics) + ranked = rank_equities(metrics, sort_by="volatility", ascending=True) + + for i in range(len(ranked) - 1): + assert ranked[i][1]["volatility"] <= ranked[i + 1][1]["volatility"] + + +def test_get_normalized_series(): + """Verify normalized series has correct structure.""" + client = _make_client() + data = fetch_all_data(client) + series = get_normalized_series(data) + + assert len(series) == 5 + for asset in EQUITIES: + assert asset in series + assert len(series[asset]) == 289 + # First step should be near 0 (current price normalized) + first = series[asset][0] + assert "0.5" in first + + +def test_generate_dashboard_html(): + """Verify dashboard HTML generation produces valid output.""" + client = _make_client() + data = fetch_all_data(client) + metrics = calculate_metrics(data) + metrics = add_relative_to_spy(metrics) + ranked = rank_equities(metrics, sort_by="median_move") + normalized = get_normalized_series(data) + + html = generate_dashboard_html(normalized, metrics, ranked) + + assert isinstance(html, str) + assert "" in html + assert "Tide Chart" in html + assert "plotly" in html.lower() + # Check all equity tickers appear + for asset in EQUITIES: + assert asset in html + # Check table has rows + assert "" in html + assert "cone-chart" in html + # Check relative_skew column exists (Skew vs SPY header) + assert "Skew vs SPY" in html + # Check sortable table headers + assert "sortable" in html + assert "data-sort" in html + # Check nominal values are displayed + assert "nominal" in html + assert "$" in html + # Check legendgroup is set for trace grouping + assert "legendgroup" in html + # Check 24h Bounds column + assert "24h Bounds" in html + assert "data-sort=\"bounds\"" in html + # Check column header tooltips + assert "data-tip=" in html + assert "50th percentile" in html + # Check time-based x-axis + assert "Time (ET)" in html + assert "%I:%M %p" in html + # Check legend toggle hint and rescale handler + assert "click legend to toggle assets" in html + assert "plotly_legendclick" in html + assert "yaxis.autorange" in html + # Check tooltip focus support + assert "data-tip]:focus-visible::after" in html + + +def test_calculate_metrics_nominal_values(): + """Verify nominal dollar values are computed correctly.""" + client = _make_client() + data = fetch_all_data(client) + metrics = calculate_metrics(data) + + for asset in EQUITIES: + m = metrics[asset] + final = data[asset]["percentiles"][-1] + cp = data[asset]["current_price"] + + assert "median_move_nominal" in m + assert "upside_nominal" in m + assert "downside_nominal" in m + assert "skew_nominal" in m + assert "range_nominal" in m + + assert abs(m["median_move_nominal"] - (final["0.5"] - cp)) < 1e-10 + assert abs(m["upside_nominal"] - (final["0.95"] - cp)) < 1e-10 + assert abs(m["downside_nominal"] - (cp - final["0.05"])) < 1e-10 + assert abs(m["skew_nominal"] - (m["upside_nominal"] - m["downside_nominal"])) < 1e-10 + assert abs(m["range_nominal"] - (m["upside_nominal"] + m["downside_nominal"])) < 1e-10 + + +def test_calculate_metrics_projection_bounds(): + """Verify price_high and price_low are computed correctly.""" + client = _make_client() + data = fetch_all_data(client) + metrics = calculate_metrics(data) + + for asset in EQUITIES: + m = metrics[asset] + final = data[asset]["percentiles"][-1] + + assert "price_high" in m + assert "price_low" in m + assert abs(m["price_high"] - final["0.95"]) < 1e-10 + assert abs(m["price_low"] - final["0.05"]) < 1e-10 + assert m["price_high"] >= m["price_low"] + + +def test_volatility_values(): + """Verify volatility values are positive floats.""" + client = _make_client() + data = fetch_all_data(client) + metrics = calculate_metrics(data) + + for asset in EQUITIES: + assert metrics[asset]["volatility"] > 0 + assert isinstance(metrics[asset]["volatility"], float) + + +if __name__ == "__main__": + test_client_loads_in_mock_mode() + test_fetch_all_equities_data() + test_normalize_percentiles() + test_calculate_metrics_median_move() + test_calculate_metrics_skew() + test_calculate_metrics_range() + test_relative_to_spy() + test_rank_equities_sorting() + test_rank_equities_ascending() + test_get_normalized_series() + test_calculate_metrics_nominal_values() + test_calculate_metrics_projection_bounds() + test_generate_dashboard_html() + test_volatility_values() + print("All tests passed!")