Skip to content

v4.4.50 - Indicators hardening + ThetaData acceptance stabilization#967

Open
grzesir wants to merge 9 commits intodevfrom
version/4.4.50
Open

v4.4.50 - Indicators hardening + ThetaData acceptance stabilization#967
grzesir wants to merge 9 commits intodevfrom
version/4.4.50

Conversation

@grzesir
Copy link
Contributor

@grzesir grzesir commented Feb 11, 2026

What this ships

  • Fix indicator subplot scaling in indicators HTML output.
  • Make indicator HTML export non-fatal so backtests still complete and CSV/Parquet artifacts are written.
  • Fix ThetaData intraday index fetch bounds so minute/hour requests stay aligned to simulation time.
  • Stabilize acceptance suite against current data revisions:
    • refresh backdoor_butterfly_full_year + backdoor_smartlimit baselines,
    • keep strict queue-free validation by default, with a bounded threshold for spx_short_straddle_repro.

Validation

  • Local targeted tests:
    • pytest tests/test_thetadata_intraday_end_validation.py -q
    • pytest tests/test_thetadata_helper.py::test_update_pandas_data_raises_on_incomplete_end -q
  • GitHub Actions: full sharded unit/backtest CI on this PR branch.

@coderabbitai
Copy link

coderabbitai bot commented Feb 11, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Added environment-driven controls and helpers for indicator plotting (dynamic subplot spacing, UI/html toggles, CSV/Parquet fallback on render failure), CI/acceptance-aware ThetaData intraday gap handling during prefetches, new tests covering these behaviors, and bumped package version to 4.4.50.

Changes

Cohort / File(s) Summary
Release metadata
CHANGELOG.md, setup.py
Added 4.4.50 Unreleased entry and bumped package version from 4.4.49 to 4.4.50.
Indicators helpers & plotting
lumibot/tools/indicators.py
Added _env_flag_enabled() and _safe_subplot_vertical_spacing(); refactored plot_indicators() to use dynamic vertical spacing, environment flags (LUMIBOT_DISABLE_UI, LUMIBOT_WRITE_INDICATORS_HTML), try/except around rendering, and CSV/Parquet fallback with improved logging and consistent plot naming.
Indicator tests
tests/test_indicator_subplots.py
Expanded tests to assert vertical spacing scaling for many rows, env-driven skipping of HTML writes, and artifact exports still occur when HTML writing fails. Adjusted imports to use lumibot.tools.indicators.
ThetaData backtesting (CI/acceptance behavior)
lumibot/backtesting/thetadata_backtesting_pandas.py
Added is_acceptance gating via LUMIBOT_ACCEPTANCE_BACKTEST; altered prefetch/end_requirement clamping and _covers_window behavior to log warnings and continue in acceptance/CI mode instead of raising; added diagnostic logging.
ThetaData tests
tests/test_thetadata_intraday_end_validation.py, tests/backtest/test_acceptance_backtests_ci.py
Added regression test verifying prefetch handles intraday index coverage gaps by fetching rather than raising; set LUMIBOT_ACCEPTANCE_BACKTEST in CI test base env.

Sequence Diagrams

sequenceDiagram
    participant User
    participant plot_indicators as plot_indicators()
    participant env as Environment
    participant subplots as make_subplots()
    participant renderer as Plotly Renderer
    participant html_writer as write_html()
    participant fallback as CSV/Parquet Export

    User->>plot_indicators: call with data & configs
    plot_indicators->>env: read LUMIBOT_DISABLE_UI / LUMIBOT_WRITE_INDICATORS_HTML
    env-->>plot_indicators: flags

    plot_indicators->>subplots: create subplots (safe spacing)
    subplots-->>plot_indicators: figure

    plot_indicators->>renderer: render traces/hover/text
    renderer-->>plot_indicators: rendered

    alt HTML writing enabled
        plot_indicators->>html_writer: write_html()
        html_writer-->>plot_indicators: success
    else HTML write fails or disabled
        plot_indicators->>fallback: write CSV/Parquet artifacts
        fallback-->>plot_indicators: artifacts produced
    end

    plot_indicators-->>User: return artifacts / logs
