mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-07-28 10:57:43 +00:00
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:
+82
-1
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user