From 79ff4a8290b56d149ec7a84527b4e431f6a61a56 Mon Sep 17 00:00:00 2001 From: Marcos Date: Wed, 16 Sep 2026 10:30:48 +0200 Subject: [PATCH] feat: add correlation charts for profit and volume routed in routing dashboard --- .../provisioning/dashboards/routing.json | 200 ++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/docker/grafana/provisioning/dashboards/routing.json b/docker/grafana/provisioning/dashboards/routing.json index 5e797d7a..901222ac 100644 --- a/docker/grafana/provisioning/dashboards/routing.json +++ b/docker/grafana/provisioning/dashboards/routing.json @@ -8395,6 +8395,206 @@ ], "title": "Fee ppm league table — earned vs priced-out", "type": "table" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "$datasource" + }, + "description": "Each bar runs from the zero line: to the right for a positive correlation, to the left for a negative one, and its length is the strength. The gridline through the middle is 0, meaning no linear relationship.\n\nProfit answers 'did this driver make us more money', APR answers 'did it make our capital work harder'. They come apart, and the gap is the interesting part: a driver that lifts profit but drags APR down is buying revenue with capital rather than efficiency. For Qualifying liquidity in particular, the profit bar is almost always positive and mostly restates that more capital earns more fees, while the APR bar is the one that tells you whether ADDING capital would help - near zero means the return rate survives, so you are capital constrained; clearly negative means the extra sats sit idle.\n\nOne caveat on Avg fee ppm -> profit: fee revenue is amount x ppm, so part of that correlation is arithmetic rather than a pricing insight. Avg fee ppm -> APR and the volume panel below are the cleaner reads on whether your pricing is working.\n\nA bar is blank when the node has under 3 days of data, or when one of the two series never varies - a flat series carries no correlation, and Postgres would otherwise return a confident but meaningless number for it.\n\nRebalance figures are SUM(AttemptNumber), since retries reuse the row: Total counts every rebalance regardless of outcome, Succeeded counts status 3 and 8 (8 is a succeeded payment relabelled for exceeding the ppm cap, so it moved sats). Because Total contains Succeeded, comparing the two is what isolates the failures: Total scoring clearly higher than Succeeded means the attempts that moved no liquidity are still tracking the outcome.\n\nAvg fee ppm is the outgoing policy price quoted on settled forwards that day, so it can only be sampled on channels that actually saw traffic. Both panels share one sample - node-days with no deployed liquidity are excluded - so the coefficients are directly comparable. Profit and APR are net and follow the swap/rebalance cost toggles; Liquidity Scope and Active Channels Only apply throughout. Rough reading: below 0.2 is noise, 0.2-0.4 weak, 0.4-0.7 moderate, above 0.7 strong.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": true, + "axisColorMode": "text", + "axisGridShow": true, + "axisLabel": "Pearson r (0 = no correlation)", + "axisPlacement": "auto", + "fillOpacity": 85, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "decimals": 2, + "mappings": [], + "max": 1, + "min": -1, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "transparent", + "value": null + }, + { + "color": "text", + "value": 0 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 16, + "w": 24, + "x": 0, + "y": 297 + }, + "id": 800, + "options": { + "barRadius": 0, + "barWidth": 0.8, + "fullHighlight": false, + "groupWidth": 0.8, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "12.4.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "$datasource" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "WITH days AS (\n SELECT generate_series(date_trunc('day', $__timeFrom()::timestamptz), $__timeTo()::timestamptz, INTERVAL '1 day') AS bucket_start\n), day_buckets AS (\n SELECT bucket_start, bucket_start + INTERVAL '1 day' AS bucket_end\n FROM days\n WHERE bucket_start + INTERVAL '1 day' <= LEAST($__timeTo()::timestamptz, now())\n), nodes AS (\n SELECT \"ManagedNodePubKey\" AS pubkey, MAX(\"ManagedNodeName\") AS node_name\n FROM \"ForwardingHtlcEvents\"\n WHERE $__timeFilter(\"EventTimestamp\") AND \"ManagedNodePubKey\" IN ($node)\n GROUP BY 1\n), participating_node_chans AS (\n SELECT DISTINCT f.\"ManagedNodePubKey\" AS pubkey, u.chan_id\n FROM \"ForwardingHtlcEvents\" f\n CROSS JOIN LATERAL (VALUES (f.\"IncomingChannelId\"), (f.\"OutgoingChannelId\")) u(chan_id)\n WHERE $__timeFilter(f.\"EventTimestamp\") AND f.\"Outcome\" = 1 AND f.\"FeeMsat\" IS NOT NULL AND f.\"ManagedNodePubKey\" IN ($node)\n UNION\n SELECT n.\"PubKey\" AS pubkey, c.\"ChanId\" AS chan_id\n FROM \"Channels\" c\n JOIN \"Nodes\" n ON n.\"Id\" IN (c.\"SourceNodeId\", c.\"DestinationNodeId\")\n WHERE $activeChannelsOnly = 0 AND n.\"PubKey\" IN ($node)\n), day_node_grid AS (\n SELECT db.bucket_start, db.bucket_end, n.pubkey, n.node_name\n FROM day_buckets db CROSS JOIN nodes n\n), day_forwards AS (\n SELECT g.bucket_start, g.pubkey, g.node_name,\n COALESCE(ROUND(SUM(f.\"FeeMsat\") / 1000.0)::bigint, 0) AS gross_fee_sats,\n COALESCE(SUM(COALESCE(f.\"OutgoingAmountMsat\", f.\"IncomingAmountMsat\")) / 1e11, 0) AS routed_btc,\n -- policy ppm on the outgoing channel at event time, so this is the price actually\n -- quoted that day; it can only be sampled on channels that saw traffic\n AVG(f.\"RoutingFeePpm\") AS avg_fee_ppm\n FROM day_node_grid g\n LEFT JOIN \"ForwardingHtlcEvents\" f\n ON f.\"EventTimestamp\" >= g.bucket_start AND f.\"EventTimestamp\" < g.bucket_end\n AND f.\"Outcome\" = 1 AND f.\"FeeMsat\" IS NOT NULL AND f.\"ManagedNodePubKey\" = g.pubkey\n GROUP BY g.bucket_start, g.pubkey, g.node_name\n), day_yield_costs AS (\n SELECT g.bucket_start, g.pubkey, COALESCE(SUM(yc.cost_sats), 0) AS yield_cost_sats\n FROM day_node_grid g\n LEFT JOIN \"Nodes\" n ON n.\"PubKey\" = g.pubkey\n LEFT JOIN (\n SELECT s.\"NodeId\", s.\"CreationDatetime\" AS cost_time,\n $applySwapCosts * (COALESCE(s.\"ServiceFeeSats\", 0) + COALESCE(s.\"LightningFeeSats\", 0) + COALESCE(s.\"OnChainFeeSats\", 0)) AS cost_sats\n FROM \"SwapOuts\" s\n WHERE s.\"Status\" = 1 AND $__timeFilter(s.\"CreationDatetime\")\n UNION ALL\n SELECT rb.\"NodeId\", rb.\"CreationDatetime\" AS cost_time,\n $applyRebalanceCosts * COALESCE(rb.\"FeePaidSats\", 0) AS cost_sats\n FROM \"Rebalances\" rb\n WHERE rb.\"Status\" = 3 AND rb.\"FeePaidSats\" IS NOT NULL AND $__timeFilter(rb.\"CreationDatetime\")\n ) yc ON yc.\"NodeId\" = n.\"Id\" AND yc.cost_time >= g.bucket_start AND yc.cost_time < g.bucket_end\n GROUP BY g.bucket_start, g.pubkey\n), day_liquidity AS (\n SELECT g.bucket_start, g.pubkey,\n COALESCE(SUM(c.\"SatsAmount\" * GREATEST(EXTRACT(EPOCH FROM (\n LEAST(CASE WHEN c.\"Status\" = 2 THEN COALESCE(c.\"ClosedAt\", c.\"UpdateDatetime\") ELSE g.bucket_end END, g.bucket_end)\n - GREATEST(c.\"CreationDatetime\", g.bucket_start))) / 86400.0, 0.0)), 0) AS sat_days\n FROM day_node_grid g\n LEFT JOIN participating_node_chans pc ON pc.pubkey = g.pubkey\n LEFT JOIN \"Channels\" c ON c.\"ChanId\" = pc.chan_id AND (c.\"CreatedByNodeGuard\" = TRUE OR $liquidityScope = 0)\n GROUP BY g.bucket_start, g.pubkey\n), day_rebalances AS (\n -- Retries reuse the row and bump AttemptNumber, so SUM(AttemptNumber) counts payment attempts\n -- rather than rebalance intents. Total is every status; succeeded is 3 plus 8, since 8 is a\n -- succeeded payment relabelled for blowing the ppm cap and it did move sats.\n SELECT g.bucket_start, g.pubkey,\n COALESCE(SUM(rb.\"AttemptNumber\"), 0) AS total_attempts,\n COALESCE(SUM(rb.\"AttemptNumber\") FILTER (WHERE rb.\"Status\" IN (3, 8)), 0) AS ok_attempts\n FROM day_node_grid g\n LEFT JOIN \"Nodes\" n ON n.\"PubKey\" = g.pubkey\n LEFT JOIN \"Rebalances\" rb\n ON rb.\"NodeId\" = n.\"Id\"\n AND rb.\"CreationDatetime\" >= g.bucket_start AND rb.\"CreationDatetime\" < g.bucket_end\n GROUP BY g.bucket_start, g.pubkey\n), obs AS (\n SELECT\n df.node_name,\n COALESCE(dr.total_attempts, 0)::float8 AS total_rebalances,\n COALESCE(dr.ok_attempts, 0)::float8 AS ok_rebalances,\n (dl.sat_days / 100000000.0)::float8 AS liquidity_btc,\n df.avg_fee_ppm::float8 AS avg_fee_ppm,\n (df.gross_fee_sats - COALESCE(dyc.yield_cost_sats, 0))::float8 AS profit_sats,\n ((df.gross_fee_sats - COALESCE(dyc.yield_cost_sats, 0))::numeric * 365.0\n / NULLIF(dl.sat_days, 0))::float8 AS apr,\n df.routed_btc::float8 AS routed_btc\n FROM day_forwards df\n LEFT JOIN day_yield_costs dyc ON dyc.bucket_start = df.bucket_start AND dyc.pubkey = df.pubkey\n LEFT JOIN day_liquidity dl ON dl.bucket_start = df.bucket_start AND dl.pubkey = df.pubkey\n LEFT JOIN day_rebalances dr ON dr.bucket_start = df.bucket_start AND dr.pubkey = df.pubkey\n WHERE COALESCE(dl.sat_days, 0) > 0\n)\nSELECT\n node_name || ' (n=' || COUNT(*) || ')' AS \"Node\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(total_rebalances) - MIN(total_rebalances)) > 1e-9 * GREATEST(ABS(AVG(total_rebalances)), 1e-9) AND (MAX(profit_sats) - MIN(profit_sats)) > 1e-9 * GREATEST(ABS(AVG(profit_sats)), 1e-9)\n THEN corr(profit_sats, total_rebalances) END AS \"Total rebalances -> profit\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(ok_rebalances) - MIN(ok_rebalances)) > 1e-9 * GREATEST(ABS(AVG(ok_rebalances)), 1e-9) AND (MAX(profit_sats) - MIN(profit_sats)) > 1e-9 * GREATEST(ABS(AVG(profit_sats)), 1e-9)\n THEN corr(profit_sats, ok_rebalances) END AS \"Succeeded rebalances -> profit\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(liquidity_btc) - MIN(liquidity_btc)) > 1e-9 * GREATEST(ABS(AVG(liquidity_btc)), 1e-9) AND (MAX(profit_sats) - MIN(profit_sats)) > 1e-9 * GREATEST(ABS(AVG(profit_sats)), 1e-9)\n THEN corr(profit_sats, liquidity_btc) END AS \"Qualifying liquidity -> profit\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(avg_fee_ppm) - MIN(avg_fee_ppm)) > 1e-9 * GREATEST(ABS(AVG(avg_fee_ppm)), 1e-9) AND (MAX(profit_sats) - MIN(profit_sats)) > 1e-9 * GREATEST(ABS(AVG(profit_sats)), 1e-9)\n THEN corr(profit_sats, avg_fee_ppm) END AS \"Avg fee ppm -> profit\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(total_rebalances) - MIN(total_rebalances)) > 1e-9 * GREATEST(ABS(AVG(total_rebalances)), 1e-9) AND (MAX(apr) - MIN(apr)) > 1e-9 * GREATEST(ABS(AVG(apr)), 1e-9)\n THEN corr(apr, total_rebalances) END AS \"Total rebalances -> APR\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(ok_rebalances) - MIN(ok_rebalances)) > 1e-9 * GREATEST(ABS(AVG(ok_rebalances)), 1e-9) AND (MAX(apr) - MIN(apr)) > 1e-9 * GREATEST(ABS(AVG(apr)), 1e-9)\n THEN corr(apr, ok_rebalances) END AS \"Succeeded rebalances -> APR\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(liquidity_btc) - MIN(liquidity_btc)) > 1e-9 * GREATEST(ABS(AVG(liquidity_btc)), 1e-9) AND (MAX(apr) - MIN(apr)) > 1e-9 * GREATEST(ABS(AVG(apr)), 1e-9)\n THEN corr(apr, liquidity_btc) END AS \"Qualifying liquidity -> APR\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(avg_fee_ppm) - MIN(avg_fee_ppm)) > 1e-9 * GREATEST(ABS(AVG(avg_fee_ppm)), 1e-9) AND (MAX(apr) - MIN(apr)) > 1e-9 * GREATEST(ABS(AVG(apr)), 1e-9)\n THEN corr(apr, avg_fee_ppm) END AS \"Avg fee ppm -> APR\"\nFROM obs\nGROUP BY node_name\nORDER BY node_name", + "refId": "A" + } + ], + "title": "Correlations x -> profit / APR (by node, -1 to +1)", + "type": "barchart" + }, + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "$datasource" + }, + "description": "Each bar runs from the zero line: to the right for a positive correlation, to the left for a negative one, and its length is the strength. The gridline through the middle is 0, meaning no linear relationship.\n\nVolume is the demand-side read, and it separates 'we earned more' from 'we moved more'. Rebalances correlating with volume says the activity is shifting liquidity to where flow wants to go. Qualifying liquidity correlating with volume says capacity is the binding constraint on throughput. And Avg fee ppm -> volume is the price elasticity of your routing: a clearly negative bar means raising fees is costing you flow, while a bar near zero means demand is not noticing your price and there may be headroom to charge more.\n\nRead this against the profit panel. Volume up with profit flat means you are routing more for less; profit up with volume flat means pricing is doing the work.\n\nA bar is blank when the node has under 3 days of data, or when one of the two series never varies - a flat series carries no correlation, and Postgres would otherwise return a confident but meaningless number for it.\n\nRebalance figures are SUM(AttemptNumber), since retries reuse the row: Total counts every rebalance regardless of outcome, Succeeded counts status 3 and 8 (8 is a succeeded payment relabelled for exceeding the ppm cap, so it moved sats). Because Total contains Succeeded, comparing the two is what isolates the failures: Total scoring clearly higher than Succeeded means the attempts that moved no liquidity are still tracking the outcome.\n\nAvg fee ppm is the outgoing policy price quoted on settled forwards that day, so it can only be sampled on channels that actually saw traffic. Both panels share one sample - node-days with no deployed liquidity are excluded - so the coefficients are directly comparable. Profit and APR are net and follow the swap/rebalance cost toggles; Liquidity Scope and Active Channels Only apply throughout. Rough reading: below 0.2 is noise, 0.2-0.4 weak, 0.4-0.7 moderate, above 0.7 strong.", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": true, + "axisColorMode": "text", + "axisGridShow": true, + "axisLabel": "Pearson r (0 = no correlation)", + "axisPlacement": "auto", + "fillOpacity": 85, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineWidth": 1, + "scaleDistribution": { + "type": "linear" + }, + "thresholdsStyle": { + "mode": "line" + } + }, + "decimals": 2, + "mappings": [], + "max": 1, + "min": -1, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "transparent", + "value": null + }, + { + "color": "text", + "value": 0 + } + ] + }, + "unit": "none" + }, + "overrides": [] + }, + "gridPos": { + "h": 11, + "w": 24, + "x": 0, + "y": 313 + }, + "id": 801, + "options": { + "barRadius": 0, + "barWidth": 0.8, + "fullHighlight": false, + "groupWidth": 0.8, + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "orientation": "horizontal", + "showValue": "auto", + "stacking": "none", + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + }, + "xTickLabelRotation": 0, + "xTickLabelSpacing": 0 + }, + "pluginVersion": "12.4.2", + "targets": [ + { + "datasource": { + "type": "grafana-postgresql-datasource", + "uid": "$datasource" + }, + "editorMode": "code", + "format": "table", + "rawQuery": true, + "rawSql": "WITH days AS (\n SELECT generate_series(date_trunc('day', $__timeFrom()::timestamptz), $__timeTo()::timestamptz, INTERVAL '1 day') AS bucket_start\n), day_buckets AS (\n SELECT bucket_start, bucket_start + INTERVAL '1 day' AS bucket_end\n FROM days\n WHERE bucket_start + INTERVAL '1 day' <= LEAST($__timeTo()::timestamptz, now())\n), nodes AS (\n SELECT \"ManagedNodePubKey\" AS pubkey, MAX(\"ManagedNodeName\") AS node_name\n FROM \"ForwardingHtlcEvents\"\n WHERE $__timeFilter(\"EventTimestamp\") AND \"ManagedNodePubKey\" IN ($node)\n GROUP BY 1\n), participating_node_chans AS (\n SELECT DISTINCT f.\"ManagedNodePubKey\" AS pubkey, u.chan_id\n FROM \"ForwardingHtlcEvents\" f\n CROSS JOIN LATERAL (VALUES (f.\"IncomingChannelId\"), (f.\"OutgoingChannelId\")) u(chan_id)\n WHERE $__timeFilter(f.\"EventTimestamp\") AND f.\"Outcome\" = 1 AND f.\"FeeMsat\" IS NOT NULL AND f.\"ManagedNodePubKey\" IN ($node)\n UNION\n SELECT n.\"PubKey\" AS pubkey, c.\"ChanId\" AS chan_id\n FROM \"Channels\" c\n JOIN \"Nodes\" n ON n.\"Id\" IN (c.\"SourceNodeId\", c.\"DestinationNodeId\")\n WHERE $activeChannelsOnly = 0 AND n.\"PubKey\" IN ($node)\n), day_node_grid AS (\n SELECT db.bucket_start, db.bucket_end, n.pubkey, n.node_name\n FROM day_buckets db CROSS JOIN nodes n\n), day_forwards AS (\n SELECT g.bucket_start, g.pubkey, g.node_name,\n COALESCE(ROUND(SUM(f.\"FeeMsat\") / 1000.0)::bigint, 0) AS gross_fee_sats,\n COALESCE(SUM(COALESCE(f.\"OutgoingAmountMsat\", f.\"IncomingAmountMsat\")) / 1e11, 0) AS routed_btc,\n -- policy ppm on the outgoing channel at event time, so this is the price actually\n -- quoted that day; it can only be sampled on channels that saw traffic\n AVG(f.\"RoutingFeePpm\") AS avg_fee_ppm\n FROM day_node_grid g\n LEFT JOIN \"ForwardingHtlcEvents\" f\n ON f.\"EventTimestamp\" >= g.bucket_start AND f.\"EventTimestamp\" < g.bucket_end\n AND f.\"Outcome\" = 1 AND f.\"FeeMsat\" IS NOT NULL AND f.\"ManagedNodePubKey\" = g.pubkey\n GROUP BY g.bucket_start, g.pubkey, g.node_name\n), day_yield_costs AS (\n SELECT g.bucket_start, g.pubkey, COALESCE(SUM(yc.cost_sats), 0) AS yield_cost_sats\n FROM day_node_grid g\n LEFT JOIN \"Nodes\" n ON n.\"PubKey\" = g.pubkey\n LEFT JOIN (\n SELECT s.\"NodeId\", s.\"CreationDatetime\" AS cost_time,\n $applySwapCosts * (COALESCE(s.\"ServiceFeeSats\", 0) + COALESCE(s.\"LightningFeeSats\", 0) + COALESCE(s.\"OnChainFeeSats\", 0)) AS cost_sats\n FROM \"SwapOuts\" s\n WHERE s.\"Status\" = 1 AND $__timeFilter(s.\"CreationDatetime\")\n UNION ALL\n SELECT rb.\"NodeId\", rb.\"CreationDatetime\" AS cost_time,\n $applyRebalanceCosts * COALESCE(rb.\"FeePaidSats\", 0) AS cost_sats\n FROM \"Rebalances\" rb\n WHERE rb.\"Status\" = 3 AND rb.\"FeePaidSats\" IS NOT NULL AND $__timeFilter(rb.\"CreationDatetime\")\n ) yc ON yc.\"NodeId\" = n.\"Id\" AND yc.cost_time >= g.bucket_start AND yc.cost_time < g.bucket_end\n GROUP BY g.bucket_start, g.pubkey\n), day_liquidity AS (\n SELECT g.bucket_start, g.pubkey,\n COALESCE(SUM(c.\"SatsAmount\" * GREATEST(EXTRACT(EPOCH FROM (\n LEAST(CASE WHEN c.\"Status\" = 2 THEN COALESCE(c.\"ClosedAt\", c.\"UpdateDatetime\") ELSE g.bucket_end END, g.bucket_end)\n - GREATEST(c.\"CreationDatetime\", g.bucket_start))) / 86400.0, 0.0)), 0) AS sat_days\n FROM day_node_grid g\n LEFT JOIN participating_node_chans pc ON pc.pubkey = g.pubkey\n LEFT JOIN \"Channels\" c ON c.\"ChanId\" = pc.chan_id AND (c.\"CreatedByNodeGuard\" = TRUE OR $liquidityScope = 0)\n GROUP BY g.bucket_start, g.pubkey\n), day_rebalances AS (\n -- Retries reuse the row and bump AttemptNumber, so SUM(AttemptNumber) counts payment attempts\n -- rather than rebalance intents. Total is every status; succeeded is 3 plus 8, since 8 is a\n -- succeeded payment relabelled for blowing the ppm cap and it did move sats.\n SELECT g.bucket_start, g.pubkey,\n COALESCE(SUM(rb.\"AttemptNumber\"), 0) AS total_attempts,\n COALESCE(SUM(rb.\"AttemptNumber\") FILTER (WHERE rb.\"Status\" IN (3, 8)), 0) AS ok_attempts\n FROM day_node_grid g\n LEFT JOIN \"Nodes\" n ON n.\"PubKey\" = g.pubkey\n LEFT JOIN \"Rebalances\" rb\n ON rb.\"NodeId\" = n.\"Id\"\n AND rb.\"CreationDatetime\" >= g.bucket_start AND rb.\"CreationDatetime\" < g.bucket_end\n GROUP BY g.bucket_start, g.pubkey\n), obs AS (\n SELECT\n df.node_name,\n COALESCE(dr.total_attempts, 0)::float8 AS total_rebalances,\n COALESCE(dr.ok_attempts, 0)::float8 AS ok_rebalances,\n (dl.sat_days / 100000000.0)::float8 AS liquidity_btc,\n df.avg_fee_ppm::float8 AS avg_fee_ppm,\n (df.gross_fee_sats - COALESCE(dyc.yield_cost_sats, 0))::float8 AS profit_sats,\n ((df.gross_fee_sats - COALESCE(dyc.yield_cost_sats, 0))::numeric * 365.0\n / NULLIF(dl.sat_days, 0))::float8 AS apr,\n df.routed_btc::float8 AS routed_btc\n FROM day_forwards df\n LEFT JOIN day_yield_costs dyc ON dyc.bucket_start = df.bucket_start AND dyc.pubkey = df.pubkey\n LEFT JOIN day_liquidity dl ON dl.bucket_start = df.bucket_start AND dl.pubkey = df.pubkey\n LEFT JOIN day_rebalances dr ON dr.bucket_start = df.bucket_start AND dr.pubkey = df.pubkey\n WHERE COALESCE(dl.sat_days, 0) > 0\n)\nSELECT\n node_name || ' (n=' || COUNT(*) || ')' AS \"Node\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(total_rebalances) - MIN(total_rebalances)) > 1e-9 * GREATEST(ABS(AVG(total_rebalances)), 1e-9) AND (MAX(routed_btc) - MIN(routed_btc)) > 1e-9 * GREATEST(ABS(AVG(routed_btc)), 1e-9)\n THEN corr(routed_btc, total_rebalances) END AS \"Total rebalances -> volume routed\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(ok_rebalances) - MIN(ok_rebalances)) > 1e-9 * GREATEST(ABS(AVG(ok_rebalances)), 1e-9) AND (MAX(routed_btc) - MIN(routed_btc)) > 1e-9 * GREATEST(ABS(AVG(routed_btc)), 1e-9)\n THEN corr(routed_btc, ok_rebalances) END AS \"Succeeded rebalances -> volume routed\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(liquidity_btc) - MIN(liquidity_btc)) > 1e-9 * GREATEST(ABS(AVG(liquidity_btc)), 1e-9) AND (MAX(routed_btc) - MIN(routed_btc)) > 1e-9 * GREATEST(ABS(AVG(routed_btc)), 1e-9)\n THEN corr(routed_btc, liquidity_btc) END AS \"Qualifying liquidity -> volume routed\",\n CASE WHEN COUNT(*) >= 3 AND (MAX(avg_fee_ppm) - MIN(avg_fee_ppm)) > 1e-9 * GREATEST(ABS(AVG(avg_fee_ppm)), 1e-9) AND (MAX(routed_btc) - MIN(routed_btc)) > 1e-9 * GREATEST(ABS(AVG(routed_btc)), 1e-9)\n THEN corr(routed_btc, avg_fee_ppm) END AS \"Avg fee ppm -> volume routed\"\nFROM obs\nGROUP BY node_name\nORDER BY node_name", + "refId": "A" + } + ], + "title": "Correlations x -> volume routed (by node, -1 to +1)", + "type": "barchart" } ], "preload": false,