From 57db9ea69c494a2bb175707f5b52bab8163acd4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20Eren=20Karaku=C5=9F?= Date: Fri, 27 Feb 2026 22:27:12 +0300 Subject: [PATCH 1/5] feat(tools): add tide-chart equity forecast comparison dashboard Interactive HTML dashboard comparing 24h probability cones for 5 equities (SPY, NVDA, TSLA, AAPL, GOOGL) using Synth API data. - Probability cone overlay chart (5th-95th percentile, normalized to % change) - Sortable rank table with median move, volatility, skew, and vs SPY metrics - Insight cards for directional alignment, widest range, and asymmetric skew - Scroll-to-zoom, keyboard accessible, dark premium fintech theme - 12 tests passing in mock mode Fixes #4 --- tools/tide-chart/README.md | 59 +++ tools/tide-chart/chart.py | 139 ++++++ tools/tide-chart/main.py | 726 ++++++++++++++++++++++++++++ tools/tide-chart/requirements.txt | 2 + tools/tide-chart/tests/test_tool.py | 230 +++++++++ 5 files changed, 1156 insertions(+) create mode 100644 tools/tide-chart/README.md create mode 100644 tools/tide-chart/chart.py create mode 100644 tools/tide-chart/main.py create mode 100644 tools/tide-chart/requirements.txt create mode 100644 tools/tide-chart/tests/test_tool.py diff --git a/tools/tide-chart/README.md b/tools/tide-chart/README.md new file mode 100644 index 0000000..6c8f23b --- /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 %, forecasted volatility, directional skew, probability range, median vs SPY, and skew vs SPY. Click any column header to re-sort. Values are color-coded green (positive) or red (negative). + +## 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..218d8b6 --- /dev/null +++ b/tools/tide-chart/chart.py @@ -0,0 +1,139 @@ +""" +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 + + metrics[asset] = { + "median_move": median_move, + "upside": upside, + "downside": downside, + "skew": skew, + "range_pct": range_pct, + "volatility": info["average_volatility"], + "current_price": current_price, + } + 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..a88399f --- /dev/null +++ b/tools/tide-chart/main.py @@ -0,0 +1,726 @@ +""" +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, timezone + +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") + + # Build Plotly traces for probability cones + traces = [] + for asset in ["SPY", "NVDA", "TSLA", "AAPL", "GOOGL"]: + series = normalized_series[asset] + steps = list(range(len(series))) + 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, + "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, + "name": f"{asset} 5th", + "hoverinfo": "skip", + }) + + # Median line + traces.append({ + "x": steps, + "y": median, + "type": "scatter", + "mode": "lines", + "line": {"color": color["primary"], "width": 2}, + "name": f"{label} ({asset})", + "hovertemplate": ( + f"{label}
" + "Step %{x}
" + "Median: %{y:.3f}%" + ), + }) + + 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, suffix="%"): + sign = "+" if val > 0 else "" + css_class = "positive" if val > 0 else "negative" if val < 0 else "neutral" + return f'{sign}{val:.3f}{suffix}' + + 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['volatility']:.2f} + {fmt_val(m['skew'])} + {m['range_pct']:.3f}% + {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) + +
+
+
scroll to zoom · drag to pan · double-click to reset
+
+ +
+
+ Equity Rankings + +
+ + + + + + + + + + + + + + + + {table_rows} + +
#AssetPriceMedian MoveVolatilitySkewRangevs SPYSkew vs SPY
+
+ + + +
+ + + +""" + + 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..e19162d --- /dev/null +++ b/tools/tide-chart/tests/test_tool.py @@ -0,0 +1,230 @@ +""" +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 + + +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_generate_dashboard_html() + test_volatility_values() + print("All tests passed!") From f9c532682f32675d50bddcb12ec466e850654735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20Eren=20Karaku=C5=9F?= Date: Sat, 28 Feb 2026 00:17:41 +0300 Subject: [PATCH 2/5] feat(tide-chart): add nominal dollar values alongside percentages Display dollar amounts next to percentage values in the ranking table (Median Move, Skew, Range columns) and chart hover tooltips, giving users both relative and absolute context for forecast moves. --- tools/tide-chart/chart.py | 10 ++++++++++ tools/tide-chart/main.py | 27 +++++++++++++++++++++------ tools/tide-chart/tests/test_tool.py | 28 ++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/tools/tide-chart/chart.py b/tools/tide-chart/chart.py index 218d8b6..966b5ad 100644 --- a/tools/tide-chart/chart.py +++ b/tools/tide-chart/chart.py @@ -78,6 +78,11 @@ def calculate_metrics(data): 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, @@ -86,6 +91,11 @@ def calculate_metrics(data): "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, } return metrics diff --git a/tools/tide-chart/main.py b/tools/tide-chart/main.py index a88399f..11acbae 100644 --- a/tools/tide-chart/main.py +++ b/tools/tide-chart/main.py @@ -94,9 +94,12 @@ def generate_dashboard_html(normalized_series, metrics, ranked): }) # Median line + current_price = metrics[asset]["current_price"] + median_nominal = [v * current_price / 100 for v in median] traces.append({ "x": steps, "y": median, + "customdata": median_nominal, "type": "scatter", "mode": "lines", "line": {"color": color["primary"], "width": 2}, @@ -104,7 +107,8 @@ def generate_dashboard_html(normalized_series, metrics, ranked): "hovertemplate": ( f"{label}
" "Step %{x}
" - "Median: %{y:.3f}%" + "Median: %{y:+.3f}% (%{customdata:+$,.2f})" + "" ), }) @@ -116,10 +120,15 @@ def generate_dashboard_html(normalized_series, metrics, ranked): color = EQUITY_COLORS[asset]["primary"] label = EQUITY_LABELS[asset] - def fmt_val(val, suffix="%"): + 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" - return f'{sign}{val:.3f}{suffix}' + 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"]) @@ -133,10 +142,10 @@ def fmt_val(val, suffix="%"): {asset} ${m['current_price']:,.2f} - {fmt_val(m['median_move'])} + {fmt_val(m['median_move'], m['median_move_nominal'])} {m['volatility']:.2f} - {fmt_val(m['skew'])} - {m['range_pct']:.3f}% + {fmt_val(m['skew'], m['skew_nominal'])} + {m['range_pct']:.3f}% (${m['range_nominal']:,.2f}) {rel_median} {rel_skew} """ @@ -497,6 +506,12 @@ def fmt_val(val, suffix="%"): .negative {{ color: var(--negative); }} .neutral {{ color: var(--text-secondary); }} + .nominal {{ + font-size: 10px; + color: var(--text-muted); + font-weight: 400; + }} + /* Footer */ .footer {{ margin-top: 24px; diff --git a/tools/tide-chart/tests/test_tool.py b/tools/tide-chart/tests/test_tool.py index e19162d..c09508c 100644 --- a/tools/tide-chart/tests/test_tool.py +++ b/tools/tide-chart/tests/test_tool.py @@ -201,6 +201,33 @@ def test_generate_dashboard_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 + + +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_volatility_values(): @@ -225,6 +252,7 @@ def test_volatility_values(): test_rank_equities_sorting() test_rank_equities_ascending() test_get_normalized_series() + test_calculate_metrics_nominal_values() test_generate_dashboard_html() test_volatility_values() print("All tests passed!") From d7a1850c2d8b757b8675d0db6cda15aa8057727f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20Eren=20Karaku=C5=9F?= Date: Sat, 28 Feb 2026 00:22:32 +0300 Subject: [PATCH 3/5] fix(tide-chart): use 2 decimal places in chart tooltip, update README Reduce tooltip percentage precision from 3 to 2 decimal places for cleaner hover display. Update README to reflect nominal dollar values in the equity rankings table description. --- tools/tide-chart/README.md | 2 +- tools/tide-chart/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/tide-chart/README.md b/tools/tide-chart/README.md index 6c8f23b..b6a8d50 100644 --- a/tools/tide-chart/README.md +++ b/tools/tide-chart/README.md @@ -48,7 +48,7 @@ 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 %, forecasted volatility, directional skew, probability range, median vs SPY, and skew vs SPY. Click any column header to re-sort. Values are color-coded green (positive) or red (negative). +**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 diff --git a/tools/tide-chart/main.py b/tools/tide-chart/main.py index 11acbae..e401c55 100644 --- a/tools/tide-chart/main.py +++ b/tools/tide-chart/main.py @@ -107,7 +107,7 @@ def generate_dashboard_html(normalized_series, metrics, ranked): "hovertemplate": ( f"{label}
" "Step %{x}
" - "Median: %{y:+.3f}% (%{customdata:+$,.2f})" + "Median: %{y:+.2f}% (%{customdata:+$,.2f})" "" ), }) From aab50eb97dc387cca41980a6355aeda0b7bd0fcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20Eren=20Karaku=C5=9F?= Date: Sat, 28 Feb 2026 00:29:11 +0300 Subject: [PATCH 4/5] fix(tide-chart): pre-format hover values for unified tooltip Plotly's d3-format specifiers are unreliable in unified hover mode, causing raw floats with excessive decimals. Pre-format percentage and nominal values in Python to guarantee 2 decimal places. --- tools/tide-chart/main.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tools/tide-chart/main.py b/tools/tide-chart/main.py index e401c55..76397a9 100644 --- a/tools/tide-chart/main.py +++ b/tools/tide-chart/main.py @@ -93,13 +93,18 @@ def generate_dashboard_html(normalized_series, metrics, ranked): "hoverinfo": "skip", }) - # Median line + # Median line - pre-format hover text (d3-format unreliable in unified hover) current_price = metrics[asset]["current_price"] - median_nominal = [v * current_price / 100 for v in median] + 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": median_nominal, + "customdata": hover_text, "type": "scatter", "mode": "lines", "line": {"color": color["primary"], "width": 2}, @@ -107,7 +112,7 @@ def generate_dashboard_html(normalized_series, metrics, ranked): "hovertemplate": ( f"{label}
" "Step %{x}
" - "Median: %{y:+.2f}% (%{customdata:+$,.2f})" + "Median: %{customdata}" "" ), }) From ee90544201485cfb03a5a6ba8e66c6f7959aaf4f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Muhammet=20Eren=20Karaku=C5=9F?= Date: Sun, 1 Mar 2026 11:27:00 +0300 Subject: [PATCH 5/5] feat(tide-chart): add interactive chart features and table enhancements - Add legendgroup to traces so clicking legend toggles band + line together, with y-axis autorange on legend toggle/double-click - Replace step-based x-axis with ET timezone time labels (5-min intervals) - Add 24h Bounds column showing projected price at 5th/95th percentile - Add CSS tooltips on column headers with descriptions - Add price_high/price_low projection bounds to metrics - Set default drag mode to pan instead of zoom - Improve sort arrow design with inline glyphs and accent highlight - Improve section title and insight label font visibility - Add mobile responsive table scroll for narrow screens - Add tests for projection bounds and new HTML features --- tools/tide-chart/chart.py | 2 + tools/tide-chart/main.py | 164 +++++++++++++++++++++------- tools/tide-chart/tests/test_tool.py | 35 ++++++ 3 files changed, 164 insertions(+), 37 deletions(-) diff --git a/tools/tide-chart/chart.py b/tools/tide-chart/chart.py index 966b5ad..7c36fac 100644 --- a/tools/tide-chart/chart.py +++ b/tools/tide-chart/chart.py @@ -96,6 +96,8 @@ def calculate_metrics(data): "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 diff --git a/tools/tide-chart/main.py b/tools/tide-chart/main.py index 76397a9..a351f68 100644 --- a/tools/tide-chart/main.py +++ b/tools/tide-chart/main.py @@ -11,7 +11,8 @@ import json import webbrowser import tempfile -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) @@ -55,11 +56,19 @@ def generate_dashboard_html(normalized_series, metrics, ranked): """ 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 = list(range(len(series))) + steps = time_points color = EQUITY_COLORS[asset] label = EQUITY_LABELS[asset] @@ -75,6 +84,7 @@ def generate_dashboard_html(normalized_series, metrics, ranked): "mode": "lines", "line": {"width": 0}, "showlegend": False, + "legendgroup": asset, "name": f"{asset} 95th", "hoverinfo": "skip", }) @@ -89,6 +99,7 @@ def generate_dashboard_html(normalized_series, metrics, ranked): "fill": "tonexty", "fillcolor": f"rgba({color['rgb']},0.12)", "showlegend": False, + "legendgroup": asset, "name": f"{asset} 5th", "hoverinfo": "skip", }) @@ -108,11 +119,12 @@ def generate_dashboard_html(normalized_series, metrics, ranked): "type": "scatter", "mode": "lines", "line": {"color": color["primary"], "width": 2}, + "legendgroup": asset, "name": f"{label} ({asset})", "hovertemplate": ( f"{label}
" - "Step %{x}
" - "Median: %{customdata}" + "%{{x|%I:%M %p}}
" + "Median: %{{customdata}}" "" ), }) @@ -139,7 +151,7 @@ def fmt_val(val, nominal=None, suffix="%"): rel_skew = "-" if asset == "SPY" else fmt_val(m["relative_skew"]) table_rows += f""" - + {rank_idx} @@ -151,6 +163,7 @@ def fmt_val(val, nominal=None, suffix="%"): {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} """ @@ -295,10 +308,10 @@ def fmt_val(val, nominal=None, suffix="%"): }} .insight-label {{ - font-size: 10px; + font-size: 11px; text-transform: uppercase; - letter-spacing: 1.2px; - color: var(--text-muted); + letter-spacing: 1px; + color: var(--text-secondary); margin-bottom: 6px; font-weight: 500; }} @@ -311,7 +324,7 @@ def fmt_val(val, nominal=None, suffix="%"): .insight-value.bullish {{ color: var(--positive); }} .insight-value.bearish {{ color: var(--negative); }} - .insight-value.mixed {{ color: var(--text-secondary); }} + .insight-value.mixed {{ color: var(--text-primary); }} /* Chart section */ .chart-container {{ @@ -335,11 +348,11 @@ def fmt_val(val, nominal=None, suffix="%"): }} .section-title {{ - font-size: 14px; - font-weight: 500; - color: var(--text-secondary); + font-size: 15px; + font-weight: 600; + color: var(--text-primary); text-transform: uppercase; - letter-spacing: 0.8px; + letter-spacing: 0.6px; }} .section-line {{ @@ -385,7 +398,6 @@ def fmt_val(val, nominal=None, suffix="%"): border: 1px solid var(--border); border-radius: 10px; padding: 20px; - overflow-x: auto; transition: box-shadow 0.3s ease; }} @@ -415,8 +427,8 @@ def fmt_val(val, nominal=None, suffix="%"): thead th:first-child {{ padding-left: 16px; }} /* Visual separator before "vs SPY" group */ - thead th:nth-child(8), - tbody td:nth-child(8) {{ + thead th:nth-child(9), + tbody td:nth-child(9) {{ border-left: 1px solid var(--border); padding-left: 12px; }} @@ -482,31 +494,88 @@ def fmt_val(val, nominal=None, suffix="%"): cursor: pointer; user-select: none; position: relative; - padding-right: 18px !important; }} - .sortable::after {{ - content: '\u2195'; - position: absolute; - right: 2px; - opacity: 0.3; - font-size: 9px; + .sortable .sort-arrow {{ + display: inline-block; + font-size: 12px; + opacity: 0.25; + margin-left: 3px; + letter-spacing: -2px; + transition: opacity 0.15s ease, color 0.15s ease; + vertical-align: middle; }} - .sortable.asc::after {{ - content: '\u2191'; - opacity: 0.8; + .sortable:hover .sort-arrow {{ + opacity: 0.5; }} - .sortable.desc::after {{ - content: '\u2193'; - opacity: 0.8; + .sortable.asc .sort-arrow {{ + opacity: 0.9; + color: var(--accent); + }} + + .sortable.desc .sort-arrow {{ + opacity: 0.9; + color: var(--accent); }} .sortable:hover {{ color: var(--accent); }} + /* Column header tooltips */ + th[data-tip]::before {{ + content: ''; + position: absolute; + top: calc(100% + 2px); + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-bottom-color: rgba(232,212,77,0.35); + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease 0.05s; + z-index: 11; + }} + + th[data-tip]::after {{ + content: attr(data-tip); + position: absolute; + top: calc(100% + 11px); + left: 50%; + transform: translateX(-50%) translateY(2px); + background: var(--bg-deep); + border: 1px solid rgba(232,212,77,0.2); + color: var(--text-primary); + font-family: 'IBM Plex Sans', sans-serif; + font-size: 11px; + font-weight: 400; + text-transform: none; + letter-spacing: 0.2px; + line-height: 1.4; + padding: 8px 14px; + border-radius: 6px; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.2s ease 0.05s, transform 0.2s ease 0.05s; + z-index: 10; + box-shadow: 0 8px 24px rgba(0,0,0,0.5), 0 0 0 1px rgba(232,212,77,0.06); + }} + + th[data-tip]:hover::before, + th[data-tip]:focus-visible::before, + th[data-tip]:hover::after, + th[data-tip]:focus-visible::after {{ + opacity: 1; + }} + + th[data-tip]:hover::after, + th[data-tip]:focus-visible::after {{ + transform: translateX(-50%) translateY(0); + }} + .positive {{ color: var(--positive); }} .negative {{ color: var(--negative); }} .neutral {{ color: var(--text-secondary); }} @@ -540,6 +609,7 @@ def fmt_val(val, nominal=None, suffix="%"): .title {{ font-size: 22px; }} .dashboard {{ padding: 16px 12px 32px; }} #cone-chart {{ height: 320px; }} + .table-container {{ overflow-x: auto; }} }} @@ -575,7 +645,7 @@ def fmt_val(val, nominal=None, suffix="%"):
-
scroll to zoom · drag to pan · double-click to reset
+
click legend to toggle assets · scroll to zoom · drag to pan · double-click to reset
@@ -589,12 +659,13 @@ def fmt_val(val, nominal=None, suffix="%"): # Asset Price - Median Move - Volatility - Skew - Range - vs SPY - Skew vs SPY + 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 @@ -622,9 +693,10 @@ def fmt_val(val, nominal=None, suffix="%"): }}, margin: {{ t: 8, r: 16, b: 40, l: 48 }}, xaxis: {{ - title: {{ text: 'Time Steps (5-min intervals)', font: {{ size: 10 }} }}, + title: {{ text: 'Time (ET)', font: {{ size: 10 }} }}, gridcolor: 'rgba(30,42,64,0.7)', zerolinecolor: 'rgba(30,42,64,0.9)', + tickformat: '%I:%M %p', tickfont: {{ family: 'IBM Plex Mono, monospace', size: 10 }} }}, yaxis: {{ @@ -644,6 +716,7 @@ def fmt_val(val, nominal=None, suffix="%"): font: {{ size: 11 }}, itemwidth: 30 }}, + dragmode: 'pan', hovermode: 'x unified', hoverlabel: {{ bgcolor: '#111827', @@ -661,6 +734,19 @@ def fmt_val(val, nominal=None, suffix="%"): Plotly.newPlot('cone-chart', traces, layout, config); + // Rescale y-axis after legend toggle (show/hide) + var chart = document.getElementById('cone-chart'); + chart.on('plotly_legendclick', function() {{ + setTimeout(function() {{ + Plotly.relayout('cone-chart', {{ 'yaxis.autorange': true }}); + }}, 100); + }}); + chart.on('plotly_legenddoubleclick', function() {{ + setTimeout(function() {{ + Plotly.relayout('cone-chart', {{ 'yaxis.autorange': true }}); + }}, 100); + }}); + // Sortable table (function() {{ var table = document.getElementById('rank-table'); @@ -680,9 +766,13 @@ def fmt_val(val, nominal=None, suffix="%"): headers.forEach(function(h) {{ h.classList.remove('asc', 'desc'); h.setAttribute('aria-sort', 'none'); + var arrow = h.querySelector('.sort-arrow'); + if (arrow) arrow.textContent = '\u25B4\u25BE'; }}); th.classList.add(currentDir); th.setAttribute('aria-sort', currentDir === 'desc' ? 'descending' : 'ascending'); + var activeArrow = th.querySelector('.sort-arrow'); + if (activeArrow) activeArrow.textContent = currentDir === 'asc' ? '\u25B4' : '\u25BE'; var tbody = table.querySelector('tbody'); var rows = Array.from(tbody.querySelectorAll('tr')); diff --git a/tools/tide-chart/tests/test_tool.py b/tools/tide-chart/tests/test_tool.py index c09508c..095d465 100644 --- a/tools/tide-chart/tests/test_tool.py +++ b/tools/tide-chart/tests/test_tool.py @@ -204,6 +204,23 @@ def test_generate_dashboard_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(): @@ -230,6 +247,23 @@ def test_calculate_metrics_nominal_values(): 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() @@ -253,6 +287,7 @@ def test_volatility_values(): 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!")