"""matplotlib chart — Net GEX All Expirations.""" import logging from datetime import datetime from pathlib import Path from typing import Dict, Optional import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import matplotlib.ticker as mticker import numpy as np import pandas as pd from matplotlib.lines import Line2D from matplotlib.patches import Patch from zoneinfo import ZoneInfo from .config import GexConfig from .compute import render_bucket as compute_render_bucket logger = logging.getLogger(__name__) ET = ZoneInfo("America/New_York") def _dynamic_formatter(x, pos): """FIX 37 / FIX 50: format ticks as K/M/B/T by magnitude (dynamic). Drops the trailing ".0" on whole numbers (e.g. "3.0T" -> "3T").""" ax = abs(x) if ax == 0: return "0" if ax >= 1e12: v = x / 1e12 return f"{v:.1f}T".replace(".0T", "T") if ax >= 1e9: v = x / 1e9 return f"{v:.1f}B".replace(".0B", "B") if ax >= 1e6: return f"{x / 1e6:.0f}M" if ax >= 1e3: return f"{x / 1e3:.0f}K" return f"{x:.0f}" def _clean_xlim(v: float) -> float: """FIX 37: dynamic clean-step ladder. Pick the largest power of ten below |v|, then step through {1, 2.5, 5, 10} multiples of it. No fixed 1M..1000M table.""" v = abs(float(v)) if v <= 0: return 1e6 import math decade = 10 ** math.floor(math.log10(v)) for mult in (1, 2.5, 5, 10): step = decade * mult if v <= step: return step return decade * 10 def format_timestamp(timestamp_str: str, source_tz: str = "UTC") -> str: """Convert Cboe's UTC timestamp to ET for display, with real tz suffix (FIX 1). Returns e.g. "2026-07-24 15:35 EDT". The suffix is derived from the converted datetime (EDT/EST), never hardcoded. """ src = ZoneInfo(source_tz) try: ts = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=src).astimezone(ET) except Exception: ts = datetime.now(ET) return ts.strftime("%Y-%m-%d %H:%M") + f" {ts.tzname()}" def _pick_ytick_step(span: float) -> float: """Pick a clean strike-tick step giving ~8-16 ticks across the window.""" candidates = [1, 2, 2.5, 5, 10, 20, 25, 50, 100] for c in candidates: if span / c <= 16: return c return 100 def render_chart( symbol: str, agg: pd.DataFrame, grid: np.ndarray, gex_prof: np.ndarray, grid_dex: np.ndarray, dex_prof: np.ndarray, levels: Dict, timestamp_str: str, cfg: GexConfig, outdir: str, slot_label: str, increment: float, source_label: str = "Cboe delayed (~15m)", display_label: Optional[str] = None, render_bucket: Optional[float] = None, rolling_gex_limit: Optional[float] = None, rolling_dex_limit: Optional[float] = None, ) -> Path: """Render the full chart and save PNG. Returns the output path. FIX 26: the DEX profile lives on its own wider grid (`grid_dex`, ±40%) while the GEX profile uses `grid` (±25%). Both are restricted to the visible window below. FIX 71 / FIX 72: the bar bucket is passed in (`render_bucket`, computed once in snapshot.py from the plot-band window) and drives bar aggregation + height. The plotted y-window is spot × (1 ± plot_band) — the region where bars actually exist — NOT extended to distant key levels. A level that falls outside the bar window is drawn as an edge marker/arrow with its price labelled instead of stretching the axis. """ spot = levels["spot"] call_res = levels["call_resistance"] put_sup = levels["put_support"] hvl = levels["hvl"] # may be None when no_flip_in_range (FIX 29) hvl_rule = levels["hvl_rule"] hvl_confidence = levels.get("hvl_confidence", "high") hvl_status = levels.get("hvl_status", "ok") hvl_regime_note = levels.get("hvl_regime_note") gex_transition = levels.get("gex_transition") levels_ephemeral = levels.get("levels_ephemeral", []) # --- FIX 72: plot window = spot × (1 ± plot_band), the region where bars exist. # Distant key levels (HVL, GEX-transition, even call_resistance/put_support) no # longer stretch the axis; any level outside the bar window is collected here and # drawn later as an edge marker/arrow with its price labelled. lo = spot * (1 - cfg.plot_band) hi = spot * (1 + cfg.plot_band) if increment > 0: lo = np.floor(lo / increment) * increment hi = np.ceil(hi / increment) * increment # Named key levels (name, strike, colour) for off-scale edge-marker handling. _named_levels = [ ("Call Resistance", call_res, cfg.call_res_color), ("Put Support", put_sup, cfg.put_sup_color), ] if hvl is not None: _named_levels.append(("HVL", hvl, cfg.hvl_color)) if gex_transition is not None: _named_levels.append(("GEX Transition", gex_transition, cfg.gex_transition_color)) offscale = [(name, strike, color) for name, strike, color in _named_levels if strike < lo or strike > hi] if offscale: logger.info("FIX 72: %d level(s) off-scale (window %.1f–%.1f): %s", len(offscale), lo, hi, ", ".join(f"{n}@{s:g}" for n, s, _ in offscale)) vis = agg[(agg.index >= lo) & (agg.index <= hi)] strikes = vis.index.to_numpy() net = vis["net_gex"].to_numpy() # --- FIX 19: honest LINEAR clipping (symlog removed — it distorts a linear # dollar quantity). xlim = clean(1.15 * p97); widen for a key-level strike ONLY # if 1.05*|net_gex| there is <= 3x that limit. Beyond 3x, clip and annotate. --- nz = np.abs(net[net != 0]) if len(nz): p97 = np.percentile(nz, 97) xlim = _clean_xlim(1.15 * p97) else: p97 = 0.0 xlim = 5_000_000 def _net_at(strike): return float(agg["net_gex"].get(strike, 0.0)) if strike in agg.index else 0.0 protect = max(abs(_net_at(put_sup)), abs(_net_at(call_res))) if 1.05 * protect <= 3 * xlim: xlim = max(xlim, 1.05 * protect) # widen to include the key-level bar else: logger.info("Key-level bar (%.3g) exceeds 3× p97 limit (%.3g); clipping + annotating.", protect, xlim) logger.info("Bar axis: linear (xlim=%.3g, p97=%.3g)", xlim, p97) max_net = np.max(np.abs(net)) if len(net) else 0.0 argmax_strike = float(strikes[np.argmax(np.abs(net))]) if len(net) else float("nan") # --- profiles on the fine price grid, restricted to the visible window --- # GEX profile uses `grid`; DEX profile uses the wider `grid_dex` (FIX 26). mask = (grid >= lo) & (grid <= hi) g_vis = grid[mask] gp_vis = gex_prof[mask] mask_dex = (grid_dex >= lo) & (grid_dex <= hi) g_vis_dex = grid_dex[mask_dex] dp_vis = dex_prof[mask_dex] # --- FIX 10 + FIX 11: each profile gets its OWN axis (different units) --- gp_peak = float(np.max(np.abs(gp_vis))) if len(gp_vis) else 0.0 dp_peak = float(np.max(np.abs(dp_vis))) if len(dp_vis) else 0.0 if gp_peak > 0 and dp_peak > 0: logger.info("profile peaks: gex=%.3g dex=%.3g ratio=%.1f", gp_peak, dp_peak, dp_peak / gp_peak) # FIX 50: axis limits come from the DATA (visible-window max), not spot^2. # "data": ±1.10 * visible max. "rolling": ±1.2 * median of last N maxima # (passed in), but never clip the actual curve — take the larger of the two. data_gex = 1.10 * gp_peak if gp_peak > 0 else 1.0 data_dex = 1.10 * dp_peak if dp_peak > 0 else 1.0 if cfg.profile_axis_mode == "rolling" and rolling_gex_limit and rolling_dex_limit: ax2_xlim = max(rolling_gex_limit, data_gex) ax3_xlim = max(rolling_dex_limit, data_dex) else: # "data" (default) or rolling with insufficient history ax2_xlim = data_gex ax3_xlim = data_dex ax2_xlim = max(ax2_xlim, 1.0) ax3_xlim = max(ax3_xlim, 1.0) # FIX 50: log chosen limit vs data max; ratio > 5 is the over-scaling signature. for name, lim, peak in (("gex", ax2_xlim, gp_peak), ("dex", ax3_xlim, dp_peak)): if peak > 0: ratio = lim / (1.10 * peak) logger.info("FIX 50 %s axis: limit=%.3g data_max=%.3g ratio=%.2f", name, lim, peak, ratio) if ratio > 5: logger.warning("FIX 50 %s axis ratio %.1f > 5 — over-scaling signature.", name, ratio) # --- timestamp: Cboe's field is UTC; convert to ET for display (FIX 1) --- UTC = ZoneInfo(cfg.source_timestamp_tz) try: ts = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S").replace(tzinfo=UTC).astimezone(ET) except Exception: ts = datetime.now(ET) ts_label = format_timestamp(timestamp_str, cfg.source_timestamp_tz) # --- figure --- fig, ax = plt.subplots(figsize=(cfg.fig_width, cfg.fig_height), dpi=cfg.dpi) fig.patch.set_facecolor(cfg.bg_color) ax.set_facecolor(cfg.axes_bg) # FIX 19: linear bar axis always (symlog removed — it distorts a linear $ qty). # FIX 21: opaque, deeper bars; zorder 3 (grid 0, bars 3, key-level hlines 4, # profile lines 5 — bars never occlude the curves). # FIX 60 / FIX 71 / FIX 72: bars are aggregated into buckets targeting ~40 visible # bars. The bucket is computed ONCE in snapshot.py from the PLOT-BAND window and # passed in as `render_bucket` — it is the single source of truth for bar # aggregation AND the published render_spacing/render_bucket fields. Bucketing is # RENDERING ONLY — call_resistance, put_support, hvl, gex_transition, # delta_neutral and outlier_report all stay at true strike resolution. if render_bucket is None: render_bucket = compute_render_bucket(hi - lo, increment) bucket = render_bucket levels["render_bucket"] = bucket # audit trail: bar resolution vs strike_increment if bucket > increment and len(strikes) > 0: bucket_ids = np.round(strikes / bucket) * bucket _bagg = pd.DataFrame({"c": bucket_ids, "n": net}).groupby("c")["n"].sum().sort_index() bar_strikes = _bagg.index.to_numpy() bar_net = _bagg.to_numpy() else: bar_strikes = strikes bar_net = net bar_h = 0.8 * bucket net_clip = np.clip(bar_net, -xlim, xlim) clipped_idx = np.where(np.abs(bar_net) > xlim)[0] pos_mask = net_clip >= 0 neg_mask = ~pos_mask if pos_mask.any(): ax.barh(bar_strikes[pos_mask], net_clip[pos_mask], height=bar_h, color=cfg.bar_pos_color, alpha=1.0, edgecolor="none", zorder=3, label="Positive GEX") if neg_mask.any(): ax.barh(bar_strikes[neg_mask], net_clip[neg_mask], height=bar_h, color=cfg.bar_neg_color, alpha=1.0, edgecolor="none", zorder=3, label="Negative GEX") # FIX 19: honest clip annotation — draw to the edge, add a » / « marker plus a # text label with the bar's TRUE value just inside the axis, in the bar colour. # (v1.7.0: the guillemet is drawn as text, not a plot marker — "«"/"»" are not # valid matplotlib markers and crashed once FIX 60 bucketing made aggregated # bars exceed xlim often enough to reach this path.) for ci in clipped_idx: color = cfg.bar_pos_color if bar_net[ci] >= 0 else cfg.bar_neg_color edge = xlim if bar_net[ci] > 0 else -xlim marker = "»" if bar_net[ci] > 0 else "«" ax.text(edge, bar_strikes[ci], marker, color=color, fontsize=12, fontweight="bold", va="center", ha="center", zorder=6, clip_on=False) label_x = xlim * 0.985 if bar_net[ci] > 0 else -xlim * 0.985 ax.text(label_x, bar_strikes[ci], f"{bar_net[ci]/1e6:.0f}M", color=color, fontsize=7, va="center", ha="right" if bar_net[ci] > 0 else "left", zorder=6, clip_on=False) # FIX 95: reconciliation status is NO LONGER drawn on the chart image. The PNG # is self-contained chart furniture only (title, spot, levels, legend, source # line). The red FAIL banner, amber precision-limited strip, and the # reconciliation outcome all moved to a collapsible
section on the # HTML page directly beneath the image. A short neutral footer line (below) # points a reader who saves/shares the PNG at where the reliability detail lives. # (Bars use Cboe's reported gamma and are unaffected by reconciliation either # way; only the recomputed profile and its derived levels are in doubt on a # fail — that nuance now lives on the page, not the image.) # --- FIX 10: separate twin axis per profile (different units, colour-matched) --- # ax2 (TOP): GEX Profile, yellow axis. ax3 (BOTTOM, offset): DEX Profile, orange. ax2 = ax.twiny() ax2.set_facecolor("none") if len(g_vis) > 1: ax2.plot(gp_vis, g_vis, color=cfg.gex_profile_color, lw=1.6, zorder=5, label="GEX Profile") ax2.set_xlim(-ax2_xlim, ax2_xlim) ax2.xaxis.set_major_formatter(mticker.FuncFormatter(_dynamic_formatter)) ax2.set_xlabel("GEX Profile ($ per 1% move)", color=cfg.gex_profile_color, fontsize=10) ax2.tick_params(colors=cfg.gex_profile_color, labelsize=8) for spine in ax2.spines.values(): spine.set_color(cfg.gex_profile_color) ax2.axvline(0.0, color="#555555", lw=0.8, zorder=1) ax3 = ax.twiny() ax3.set_facecolor("none") if len(g_vis_dex) > 1: ax3.plot(dp_vis, g_vis_dex, color=cfg.dex_color, lw=1.6, zorder=5, label="DEX Profile") ax3.set_xlim(-ax3_xlim, ax3_xlim) # move ax3's spine to the BOTTOM, offset below ax's own x-axis ax3.xaxis.set_ticks_position("bottom") ax3.xaxis.set_label_position("bottom") ax3.spines["bottom"].set_position(("outward", 42)) ax3.xaxis.set_major_formatter(mticker.FuncFormatter(_dynamic_formatter)) ax3.set_xlabel("DEX Profile ($ delta notional)", color=cfg.dex_color, fontsize=10) ax3.tick_params(colors=cfg.dex_color, labelsize=8) for spine in ax3.spines.values(): spine.set_color(cfg.dex_color) # set the bar-axis limit, then check all three zeros coincide (FIX 10: warn, # never assert — asserts are stripped under -O and kill the run on float drift). ax.set_xlim(-xlim, xlim) fig.canvas.draw() ax0 = ax.transData.transform((0.0, 0.0))[0] ax2_0 = ax2.transData.transform((0.0, 0.0))[0] ax3_0 = ax3.transData.transform((0.0, 0.0))[0] if abs(ax0 - ax2_0) > 0.5 or abs(ax0 - ax3_0) > 0.5: logger.warning("twin-axis zero misaligned: ax=%.2f ax2=%.2f ax3=%.2f", ax0, ax2_0, ax3_0) # key levels — FIX 72: only draw an hline for levels INSIDE the bar window; # off-scale levels are drawn as edge markers/arrows with their price labelled # (see the offscale loop below) instead of stretching the axis to reach them. def _in_window(strike): return strike is not None and lo <= strike <= hi if _in_window(call_res): ax.axhline(call_res, color=cfg.call_res_color, ls="--", lw=1.4, zorder=4) if _in_window(put_sup): ax.axhline(put_sup, color=cfg.put_sup_color, ls="--", lw=1.4, zorder=4) ax.axhline(spot, color=cfg.spot_color, ls="--", lw=1.4, zorder=4) # FIX 29: HVL is always a single dashed line (zero crossing). No band, no # "indeterminate". If no_flip_in_range, annotate that on the chart. if hvl is not None and _in_window(hvl): ax.axhline(hvl, color=cfg.hvl_color, ls="--", lw=1.4, zorder=4) # regime note annotation next to the HVL line if hvl_regime_note: ax.text(xlim * 0.97, hvl, f" {hvl_regime_note}", color=cfg.hvl_color, fontsize=7, va="bottom", ha="right", zorder=6, clip_on=False) elif hvl is None and hvl_status == "no_flip_in_range": ax.text(0.5, 0.50, "no gamma flip within ±40% of spot", transform=ax.transAxes, fontsize=10, color=cfg.hvl_color, ha="center", va="center", alpha=0.7, zorder=6) # FIX 30: GEX Transition — separate named level, colour #7FA6C9 if gex_transition is not None and _in_window(gex_transition): ax.axhline(gex_transition, color=cfg.gex_transition_color, ls="--", lw=1.2, zorder=4) # FIX 72: off-scale key levels -> edge marker/arrow with price label. The level # sits beyond the plotted bar window, so we point at the top/bottom edge and # label its true strike so the reader knows where it is without widening the axis. # Stack multiple labels on the same edge so they don't overlap. _off_above = [(n, s, c) for n, s, c in offscale if s > hi] _off_below = [(n, s, c) for n, s, c in offscale if s <= lo] _line_h = (hi - lo) * 0.035 # vertical spacing between stacked labels for i, (name, strike, color) in enumerate(_off_above): y_pos = hi - i * _line_h ax.text(xlim * 0.97, y_pos, f"▲ {name} {strike:g} (off-scale)", color=color, fontsize=7.5, fontweight="bold", va="top", ha="right", zorder=7, clip_on=False) for i, (name, strike, color) in enumerate(_off_below): y_pos = lo + i * _line_h ax.text(xlim * 0.97, y_pos, f"▼ {name} {strike:g} (off-scale)", color=color, fontsize=7.5, fontweight="bold", va="bottom", ha="right", zorder=7, clip_on=False) # grid ax.grid(True, which="both", color=cfg.grid_color, ls=":", lw=0.6, zorder=0) # axes cosmetics for spine in ax.spines.values(): spine.set_color(cfg.text_color) ax.tick_params(colors=cfg.text_color, labelsize=9) ax.set_ylabel("Strike Price", color=cfg.text_color, fontsize=11) ax.set_xlabel("GEX", color=cfg.text_color, fontsize=11) ax.xaxis.set_major_formatter(mticker.FuncFormatter(_dynamic_formatter)) # y ticks (clean step across the widened window) ytick_step = _pick_ytick_step(hi - lo) yticks = np.arange(np.ceil(lo / ytick_step) * ytick_step, hi + 1, ytick_step) ax.set_yticks(yticks) ax.set_yticklabels([f"{v:g}" for v in yticks]) ax.set_ylim(lo - increment, hi + increment) # titles — placed well above axes to avoid overlapping data title_sym = display_label or symbol fig.text(0.10, 0.97, f"Net GEX All Expirations for {title_sym}", fontsize=17, fontweight="bold", color=cfg.title_color, ha="left") fig.text(0.10, 0.94, f"Timestamp: {ts_label}", fontsize=11, color=cfg.text_color, ha="left") # legend (FIX 29: HVL is always a single line; FIX 30: GEX Transition added) _off_names_leg = {n for n, _, _ in offscale} if hvl is not None: hvl_dist = levels.get("hvl_distance_pct") dist_str = f" ({hvl_dist:+.1%})" if hvl_dist is not None else "" hvl_off = " (off-scale)" if "HVL" in _off_names_leg else "" hvl_lbl = f"HVL: {hvl:g}{dist_str}{hvl_off}" hvl_handle = Line2D([0], [0], color=cfg.hvl_color, ls="--", lw=1.4, label=hvl_lbl) elif hvl_status == "no_flip_in_range": hvl_handle = Line2D([0], [0], color=cfg.hvl_color, ls="--", lw=1.4, label="HVL: no flip in ±40%") else: hvl_handle = Line2D([0], [0], color=cfg.hvl_color, ls="--", lw=1.4, label="HVL: n/a") cr_dag = " †" if "call_resistance" in levels_ephemeral else "" ps_dag = " †" if "put_support" in levels_ephemeral else "" # FIX 72: tag legend labels for levels drawn off-scale as edge markers. _off_names = {n for n, _, _ in offscale} cr_off = " (off-scale)" if "Call Resistance" in _off_names else "" ps_off = " (off-scale)" if "Put Support" in _off_names else "" handles = [ Line2D([0], [0], color=cfg.dex_color, lw=1.6, label="DEX Profile"), Line2D([0], [0], color=cfg.gex_profile_color, lw=1.6, label="GEX Profile"), Line2D([0], [0], color=cfg.call_res_color, ls="--", lw=1.4, label=f"Call Resistance: {call_res:g}{cr_dag}{cr_off}"), Line2D([0], [0], color=cfg.put_sup_color, ls="--", lw=1.4, label=f"Put Support: {put_sup:g}{ps_dag}{ps_off}"), hvl_handle, ] # FIX 30: GEX Transition in legend if gex_transition is not None: gt_off = " (off-scale)" if "GEX Transition" in _off_names_leg else "" handles.append(Line2D([0], [0], color=cfg.gex_transition_color, ls="--", lw=1.2, label=f"GEX Transition: {gex_transition:g}{gt_off}")) handles.extend([ Line2D([0], [0], color=cfg.spot_color, ls="--", lw=1.4, label=f"Spot Price: {spot:.1f}"), Patch(facecolor=cfg.bar_pos_color, label="Positive GEX"), Patch(facecolor=cfg.bar_neg_color, label="Negative GEX"), ]) ax.legend(handles=handles, loc="upper center", bbox_to_anchor=(0.5, 1.20), ncol=4, frameon=False, fontsize=9, labelcolor=cfg.text_color, columnspacing=1.2, handletextpad=0.4) # Brand watermark row (FIX 20/27: centered at y=0.030, above the footnote strip). # Known-safe centered element, intentionally NOT in the layout-guard list. fig.text(0.5, 0.030, cfg.brand_text, ha="center", fontsize=11, fontweight="bold", color="#F2E4CE") # watermark if cfg.watermark_text: ax.text(0.72, 0.18, cfg.watermark_text, transform=ax.transAxes, fontsize=28, alpha=0.35, color=cfg.text_color, ha="center", va="center") # FIX 20: put/call warning removed from the chart image — it's already shown in # the HTML dashboard below the chart. No fig.text annotation here. # Guard list tracks fig.text y-positions. The brand (y=0.039) is intentionally # excluded — it's a known-safe centered row, not an unexpected intrusion into the # offset-DEX-axis furniture band the guard watches. _fig_text_ys = [0.97, 0.94] # title, timestamp # v1.6.5: the spread-structure and ephemeral-level footnotes were removed from # the chart image — they duplicated data already shown on the HTML dashboard and # their coexistence caused overlapping text at the bottom of the image. Only the # safety-critical FAULT note (profile outlier guard) remains, plus the # right-aligned source/axis footer. profile_outliers = levels.get("profile_outliers_dropped", []) if profile_outliers: n_out = len(profile_outliers) strikes = ", ".join(f"{o['strike']:g}" for o in profile_outliers[:3]) fig.text(0.10, 0.016, f"⚠ FAULT: {n_out} contract(s) dropped by profile outlier guard " f"(strike {strikes}{'…' if n_out > 3 else ''}) — investigate", ha="left", va="center", fontsize=8, color="#FF6B6B", fontweight="bold") _fig_text_ys.append(0.016) # FIX 95: the coarse-gamma footnote moved to the page's reconciliation #
section (gamma_precision + digits are published in the JSON and # rendered there). The PNG carries no reconciliation/precision commentary. # FIX 95: neutral reliability pointer. The PNG is now caveat-free, so anyone # who saves or shares it loses the reconciliation warning. This short neutral # line tells them where the reliability detail lives — better than nothing. fig.text(0.10, 0.010, "reliability detail: allofthesewords.com/optionsdata", ha="left", va="center", fontsize=7.5, color=cfg.footer_color, fontstyle="italic") _fig_text_ys.append(0.010) # footer (FIX 11: profile axis mode; FIX 19: clipped-bar footnote) — right-aligned # bottom row. n_clip = len(clipped_idx) clip_note = (f" | {n_clip} strike(s) clipped; max |net GEX| = " f"{max_net/1e6:.0f}M at {argmax_strike:g}") if n_clip else "" fig.text(0.98, 0.0045, f"src: {source_label} | HVL rule: {hvl_rule} | profile axis: " f"{cfg.profile_axis_mode}{clip_note}", ha="right", fontsize=7, color=cfg.footer_color) _fig_text_ys.append(0.0045) # FIX 20 layout guard: no fig.text may sit in the bottom furniture band # (0.02 <= y <= 0.14) where the offset DEX axis and its labels live. Warn, never assert. for y in _fig_text_ys: if 0.02 <= y <= 0.14: logger.warning("LAYOUT: a fig.text element sits at y=%.3f inside the " "bottom axis-furniture band [0.02, 0.14].", y) plt.subplots_adjust(left=0.10, right=0.90, top=0.78, bottom=0.16) # save date_str = ts.strftime("%Y-%m-%d") out_dir = Path(outdir) / symbol out_dir.mkdir(parents=True, exist_ok=True) png_path = out_dir / f"{date_str}_{slot_label}.png" fig.savefig(png_path, facecolor=fig.get_facecolor()) plt.close(fig) logger.info("Chart saved -> %s", png_path) return png_path