Loading
sequenceDiagram
    participant Backtest
    participant _covers_window as _covers_window()
    participant env as Environment
    participant logger as Logger
    participant data as DataFetcher

    Backtest->>_covers_window: validate coverage for window
    _covers_window->>data: detect intraday index gap
    data-->>_covers_window: gap found

    _covers_window->>env: check LUMIBOT_ACCEPTANCE_BACKTEST
    alt Acceptance/CI enabled
        env-->>_covers_window: true
        _covers_window->>logger: log warning + diagnostics
        _covers_window->>Backtest: continue with available data (no raise)
    else Acceptance disabled
        env-->>_covers_window: false
        _covers_window-->>Backtest: raise ValueError
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • 4.4.28 #938: Modifies thetadata_backtesting_pandas.py intraday caching / end_requirement logic—overlaps with acceptance-mode gap handling changes.
  • v4.4.42 - Release #957: Addresses HTML/CSV rendering and OHLC exporting in lumibot/tools/indicators.py, related to the new plotting fallback and export behavior.
  • 4.4.29 #939: Updates ThetaData fetch/prefetch logic and clamping; closely related to the prefetch/coverage adjustments in this PR.

Poem

🐰 I hop through plots with careful paws,
I space the rows and check the laws.
If HTML trips, I patter on—
CSV and Parquet carry on.
Version bumped, a joyful pause! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 19.05% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main changes: it references v4.4.50 (the version bump visible in setup.py and CHANGELOG.md), indicators hardening (the new environment-based utilities and error handling in indicators.py), and ThetaData acceptance stabilization (the changes in thetadata_backtesting_pandas.py and related tests).

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch version/4.4.50

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@CHANGELOG.md`:
- Around line 3-5: Replace the placeholder under the heading "## 4.4.50 -
Unreleased" with a concrete changelog entry that lists the two PR changes: the
subplot spacing fix and the non-fatal HTML export behavior; mention the affected
features (subplot spacing adjustment and HTML export error handling), include a
brief one-line description for each change and any user-facing impact or
migration notes, and ensure the version header "## 4.4.50 - Unreleased" is
preserved.

In `@lumibot/tools/indicators.py`:
- Around line 549-805: The try/except is too broad and currently wraps all
plotting logic (functions like
generate_marker_plotly_text/_generate_ohlc_hover_text, loops over
chart_markers_df, chart_lines_df, chart_ohlc_df, and fig construction), so move
the try/except so only the HTML export call (fig.write_html(...)) is wrapped;
let the subplot creation, trace additions, color/size handling, and layout code
run outside the try so data-processing errors propagate normally, and keep the
existing logger.exception message around the fig.write_html call to make HTML
export non-fatal.
🧹 Nitpick comments (3)
lumibot/tools/indicators.py (2)

453-464: LUMIBOT_DISABLE_UI was explicitly removed in v4.4.31 — re-introducing it may confuse users.

The CHANGELOG for 4.4.31 states: "Removed the short-lived LUMIBOT_DISABLE_UI env var (use SHOW_PLOT/SHOW_INDICATORS/SHOW_TEARSHEET + pytest non-interactive behavior instead)." This code re-reads LUMIBOT_DISABLE_UI at lines 792, 879, and 1534 via the new _env_flag_enabled helper, effectively resurrecting the removed variable.

Additionally, LUMIBOT_WRITE_INDICATORS_HTML is a new env var. Based on learnings: "Do not add new environment variables by default. Prefer explicit function parameters, config objects, or stable defaults."

Consider either:

  • Relying solely on PYTEST_CURRENT_TEST + the existing show_* parameters (already passed through), or
  • If the env vars are truly needed, documenting them in docsrc/environment_variables.rst as per your coding guidelines.

543-547: Nit: the log message assumes the original default is always 0.15.

If _safe_subplot_vertical_spacing is later called with a different default_spacing, this log line would be misleading. Minor, but worth noting.

tests/test_indicator_subplots.py (1)

314-315: Redundant local import — plot_indicators is already imported at line 12.

Suggested fix
 def test_plot_indicators_handles_nan_marker_size(tmp_path, monkeypatch):
-    from lumibot.tools.indicators import plot_indicators
-
     # Build a marker DataFrame with NaN sizes to mirror the failing scenario

CHANGELOG.md Outdated
Comment on lines 3 to 5
## 4.4.50 - Unreleased

- TBD
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Changelog entry is a placeholder — fill in the actual changes before release.

The PR introduces two concrete changes (subplot spacing fix, non-fatal HTML export), but the changelog only contains "TBD". Per the coding guidelines, the changelog must document all changes in a version. Suggested content:

Proposed changelog entry
 ## 4.4.50 - Unreleased
 
-- TBD
+### Fixed
+- Indicators: fix subplot vertical spacing overflow for charts with many indicator rows (Plotly requires `vertical_spacing <= 1/(rows-1)`).
+- Indicators: make HTML chart generation non-fatal — emit a warning and continue with CSV/Parquet export when rendering fails.

As per coding guidelines: "The changelog at CHANGELOG.md MUST be updated for every deployment, release, or significant change. The changelog documents ALL changes in a version."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
## 4.4.50 - Unreleased
- TBD
## 4.4.50 - Unreleased
### Fixed
- Indicators: fix subplot vertical spacing overflow for charts with many indicator rows (Plotly requires `vertical_spacing <= 1/(rows-1)`).
- Indicators: make HTML chart generation non-fatal — emit a warning and continue with CSV/Parquet export when rendering fails.
🤖 Prompt for AI Agents
In `@CHANGELOG.md` around lines 3 - 5, Replace the placeholder under the heading
"## 4.4.50 - Unreleased" with a concrete changelog entry that lists the two PR
changes: the subplot spacing fix and the non-fatal HTML export behavior; mention
the affected features (subplot spacing adjustment and HTML export error
handling), include a brief one-line description for each change and any
user-facing impact or migration notes, and ensure the version header "## 4.4.50
- Unreleased" is preserved.

Comment on lines +549 to +805
try:
# Create subplots without shared x-axes
fig = make_subplots(
rows=num_subplots,
cols=1,
subplot_titles=subplot_titles,
shared_xaxes=False, # Do not use shared x-axes
vertical_spacing=vertical_spacing,
)

# Plot the chart markers
if chart_markers_df is not None and not chart_markers_df.empty:
chart_markers_df["detail_text"] = chart_markers_df.apply(generate_marker_plotly_text, axis=1)

# Group by plot_name first, then by name
for plot_name, plot_df in chart_markers_df.groupby("plot_name"):
# Loop over the marker names for this plot_name
for marker_name, group_df in plot_df.groupby("name"):
group_df = group_df.copy()
# Get the marker symbol
marker_symbol = group_df["symbol"].iloc[0]

# Determine marker size(s), falling back to sensible defaults when unspecified
default_marker_size = 25
raw_sizes = group_df.get("size")
marker_size = default_marker_size

if raw_sizes is not None:
marker_sizes = pd.to_numeric(raw_sizes, errors="coerce")

if isinstance(marker_sizes, pd.Series):
marker_sizes = marker_sizes.fillna(default_marker_size).clip(lower=1)
unique_sizes = marker_sizes.unique()
if len(unique_sizes) == 1:
marker_size = float(unique_sizes[0])
else:
marker_size = marker_sizes.tolist()
else:
if pd.isna(marker_sizes) or marker_sizes <= 0:
marker_size = default_marker_size
has_chart_data = False

###############################
# Chart Markers
###############################

def generate_marker_plotly_text(row):
return _format_indicator_plotly_text(row.get("value"), row.get("detail_text"))

# Plot the chart markers
if chart_markers_df is not None and not chart_markers_df.empty:
chart_markers_df["detail_text"] = chart_markers_df.apply(generate_marker_plotly_text, axis=1)

# Group by plot_name first, then by name
for plot_name, plot_df in chart_markers_df.groupby("plot_name"):
# Loop over the marker names for this plot_name
for marker_name, group_df in plot_df.groupby("name"):
group_df = group_df.copy()
# Get the marker symbol
marker_symbol = group_df["symbol"].iloc[0]

# Determine marker size(s), falling back to sensible defaults when unspecified
default_marker_size = 25
raw_sizes = group_df.get("size")
marker_size = default_marker_size

if raw_sizes is not None:
marker_sizes = pd.to_numeric(raw_sizes, errors="coerce")

if isinstance(marker_sizes, pd.Series):
marker_sizes = marker_sizes.fillna(default_marker_size).clip(lower=1)
unique_sizes = marker_sizes.unique()
if len(unique_sizes) == 1:
marker_size = float(unique_sizes[0])
else:
marker_size = marker_sizes.tolist()
else:
marker_size = float(marker_sizes)

if "color" not in group_df.columns:
group_df["color"] = None
group_df.loc[:, "color"] = group_df["color"].apply(
lambda val: _safe_color(val, f"{plot_name}:{marker_name}")
)

# Determine which subplot to use
row = plot_names.index(plot_name) + 1

# Create a new trace for this marker name
fig.add_trace(
go.Scatter(
x=group_df["datetime"],
y=group_df["value"],
mode="markers",
name=marker_name,
marker_color=group_df["color"],
marker_size=marker_size,
marker_symbol=marker_symbol,
hovertemplate=f"{marker_name}<br>%{{text}}<br>%{{x|%b %d %Y %I:%M:%S %p}}<extra></extra>",
text=group_df["detail_text"],
),
row=row,
col=1
)

has_chart_data = True

###############################
# Chart Lines
###############################

def generate_line_plotly_text(row):
return _format_indicator_plotly_text(row.get("value"), row.get("detail_text"))

# Plot the chart lines
if chart_lines_df is not None and not chart_lines_df.empty:
chart_lines_df["detail_text"] = chart_lines_df.apply(generate_line_plotly_text, axis=1)

# Group by plot_name first, then by name
for plot_name, plot_df in chart_lines_df.groupby("plot_name"):
# Loop over the line names for this plot_name
for line_name, group_df in plot_df.groupby("name"):
if "color" not in group_df.columns:
group_df = group_df.assign(color=None)
color = _safe_color(group_df["color"].iloc[0], f"{plot_name}:{line_name}")

# Determine which subplot to use
row = plot_names.index(plot_name) + 1

# Create a new trace for this line name
fig.add_trace(
go.Scatter(
x=group_df["datetime"],
y=group_df["value"],
mode="lines",
name=line_name,
line_color=color,
hovertemplate=f"{line_name}<br>%{{text}}<br>%{{x|%b %d %Y %I:%M:%S %p}}<extra></extra>",
text=group_df["detail_text"],
),
row=row,
col=1
)

has_chart_data = True
if pd.isna(marker_sizes) or marker_sizes <= 0:
marker_size = default_marker_size
else:
marker_size = float(marker_sizes)

if "color" not in group_df.columns:
group_df["color"] = None
group_df.loc[:, "color"] = group_df["color"].apply(
lambda val: _safe_color(val, f"{plot_name}:{marker_name}")
)

###############################
# Chart OHLC
###############################
# Determine which subplot to use
row = plot_names.index(plot_name) + 1

def _generate_ohlc_hover_text(row):
base = f"O: {row['open']}<br>H: {row['high']}<br>L: {row['low']}<br>C: {row['close']}"
if row.get("detail_text") is None:
return base
return base + "<br>" + str(row.get("detail_text"))
# Create a new trace for this marker name
fig.add_trace(
go.Scatter(
x=group_df["datetime"],
y=group_df["value"],
mode="markers",
name=marker_name,
marker_color=group_df["color"],
marker_size=marker_size,
marker_symbol=marker_symbol,
hovertemplate=f"{marker_name}<br>%{{text}}<br>%{{x|%b %d %Y %I:%M:%S %p}}<extra></extra>",
text=group_df["detail_text"],
),
row=row,
col=1
)

if chart_ohlc_df is not None and not chart_ohlc_df.empty:
chart_ohlc_df = chart_ohlc_df.copy()
has_chart_data = True

for col in ("open", "high", "low", "close"):
if col not in chart_ohlc_df.columns:
logger.warning(f"OHLC data missing required column '{col}', skipping OHLC plotting.")
chart_ohlc_df = None
break
###############################
# Chart Lines
###############################

if chart_ohlc_df is not None and not chart_ohlc_df.empty:
if "color" not in chart_ohlc_df.columns:
chart_ohlc_df["color"] = None
def generate_line_plotly_text(row):
return _format_indicator_plotly_text(row.get("value"), row.get("detail_text"))

# Default per-bar colors: green for bullish, red for bearish (matches Strategy.add_ohlc defaults).
chart_ohlc_df["color"] = chart_ohlc_df["color"].where(
chart_ohlc_df["color"].notna(),
np.where(chart_ohlc_df["close"] >= chart_ohlc_df["open"], "green", "red"),
)
# Plot the chart lines
if chart_lines_df is not None and not chart_lines_df.empty:
chart_lines_df["detail_text"] = chart_lines_df.apply(generate_line_plotly_text, axis=1)

chart_ohlc_df["detail_text"] = chart_ohlc_df.apply(_generate_ohlc_hover_text, axis=1)
# Group by plot_name first, then by name
for plot_name, plot_df in chart_lines_df.groupby("plot_name"):
# Loop over the line names for this plot_name
for line_name, group_df in plot_df.groupby("name"):
if "color" not in group_df.columns:
group_df = group_df.assign(color=None)
color = _safe_color(group_df["color"].iloc[0], f"{plot_name}:{line_name}")

# Group by plot_name first, then by series name.
for plot_name, plot_df in chart_ohlc_df.groupby("plot_name"):
for ohlc_name, group_df in plot_df.groupby("name"):
# Determine which subplot to use
row = plot_names.index(plot_name) + 1

# Preserve per-bar colors by splitting into separate traces per color.
color_groups = list(group_df.groupby("color"))
for idx, (bar_color, colored_df) in enumerate(color_groups):
trace_color = _safe_color(bar_color, f"{plot_name}:{ohlc_name}:{bar_color}")

fig.add_trace(
go.Candlestick(
x=colored_df["datetime"],
open=colored_df["open"],
high=colored_df["high"],
low=colored_df["low"],
close=colored_df["close"],
name=ohlc_name,
showlegend=idx == 0,
legendgroup=ohlc_name,
increasing_line_color=trace_color,
decreasing_line_color=trace_color,
increasing_fillcolor=trace_color,
decreasing_fillcolor=trace_color,
hovertext=colored_df["detail_text"],
hoverinfo="x+text",
),
row=row,
col=1,
)
# Create a new trace for this line name
fig.add_trace(
go.Scatter(
x=group_df["datetime"],
y=group_df["value"],
mode="lines",
name=line_name,
line_color=color,
hovertemplate=f"{line_name}<br>%{{text}}<br>%{{x|%b %d %Y %I:%M:%S %p}}<extra></extra>",
text=group_df["detail_text"],
),
row=row,
col=1
)

has_chart_data = True

###############################
# Chart Titles and Layouts
###############################
###############################
# Chart OHLC
###############################

# Set title and layout
# Calculate height based on number of subplots
# 400px per subplot
height = max(800, num_subplots * 400)
def _generate_ohlc_hover_text(row):
base = f"O: {row['open']}<br>H: {row['high']}<br>L: {row['low']}<br>C: {row['close']}"
if row.get("detail_text") is None:
return base
return base + "<br>" + str(row.get("detail_text"))

title_text = f"Indicators for {strategy_name}" if strategy_name else "Indicators"
if not has_chart_data:
title_text = title_text + " (no indicator data)"
if chart_ohlc_df is not None and not chart_ohlc_df.empty:
chart_ohlc_df = chart_ohlc_df.copy()

for col in ("open", "high", "low", "close"):
if col not in chart_ohlc_df.columns:
logger.warning(f"OHLC data missing required column '{col}', skipping OHLC plotting.")
chart_ohlc_df = None
break

if chart_ohlc_df is not None and not chart_ohlc_df.empty:
if "color" not in chart_ohlc_df.columns:
chart_ohlc_df["color"] = None

# Default per-bar colors: green for bullish, red for bearish (matches Strategy.add_ohlc defaults).
chart_ohlc_df["color"] = chart_ohlc_df["color"].where(
chart_ohlc_df["color"].notna(),
np.where(chart_ohlc_df["close"] >= chart_ohlc_df["open"], "green", "red"),
)

fig.update_layout(
title_text=title_text,
title_font_size=30,
template="plotly_dark",
height=height, # Dynamic height based on number of subplots
margin=dict(t=150), # Add more space between title and first subplot
)
chart_ohlc_df["detail_text"] = chart_ohlc_df.apply(_generate_ohlc_hover_text, axis=1)

# Group by plot_name first, then by series name.
for plot_name, plot_df in chart_ohlc_df.groupby("plot_name"):
for ohlc_name, group_df in plot_df.groupby("name"):
row = plot_names.index(plot_name) + 1

# Preserve per-bar colors by splitting into separate traces per color.
color_groups = list(group_df.groupby("color"))
for idx, (bar_color, colored_df) in enumerate(color_groups):
trace_color = _safe_color(bar_color, f"{plot_name}:{ohlc_name}:{bar_color}")

fig.add_trace(
go.Candlestick(
x=colored_df["datetime"],
open=colored_df["open"],
high=colored_df["high"],
low=colored_df["low"],
close=colored_df["close"],
name=ohlc_name,
showlegend=idx == 0,
legendgroup=ohlc_name,
increasing_line_color=trace_color,
decreasing_line_color=trace_color,
increasing_fillcolor=trace_color,
decreasing_fillcolor=trace_color,
hovertext=colored_df["detail_text"],
hoverinfo="x+text",
),
row=row,
col=1,
)

has_chart_data = True

###############################
# Chart Titles and Layouts
###############################

# Set title and layout
# Calculate height based on number of subplots
# 400px per subplot
height = max(800, num_subplots * 400)

title_text = f"Indicators for {strategy_name}" if strategy_name else "Indicators"
if not has_chart_data:
title_text = title_text + " (no indicator data)"

fig.update_layout(
title_text=title_text,
title_font_size=30,
template="plotly_dark",
height=height, # Dynamic height based on number of subplots
margin=dict(t=150), # Add more space between title and first subplot
)

if has_chart_data:
# Range selector buttons
rangeselector_buttons = list([
dict(count=1, label="1m", step="month", stepmode="backward"),
dict(count=6, label="6m", step="month", stepmode="backward"),
dict(count=1, label="YTD", step="year", stepmode="todate"),
dict(count=1, label="1y", step="year", stepmode="backward"),
dict(step="all"),
])

# Update axes for all subplots
for i in range(1, num_subplots + 1):
# Get the plot name for this subplot
plot_title = plot_names[i - 1]

# Set y-axes titles for each subplot
fig.update_yaxes(
title_text=plot_title,
secondary_y=False,
row=i,
col=1
)
if has_chart_data:
# Range selector buttons
rangeselector_buttons = list([
dict(count=1, label="1m", step="month", stepmode="backward"),
dict(count=6, label="6m", step="month", stepmode="backward"),
dict(count=1, label="YTD", step="year", stepmode="todate"),
dict(count=1, label="1y", step="year", stepmode="backward"),
dict(step="all"),
])

# Update axes for all subplots
for i in range(1, num_subplots + 1):
# Get the plot name for this subplot
plot_title = plot_names[i - 1]

# Set y-axes titles for each subplot
fig.update_yaxes(
title_text=plot_title,
secondary_y=False,
row=i,
col=1
)

# Add range selector and range slider to each subplot
fig.update_xaxes(
rangeselector=dict(
buttons=rangeselector_buttons,
font=dict(color="black"),
activecolor="grey",
bgcolor="white",
),
rangeslider=dict(
visible=True,
thickness=0.02 # Make the range slider height shorter to make line graph appear taller
),
row=i,
col=1
)
# Add range selector and range slider to each subplot
fig.update_xaxes(
rangeselector=dict(
buttons=rangeselector_buttons,
font=dict(color="black"),
activecolor="grey",
bgcolor="white",
),
rangeslider=dict(
visible=True,
thickness=0.02 # Make the range slider height shorter to make line graph appear taller
),
row=i,
col=1
)

disable_ui = (
os.environ.get("LUMIBOT_DISABLE_UI", "").strip().lower() in ("1", "true", "yes")
or bool(os.environ.get("PYTEST_CURRENT_TEST"))
)
disable_ui = _env_flag_enabled("LUMIBOT_DISABLE_UI", default=False) or bool(os.environ.get("PYTEST_CURRENT_TEST"))
write_indicators_html = _env_flag_enabled("LUMIBOT_WRITE_INDICATORS_HTML", default=True)

# Create graph (auto_open disabled for CI/tests).
fig.write_html(plot_file_html, auto_open=show_indicators and not disable_ui)
if write_indicators_html:
# Create graph (auto_open disabled for CI/tests).
fig.write_html(plot_file_html, auto_open=show_indicators and not disable_ui)
else:
logger.info(
"Skipping indicators HTML generation because LUMIBOT_WRITE_INDICATORS_HTML is disabled."
)
except Exception:
logger.exception(
"Indicators subplot rendering failed; continuing with indicators CSV/parquet export."
)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

The try/except scope is very broad — consider narrowing it to just the HTML write.

The try block wraps ~250 lines of plotting logic (subplot creation, all trace additions, layout configuration, and the HTML write). Any bug in marker/line/OHLC data processing (e.g., a KeyError, type mismatch) will be silently swallowed with only a log message, making debugging difficult.

The PR objective is to make HTML export non-fatal. Consider narrowing the try/except to wrap only fig.write_html(...) (line 797), while letting data-processing errors propagate normally.

Sketch of narrower scope
-    try:
-        # Create subplots without shared x-axes
-        fig = make_subplots(
-            ...
-        )
-        ...  # ~250 lines of plotting logic
-        if write_indicators_html:
-            fig.write_html(plot_file_html, auto_open=show_indicators and not disable_ui)
-        ...
-    except Exception:
-        logger.exception(
-            "Indicators subplot rendering failed; continuing with indicators CSV/parquet export."
-        )
+    # Create subplots without shared x-axes
+    fig = make_subplots(
+        ...
+    )
+    ...  # plotting logic (errors propagate normally)
+
+    if write_indicators_html:
+        try:
+            fig.write_html(plot_file_html, auto_open=show_indicators and not disable_ui)
+        except Exception:
+            logger.exception(
+                "Indicators HTML write failed; continuing with CSV/parquet export."
+            )
🤖 Prompt for AI Agents
In `@lumibot/tools/indicators.py` around lines 549 - 805, The try/except is too
broad and currently wraps all plotting logic (functions like
generate_marker_plotly_text/_generate_ohlc_hover_text, loops over
chart_markers_df, chart_lines_df, chart_ohlc_df, and fig construction), so move
the try/except so only the HTML export call (fig.write_html(...)) is wrapped;
let the subplot creation, trace additions, color/size handling, and layout code
run outside the try so data-processing errors propagate normally, and keep the
existing logger.exception message around the fig.write_html call to make HTML
export non-fatal.

@grzesir grzesir changed the title v4.4.50 - Indicators HTML subplot scaling v4.4.50 - Indicators hardening + ThetaData acceptance stabilization Feb 11, 2026
@grzesir grzesir deployed to unit-tests February 11, 2026 08:02 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant