206 lines
6.7 KiB
Python
206 lines
6.7 KiB
Python
from __future__ import annotations
|
|
|
|
import pandas as pd
|
|
import plotly.graph_objects as go
|
|
from plotly.subplots import make_subplots
|
|
|
|
from .constants import TREND_BEAR, TREND_BULL, TREND_NEUTRAL
|
|
from .time_display import format_timestamp
|
|
|
|
|
|
def _is_intraday_interval(interval: str) -> bool:
|
|
return interval in {"1m", "2m", "5m", "15m", "30m", "60m", "90m", "1h"}
|
|
|
|
|
|
def _is_daily_interval(interval: str) -> bool:
|
|
return interval == "1d"
|
|
|
|
|
|
def _intraday_day_ticks(index: pd.DatetimeIndex) -> tuple[list[pd.Timestamp], list[str]]:
|
|
if len(index) == 0:
|
|
return [], []
|
|
|
|
normalized = index.normalize()
|
|
first_mask = ~normalized.duplicated()
|
|
tickvals = [index[pos] for pos, keep in enumerate(first_mask) if bool(keep)]
|
|
ticktext = [f"{ts.month}/{ts.day}" for ts in tickvals]
|
|
return tickvals, ticktext
|
|
|
|
|
|
def _missing_calendar_day_values(df: pd.DataFrame) -> list[str]:
|
|
if df.empty:
|
|
return []
|
|
|
|
index = pd.DatetimeIndex(df.index)
|
|
session_days = pd.DatetimeIndex(index.normalize().unique()).sort_values()
|
|
if len(session_days) < 2:
|
|
return []
|
|
|
|
if session_days.tz is None:
|
|
all_days = pd.date_range(start=session_days[0], end=session_days[-1], freq="D")
|
|
else:
|
|
all_days = pd.date_range(start=session_days[0], end=session_days[-1], freq="D", tz=session_days.tz)
|
|
|
|
missing_days = all_days.difference(session_days)
|
|
# Weekend gaps are already handled by sat->mon bounds; keep explicit values
|
|
# for weekday closures (e.g., exchange holidays) to avoid overlap artifacts.
|
|
weekday_missing = [day for day in missing_days if day.dayofweek < 5]
|
|
return [day.strftime("%Y-%m-%d") for day in weekday_missing]
|
|
|
|
|
|
def build_figure(
|
|
df: pd.DataFrame,
|
|
gray_fake: bool,
|
|
*,
|
|
interval: str,
|
|
hide_market_closed_gaps: bool,
|
|
display_timezone: str,
|
|
use_24h_time: bool,
|
|
) -> go.Figure:
|
|
fig = make_subplots(
|
|
rows=2,
|
|
cols=1,
|
|
row_heights=[0.8, 0.2],
|
|
vertical_spacing=0.03,
|
|
shared_xaxes=True,
|
|
)
|
|
|
|
bull_mask = df["classification"] == "real_bull"
|
|
bear_mask = df["classification"] == "real_bear"
|
|
time_labels = [
|
|
format_timestamp(ts, display_timezone=display_timezone, use_24h_time=use_24h_time) for ts in df.index
|
|
]
|
|
|
|
if gray_fake:
|
|
fig.add_trace(
|
|
go.Candlestick(
|
|
x=df.index,
|
|
open=df["Open"],
|
|
high=df["High"],
|
|
low=df["Low"],
|
|
close=df["Close"],
|
|
name="All Bars",
|
|
increasing_line_color="#B0B0B0",
|
|
decreasing_line_color="#808080",
|
|
opacity=0.35,
|
|
customdata=time_labels,
|
|
hovertemplate=(
|
|
"Time: %{customdata}<br>"
|
|
"Open: %{open:.2f}<br>"
|
|
"High: %{high:.2f}<br>"
|
|
"Low: %{low:.2f}<br>"
|
|
"Close: %{close:.2f}<extra>All Bars</extra>"
|
|
),
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
else:
|
|
fig.add_trace(
|
|
go.Candlestick(
|
|
x=df.index,
|
|
open=df["Open"],
|
|
high=df["High"],
|
|
low=df["Low"],
|
|
close=df["Close"],
|
|
name="All Bars",
|
|
increasing_line_color="#2E8B57",
|
|
decreasing_line_color="#B22222",
|
|
opacity=0.6,
|
|
customdata=time_labels,
|
|
hovertemplate=(
|
|
"Time: %{customdata}<br>"
|
|
"Open: %{open:.2f}<br>"
|
|
"High: %{high:.2f}<br>"
|
|
"Low: %{low:.2f}<br>"
|
|
"Close: %{close:.2f}<extra>All Bars</extra>"
|
|
),
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
|
|
bull_time_labels = [time_labels[idx] for idx, is_bull in enumerate(bull_mask) if bool(is_bull)]
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=df.index[bull_mask],
|
|
y=df.loc[bull_mask, "Close"],
|
|
mode="markers",
|
|
name="Real Bullish",
|
|
marker=dict(color="#00C853", size=9, symbol="triangle-up"),
|
|
customdata=bull_time_labels,
|
|
hovertemplate="Time: %{customdata}<br>Close: %{y:.2f}<extra>Real Bullish</extra>",
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
|
|
bear_time_labels = [time_labels[idx] for idx, is_bear in enumerate(bear_mask) if bool(is_bear)]
|
|
fig.add_trace(
|
|
go.Scatter(
|
|
x=df.index[bear_mask],
|
|
y=df.loc[bear_mask, "Close"],
|
|
mode="markers",
|
|
name="Real Bearish",
|
|
marker=dict(color="#D50000", size=9, symbol="triangle-down"),
|
|
customdata=bear_time_labels,
|
|
hovertemplate="Time: %{customdata}<br>Close: %{y:.2f}<extra>Real Bearish</extra>",
|
|
),
|
|
row=1,
|
|
col=1,
|
|
)
|
|
|
|
trend_color = df["trend_state"].map(
|
|
{
|
|
TREND_BULL: "#00C853",
|
|
TREND_BEAR: "#D50000",
|
|
TREND_NEUTRAL: "#9E9E9E",
|
|
}
|
|
)
|
|
fig.add_trace(
|
|
go.Bar(
|
|
x=df.index,
|
|
y=df["Volume"],
|
|
marker_color=trend_color,
|
|
name="Volume",
|
|
opacity=0.65,
|
|
customdata=time_labels,
|
|
hovertemplate="Time: %{customdata}<br>Volume: %{y}<extra>Volume</extra>",
|
|
),
|
|
row=2,
|
|
col=1,
|
|
)
|
|
|
|
fig.update_layout(
|
|
template="plotly_white",
|
|
xaxis_rangeslider_visible=False,
|
|
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0),
|
|
margin=dict(l=20, r=20, t=60, b=20),
|
|
height=760,
|
|
)
|
|
if hide_market_closed_gaps:
|
|
if _is_intraday_interval(interval):
|
|
# Intraday rangebreak combinations can produce axis rendering artifacts
|
|
# with some feeds/timezones. Categorical axis keeps chronological bars
|
|
# contiguous and removes closed-session gaps reliably.
|
|
tickvals, ticktext = _intraday_day_ticks(pd.DatetimeIndex(df.index))
|
|
fig.update_xaxes(
|
|
type="category",
|
|
categoryorder="array",
|
|
categoryarray=list(df.index),
|
|
tickmode="array",
|
|
tickvals=tickvals,
|
|
ticktext=ticktext,
|
|
tickangle=0,
|
|
)
|
|
elif _is_daily_interval(interval):
|
|
rangebreaks: list[dict[str, object]] = [dict(bounds=["sat", "mon"])]
|
|
missing_days = _missing_calendar_day_values(df)
|
|
if missing_days:
|
|
rangebreaks.append(dict(values=missing_days))
|
|
fig.update_xaxes(rangebreaks=rangebreaks)
|
|
|
|
fig.update_yaxes(title_text="Price", row=1, col=1)
|
|
fig.update_yaxes(title_text="Volume", row=2, col=1)
|
|
return fig
|