mirror of
https://github.com/BrentNeale1/fx-quant.git
synced 2026-08-17 20:18:06 +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:
co-authored by
Claude Opus 4.6
parent
f4734f57c7
commit
d843e63e7b
+82
-1
@@ -13,7 +13,8 @@ from pathlib import Path
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
import yaml
|
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 = Flask(__name__, template_folder=str(Path(__file__).resolve().parent.parent / "templates"))
|
||||||
app.secret_key = os.urandom(24)
|
app.secret_key = os.urandom(24)
|
||||||
@@ -289,6 +290,86 @@ def killswitch():
|
|||||||
return redirect(url_for("index"))
|
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
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -90,6 +90,46 @@ def add_vwap(df, period=20):
|
|||||||
return df
|
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):
|
def build_all_features(df, config=None):
|
||||||
"""
|
"""
|
||||||
Run a standard set of features. config is optional dict matching the
|
Run a standard set of features. config is optional dict matching the
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
<div class="navbar-nav">
|
<div class="navbar-nav">
|
||||||
<a class="nav-link" href="/">Status</a>
|
<a class="nav-link" href="/">Status</a>
|
||||||
<a class="nav-link" href="/backtest">Backtest</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="/config">Config</a>
|
||||||
<a class="nav-link" href="/logs">Logs</a>
|
<a class="nav-link" href="/logs">Logs</a>
|
||||||
</div>
|
</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