Add pivot point support/resistance chart to dashboard

Add Classic Pivot Points (P, R1-R3, S1-S3) computed from previous day's
OHLC data, displayed as horizontal lines on an interactive Plotly.js
candlestick chart at /chart with instrument/granularity selectors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Brent Neale
2026-02-17 14:28:58 +10:00
parent f4734f57c7
commit d843e63e7b
4 changed files with 234 additions and 1 deletions
+82 -1
View File
@@ -13,7 +13,8 @@ from pathlib import Path
from functools import wraps
import yaml
from flask import Flask, render_template, request, redirect, url_for, flash, Response
import pandas as pd
from flask import Flask, render_template, request, redirect, url_for, flash, Response, jsonify
app = Flask(__name__, template_folder=str(Path(__file__).resolve().parent.parent / "templates"))
app.secret_key = os.urandom(24)
@@ -289,6 +290,86 @@ def killswitch():
return redirect(url_for("index"))
@app.route("/chart")
@requires_auth
def chart():
"""Candlestick chart with Classic Pivot Point support/resistance levels."""
instrument = request.args.get("instrument", "EUR_USD")
granularity = request.args.get("granularity", "M15")
# Validate inputs
if instrument not in VALID_INSTRUMENTS:
instrument = "EUR_USD"
if granularity not in VALID_GRANULARITIES:
granularity = "M15"
ohlc_json = "[]"
pivot_json = "{}"
error_msg = None
try:
from supabase import create_client
from dotenv import load_dotenv
env_path = APP_ROOT / "config" / ".env"
if env_path.exists():
load_dotenv(env_path)
url = os.environ.get("SUPABASE_URL")
key = os.environ.get("SUPABASE_KEY")
if url and key:
sb = create_client(url, key)
cfg = load_config()
table = cfg.get("supabase", {}).get("table", "fx_candles")
resp = (
sb.table(table)
.select("time,open,high,low,close")
.eq("instrument", instrument)
.eq("granularity", granularity)
.order("time", desc=True)
.limit(500)
.execute()
)
rows = resp.data if resp.data else []
if rows:
df = pd.DataFrame(rows)
df["time"] = pd.to_datetime(df["time"])
df = df.sort_values("time").set_index("time")
for col in ["open", "high", "low", "close"]:
df[col] = df[col].astype(float)
# Compute pivot points
from data_engine import add_pivot_points
df = add_pivot_points(df)
# Prepare OHLC JSON
ohlc_data = df[["open", "high", "low", "close"]].reset_index()
ohlc_data["time"] = ohlc_data["time"].dt.strftime("%Y-%m-%d %H:%M")
ohlc_json = ohlc_data.to_json(orient="records")
# Prepare pivot levels (latest non-null values)
pivot_cols = ["pivot", "r1", "r2", "r3", "s1", "s2", "s3"]
latest = df[pivot_cols].dropna().iloc[-1] if df[pivot_cols].dropna().shape[0] > 0 else None
if latest is not None:
pivot_json = latest.to_json()
else:
error_msg = "Supabase credentials not configured."
except Exception as e:
error_msg = f"Could not load candle data: {e}"
return render_template("chart.html",
ohlc_json=ohlc_json,
pivot_json=pivot_json,
instrument=instrument,
granularity=granularity,
instruments=VALID_INSTRUMENTS,
granularities=VALID_GRANULARITIES,
error_msg=error_msg)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
+40
View File
@@ -90,6 +90,46 @@ def add_vwap(df, period=20):
return df
def add_pivot_points(df):
"""
Add Classic Pivot Point support/resistance levels.
Resamples intraday OHLC data to daily bars, computes pivot levels from the
*previous* day's High/Low/Close, and merges them back onto the original
DataFrame so every intraday bar carries the current day's pivot levels.
Columns added: pivot, r1, r2, r3, s1, s2, s3
"""
# Need a DatetimeIndex to resample
df2 = df.copy()
daily = df2.resample("D").agg({"high": "max", "low": "min", "close": "last"}).dropna()
# Compute pivots from previous day
daily["pivot"] = (daily["high"] + daily["low"] + daily["close"]) / 3
daily["r1"] = 2 * daily["pivot"] - daily["low"]
daily["s1"] = 2 * daily["pivot"] - daily["high"]
daily["r2"] = daily["pivot"] + (daily["high"] - daily["low"])
daily["s2"] = daily["pivot"] - (daily["high"] - daily["low"])
daily["r3"] = daily["high"] + 2 * (daily["pivot"] - daily["low"])
daily["s3"] = daily["low"] - 2 * (daily["high"] - daily["pivot"])
# Shift so today's bars use *yesterday's* pivot levels
pivot_cols = ["pivot", "r1", "r2", "r3", "s1", "s2", "s3"]
daily_pivots = daily[pivot_cols].shift(1)
# Assign a date key to the original df for merging
df["_date"] = df.index.date
daily_pivots["_date"] = daily_pivots.index.date
df = df.merge(daily_pivots, on="_date", how="left")
df.drop(columns=["_date"], inplace=True)
# Restore the original DatetimeIndex
df.index = df2.index
return df
def build_all_features(df, config=None):
"""
Run a standard set of features. config is optional dict matching the
+1
View File
@@ -24,6 +24,7 @@
<div class="navbar-nav">
<a class="nav-link" href="/">Status</a>
<a class="nav-link" href="/backtest">Backtest</a>
<a class="nav-link" href="/chart">Chart</a>
<a class="nav-link" href="/config">Config</a>
<a class="nav-link" href="/logs">Logs</a>
</div>
+111
View File
@@ -0,0 +1,111 @@
{% extends "base.html" %}
{% block title %}Chart - fx-quant{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col">
<h4>Price Chart with Pivot Points</h4>
</div>
<div class="col-auto">
<form method="get" action="/chart" class="d-flex gap-2">
<select name="instrument" class="form-select form-select-sm" onchange="this.form.submit()">
{% for inst in instruments %}
<option value="{{ inst }}" {% if inst == instrument %}selected{% endif %}>{{ inst }}</option>
{% endfor %}
</select>
<select name="granularity" class="form-select form-select-sm" onchange="this.form.submit()">
{% for g in granularities %}
<option value="{{ g }}" {% if g == granularity %}selected{% endif %}>{{ g }}</option>
{% endfor %}
</select>
</form>
</div>
</div>
{% if error_msg %}
<div class="alert alert-warning">{{ error_msg }}</div>
{% endif %}
<div class="card">
<div class="card-body p-2">
<div id="chart" style="width:100%; height:600px;"></div>
</div>
</div>
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js" charset="utf-8"></script>
<script>
(function() {
var ohlc = {{ ohlc_json | safe }};
var pivots = {{ pivot_json | safe }};
if (!ohlc || ohlc.length === 0) {
document.getElementById('chart').innerHTML =
'<p class="text-muted text-center mt-5">No candle data available for {{ instrument }} / {{ granularity }}.</p>';
return;
}
var times = ohlc.map(function(r) { return r.time; });
var opens = ohlc.map(function(r) { return r.open; });
var highs = ohlc.map(function(r) { return r.high; });
var lows = ohlc.map(function(r) { return r.low; });
var closes = ohlc.map(function(r) { return r.close; });
var traces = [{
x: times, open: opens, high: highs, low: lows, close: closes,
type: 'candlestick',
increasing: {line: {color: '#198754'}},
decreasing: {line: {color: '#dc3545'}},
name: '{{ instrument }}'
}];
// Pivot level lines
var levelDefs = [
{key: 'r3', color: '#b71c1c', label: 'R3'},
{key: 'r2', color: '#e53935', label: 'R2'},
{key: 'r1', color: '#ef9a9a', label: 'R1'},
{key: 'pivot', color: '#1565c0', label: 'Pivot'},
{key: 's1', color: '#a5d6a7', label: 'S1'},
{key: 's2', color: '#43a047', label: 'S2'},
{key: 's3', color: '#1b5e20', label: 'S3'}
];
var shapes = [];
var annotations = [];
levelDefs.forEach(function(lv) {
var val = pivots[lv.key];
if (val == null) return;
shapes.push({
type: 'line',
x0: times[0], x1: times[times.length - 1],
y0: val, y1: val,
line: {color: lv.color, width: 1.5, dash: lv.key === 'pivot' ? 'solid' : 'dash'}
});
annotations.push({
x: times[times.length - 1], y: val,
xanchor: 'left', yanchor: 'middle',
text: ' ' + lv.label + ' ' + val.toFixed(5),
showarrow: false,
font: {size: 11, color: lv.color}
});
});
var layout = {
title: '{{ instrument }} {{ granularity }}',
xaxis: {
type: 'category',
rangeslider: {visible: false},
nticks: 12
},
yaxis: {title: 'Price'},
shapes: shapes,
annotations: annotations,
margin: {l: 60, r: 100, t: 40, b: 40},
paper_bgcolor: '#fff',
plot_bgcolor: '#f8f9fa'
};
Plotly.newPlot('chart', traces, layout, {responsive: true});
})();
</script>
{% endblock %}