Rajandran R Creator of OpenAlgo - OpenSource Algo Trading framework for Indian Traders. Building GenAI Applications. Telecom Engineer turned Full-time Derivative Trader. Mostly Trading Nifty, Banknifty, High Liquid Stock Derivatives. Trading the Markets Since 2006 onwards. Using Market Profile and Orderflow for more than a decade. Designed and published 100+ open source trading systems on various trading tools. Strongly believe that market understanding and robust trading frameworks are the key to the trading success. Building Algo Platforms, Writing about Markets, Trading System Design, Market Sentiment, Trading Softwares & Trading Nuances since 2007 onwards. Author of Marketcalls.in

Building a Simple ORB Strategy Visualization with Stop Loss and Target Using OpenAlgo – Python Tutorial

11 min read

<p>The Open Range Breakout (ORB) is one of the first strategies most intraday traders test, and for good reason. It is simple to define, easy to automate, and works across almost any liquid stock or index. In this tutorial we will build a complete Python script that pulls 1-minute historical data through OpenAlgo, computes the opening range for each trading day, runs a bar-by-bar signal engine with stop loss and target rules, and renders everything as an interactive Plotly chart.</p>

<p>By the end you will have a reusable template you can point at any symbol on any exchange supported by OpenAlgo.</p>

<h2>What is an Open Range Breakout (ORB) strategy?</h2>
<p>An ORB strategy marks the high and low of the first few minutes of trading as a range, then enters long on a breakout above the range high or short on a breakdown below the range low, using a fixed stop loss and target for each trade.</p>

<p>In this tutorial the opening range is measured from 09:15 to 09:30 IST. Once that window closes, the range high (ORBH) and range low (ORBL) become fixed levels for the rest of the day. A close above ORBH triggers a long trade, a close below ORBL triggers a short trade, and every trade carries a 0.3% stop loss and a 1% target, with a hard square-off at 15:15.</p>

<h2>Why visualize the strategy instead of only reading a trade log?</h2>
<p>A chart shows exactly where each entry, stop, target and exit fell against the live price action, which makes it far easier to catch logic errors, gaps and false breakouts than scanning a table of trade rows.</p>

<p>A trade log tells you a strategy made 2% on a given day. A chart tells you whether that 2% came from a clean breakout or from a lucky gap that happened to skip past the stop loss. When you are still shaping the rules of a strategy, seeing is debugging.</p>

<h3>Prerequisites</h3>
<p>Before you start, make sure you have:</p>
<ul>
<li>OpenAlgo installed and running, either locally or on a server you control</li>
<li>An OpenAlgo API key, and historical data already downloaded into OpenAlgo's Historify database for the symbol you want to test</li>
<li>Python 3.9 or newer</li>
<li>The following packages installed</li>
</ul>

<pre><code class="language-bash">pip install openalgo pandas numpy plotly python-dotenv</code></pre>

<h3>Step 1: Set up your environment</h3>
<p>Keep your API key and host URL out of the script itself by storing them in a <code>.env</code> file in the same folder:</p>

<pre><code class="language-bash">OPENALGO_API_KEY=your_api_key_here
OPENALGO_HOST=http://127.0.0.1:5000</code></pre>

<p>The script loads these with <code>python-dotenv</code> so you never hardcode credentials:</p>

<pre><code class="language-python">import os
from pathlib import Path
from dotenv import find_dotenv, load_dotenv

script_dir = Path(__file__).resolve().parent
load_dotenv(find_dotenv(), override=False)</code></pre>

<p>Alongside this, define the strategy parameters at the top of the file so every rule is visible and editable in one place:</p>

<pre><code class="language-python">from datetime import time

SYMBOL = "SBIN"
EXCHANGE = "NSE"
INTERVAL = "1m"
CHART_DAYS = 5                 # trading days shown on the chart

ORB_START = time(9, 15)        # opening range start
ORB_END = time(9, 30)          # opening range end (exclusive)
FRESH_START = time(9, 30)      # fresh trades allowed from
FRESH_END = time(15, 0)        # last time a new trade may trigger
SQUAREOFF = time(15, 15)       # force exit
SL_PCT = 0.003                 # stop loss 0.3%
TARGET_PCT = 0.01              # target 1%</code></pre>

<p>Because these are just variables, swapping the symbol or tightening the stop loss later is a one-line change, not a rewrite.</p>

<h3>Step 2: Fetch 1-minute historical data from OpenAlgo</h3>
<p>OpenAlgo's <code>history()</code> call returns OHLCV bars, and passing <code>source="db"</code> tells it to read from Historify instead of hitting the broker API on every request. This function pulls the last 14 calendar days and trims down to a clean set of trading days for charting:</p>

<pre><code class="language-python">import pandas as pd
from datetime import datetime, timedelta
from openalgo import api

def fetch_data():
    client = api(
        api_key=os.getenv("OPENALGO_API_KEY"),
        host=os.getenv("OPENALGO_HOST", "http://127.0.0.1:5000"),
    )
    end_date = datetime.now().date()
    start_date = end_date - timedelta(days=14)
    df = client.history(
        symbol=SYMBOL, exchange=EXCHANGE, interval=INTERVAL,
        start_date=start_date.strftime("%Y-%m-%d"),
        end_date=end_date.strftime("%Y-%m-%d"),
        source="db",
    )
    if "timestamp" in df.columns:
        df["timestamp"] = pd.to_datetime(df["timestamp"])
        df = df.set_index("timestamp")
    else:
        df.index = pd.to_datetime(df.index)
    df = df.sort_index()
    if df.index.tz is not None:
        df.index = df.index.tz_localize(None)
    # keep only the last CHART_DAYS trading days
    days = df.index.normalize().unique()
    df = df[df.index.normalize().isin(days[-CHART_DAYS:])]
    return df</code></pre>

<p>A couple of details worth noticing: the function normalizes the index to a naive datetime, since mixing timezone-aware and naive timestamps causes silent bugs later when you group by day, and it deliberately fetches more days than it needs (14) so that CHART_DAYS can be trimmed cleanly even around weekends and holidays.</p>

<h3>Step 3: Compute the opening range for each day</h3>
<p>For every trading day in the dataframe, this step slices out the 09:15 to 09:30 window, records its high and low as ORBH and ORBL, and broadcasts those levels across every bar of that day so they can be plotted as flat lines:</p>

<pre><code class="language-python">import numpy as np

def compute_orb_levels(df):
    """Per-bar ORBH/ORBL series (NaN during the opening range window)."""
    orbh = pd.Series(np.nan, index=df.index)
    orbl = pd.Series(np.nan, index=df.index)
    day_levels = {}
    for day, day_df in df.groupby(df.index.normalize()):
        rng = day_df.between_time(ORB_START, ORB_END, inclusive="left")
        if rng.empty:
            continue
        h, l = rng["high"].max(), rng["low"].min()
        day_levels[day.date()] = (h, l)
        mask = df.index.normalize() == day
        orbh[mask] = h
        orbl[mask] = l
    return orbh, orbl, day_levels</code></pre>

<p><code>day_levels</code> is the compact dictionary the signal engine will actually loop over, keyed by date, while <code>orbh</code> and <code>orbl</code> are the full-length series used purely for plotting the green and red range lines on the chart.</p>

<h3>Step 4: Define stop loss, target and exit rules</h3>
<p>Every open position needs to be checked, bar by bar, against three possible exits: a stop loss, a target, or a break of the opposite side of the opening range, plus the hard square-off time. This function returns the first exit reason that applies, along with the exact fill price:</p>

<pre><code class="language-python">def exit_check(position, entry_price, h, l, c, t, orbh, orbl):
    """Touch-based exit test for one bar. Returns (reason, fill_price) or None."""
    if t &gt;= SQUAREOFF:
        return "Squareoff", c
    if position == 1:
        sl = entry_price * (1 - SL_PCT)
        tgt = entry_price * (1 + TARGET_PCT)
        if sl &gt;= orbl:
            stop_level, stop_reason = sl, "Stop Loss"
        else:
            stop_level, stop_reason = orbl, "ORBL Break"
        if l &lt;= stop_level:
            return stop_reason, stop_level
        if h &gt;= tgt:
            return "Target", tgt
    else:
        sl = entry_price * (1 + SL_PCT)
        tgt = entry_price * (1 - TARGET_PCT)
        if sl &lt;= orbh:
            stop_level, stop_reason = sl, "Stop Loss"
        else:
            stop_level, stop_reason = orbh, "ORBH Break"
        if h &gt;= stop_level:
            return stop_reason, stop_level
        if l &lt;= tgt:
            return "Target", tgt
    return None</code></pre>

<p>Two design choices here matter for anyone adapting this to their own rules. First, whichever of the fixed stop loss or the opposite ORB level is closer to price becomes the active stop, so the strategy never risks more than the tighter of the two levels. Second, when a single candle touches both a stop and a target, the function assumes the stop hit first. That is a conservative assumption that will understate returns slightly, which is the right direction to be wrong in when you are sizing risk.</p>

<h3>Step 5: Run the bar-by-bar signal engine</h3>
<p>This is the core state machine. It walks through every 1-minute bar, checks for exits on any open position, and looks for a fresh entry only during the permitted trading window, taking at most one long and one short trade per day:</p>

<pre><code class="language-python">def run_signals(df, day_levels):
    long_entries, long_exits = [], []
    short_entries, short_exits = [], []
    trades = []
    sl_segments = []
    tgt_segments = []

    position = 0
    entry_price = np.nan
    entry_ts = None
    entry_i = -1
    long_done = short_done = False
    current_day = None

    opens = df["open"].values
    highs = df["high"].values
    lows = df["low"].values
    closes = df["close"].values
    times = df.index

    def record_exit(i, ts, reason, fill):
        nonlocal position
        pnl = (fill / entry_price - 1) * 100 * position
        trades.append({
            "side": "Long" if position == 1 else "Short",
            "entry_time": entry_ts, "entry_price": entry_price,
            "exit_time": ts, "exit_price": fill,
            "reason": reason, "pnl_pct": pnl,
        })
        sl_level = entry_price * (1 - SL_PCT if position == 1 else 1 + SL_PCT)
        tgt_level = entry_price * (1 + TARGET_PCT if position == 1 else 1 - TARGET_PCT)
        sl_segments.append((entry_i, i, sl_level))
        tgt_segments.append((entry_i, i, tgt_level))
        if position == 1:
            long_exits.append((i, fill, reason, pnl))
        else:
            short_exits.append((i, fill, reason, pnl))
        position = 0

    for i in range(len(df)):
        ts = times[i]
        day = ts.date()
        if day not in day_levels:
            continue
        if day != current_day:
            current_day = day
            long_done = short_done = False
        orbh, orbl = day_levels[day]
        t = ts.time()
        o, h, l, c = opens[i], highs[i], lows[i], closes[i]

        if position != 0:
            hit = exit_check(position, entry_price, h, l, c, t, orbh, orbl)
            if hit:
                record_exit(i, ts, hit[0], hit[1])

        if position == 0 and FRESH_START &lt;= t &lt;= FRESH_END:
            side = 0
            if not long_done and h &gt;= orbh and not short_done and l &lt;= orbl:
                side = 1 if (orbh - o) &lt;= (o - orbl) else -1
            elif not long_done and h &gt;= orbh:
                side = 1
            elif not short_done and l &lt;= orbl:
                side = -1

            if side == 1:
                entry_price = orbh
                position, entry_ts, entry_i, long_done = 1, ts, i, True
                long_entries.append((i, entry_price))
            elif side == -1:
                entry_price = orbl
                position, entry_ts, entry_i, short_done = -1, ts, i, True
                short_entries.append((i, entry_price))

            if position != 0:
                hit = exit_check(position, entry_price, h, l, c, t, orbh, orbl)
                if hit:
                    record_exit(i, ts, hit[0], hit[1])

    return (long_entries, long_exits, short_entries, short_exits, trades,
            sl_segments, tgt_segments)</code></pre>

<p>Notice the <code>long_done</code> and <code>short_done</code> flags reset at the start of every new day, which is what enforces the one-trade-per-direction-per-day rule. Also notice the rare edge case handled near the top of the loop: if a single bar's high and low sweep both ORBH and ORBL at once, the function assumes whichever level sat closer to that bar's open price was touched first.</p>

<h3>Step 6: Plot everything with Plotly</h3>
<p>With entries, exits, stop levels and target levels all computed, the chart function lays down a candlestick trace, the ORBH and ORBL lines, dashed stop loss and target segments that only appear while a trade is open, shaded rectangles over the opening range window, and triangle or X markers for every entry and exit:</p>

<pre><code class="language-python">import plotly.graph_objects as go

def build_chart(df, orbh, orbl, day_levels, signals):
    (long_entries, long_exits, short_entries, short_exits, trades,
     sl_segments, tgt_segments) = signals
    x_labels = df.index.strftime("%d-%b %H:%M")

    fig = go.Figure()
    fig.add_trace(go.Candlestick(
        x=x_labels, open=df["open"], high=df["high"],
        low=df["low"], close=df["close"], name="Price",
    ))
    fig.add_trace(go.Scatter(
        x=x_labels, y=orbh, mode="lines", name="ORBH",
        line=dict(color="lime", width=1.5), connectgaps=False,
    ))
    fig.add_trace(go.Scatter(
        x=x_labels, y=orbl, mode="lines", name="ORBL",
        line=dict(color="red", width=1.5), connectgaps=False,
    ))

    sl_line = np.full(len(df), np.nan)
    for a, b, lvl in sl_segments:
        sl_line[a:b + 1] = lvl
    fig.add_trace(go.Scatter(
        x=x_labels, y=sl_line, mode="lines", name="Stop Loss",
        line=dict(color="yellow", width=1.2, dash="dash"), connectgaps=False,
    ))
    tgt_line = np.full(len(df), np.nan)
    for a, b, lvl in tgt_segments:
        tgt_line[a:b + 1] = lvl
    fig.add_trace(go.Scatter(
        x=x_labels, y=tgt_line, mode="lines", name="Target",
        line=dict(color="cyan", width=1.2, dash="dash"), connectgaps=False,
    ))

    positions = np.arange(len(df))
    for day in df.index.normalize().unique():
        day_mask = df.index.normalize() == day
        day_pos = positions[day_mask]
        day_times = df.index[day_mask]
        in_range = day_pos[(day_times.time &gt;= ORB_START) &amp; (day_times.time &lt; ORB_END)]
        if len(in_range):
            fig.add_vrect(x0=in_range[0] - 0.5, x1=in_range[-1] + 0.5,
                          fillcolor="rgba(80,120,255,0.15)", line_width=0)
        sq = day_pos[day_times.time &gt;= SQUAREOFF]
        if len(sq):
            fig.add_vline(x=sq[0], line_dash="dot", line_color="gray", line_width=1)

    def marker_trace(points, name, symbol, color, above):
        if not points:
            return
        idx = [p[0] for p in points]
        offset = 1.0015 if above else 0.9985
        base = df["high"].values if above else df["low"].values
        y = [base[i] * offset for i in idx]
        text = []
        for p in points:
            if len(p) == 4:
                text.append(f"{name}: {p[2]} at {p[1]:.2f} ({p[3]:+.2f}%)")
            else:
                text.append(f"{name} at {p[1]:.2f}")
        fig.add_trace(go.Scatter(
            x=[x_labels[i] for i in idx], y=y, mode="markers", name=name,
            marker=dict(symbol=symbol, size=11, color=color,
                        line=dict(width=1, color="white")),
            text=text, hoverinfo="text",
        ))

    marker_trace(long_entries, "Long Entry", "triangle-up", "limegreen", above=False)
    marker_trace(long_exits, "Long Exit", "x", "orange", above=True)
    marker_trace(short_entries, "Short Entry", "triangle-down", "red", above=True)
    marker_trace(short_exits, "Short Exit", "x", "violet", above=False)

    fig.update_layout(
        template="plotly_dark",
        title=f"{SYMBOL} - Open Range Breakout (1m) | ORB window 09:15-09:30 | "
              f"SL {SL_PCT:.1%} / Target {TARGET_PCT:.0%} / Squareoff 15:15",
        xaxis_rangeslider_visible=False, xaxis_type="category",
        height=760, legend=dict(orientation="h", y=1.02, x=0),
        xaxis=dict(nticks=20),
    )
    return fig</code></pre>

<p>Using <code>xaxis_type="category"</code> here is a deliberate choice. Plotting 1-minute bars on a normal time axis would stretch overnight and weekend gaps into long empty stretches of chart. Treating the x-axis as a category instead keeps every bar the same width and butts the trading sessions right up against each other.</p>

<h3>Step 7: Print a plain-English trade summary</h3>
<p>Alongside the chart, it helps to print a readable log of what happened, so you do not have to hover over every marker to understand the day:</p>

<pre><code class="language-python">def explain(day_levels, trades, df):
    print(f"{SYMBOL} - Open Range Breakout (1m) Analysis")
    print()
    print("Daily opening range levels (09:15-09:30 IST):")
    for day, (h, l) in day_levels.items():
        width = (h / l - 1) * 100
        print(f"  {day}  ORBH {h:.2f}  ORBL {l:.2f}  range width {width:.2f}%")
    print()
    if not trades:
        print("No trades were triggered in this window.")
        return
    print(f"Trades ({len(trades)}):")
    for tr in trades:
        print(f"  {tr['side']:&lt;5} entry {tr['entry_time']:%d-%b %H:%M} at {tr['entry_price']:.2f}"
              f" -&gt; exit {tr['exit_time']:%d-%b %H:%M} at {tr['exit_price']:.2f}"
              f"  [{tr['reason']}]  P&amp;L {tr['pnl_pct']:+.2f}%")
    total = sum(tr["pnl_pct"] for tr in trades)
    wins = sum(1 for tr in trades if tr["pnl_pct"] &gt; 0)
    print()
    print(f"Summary: {len(trades)} trades, {wins} winners, "
          f"cumulative P&amp;L {total:+.2f}% (unleveraged, before costs)")</code></pre>

<h3>Running the full script</h3>
<p>The <code>main()</code> function ties every step together: fetch data, compute ORB levels, run the signal engine, build the chart, save it to an HTML file, and print the summary.</p>

<pre><code class="language-python">def main():
    df = fetch_data()
    orbh, orbl, day_levels = compute_orb_levels(df)
    signals = run_signals(df, day_levels)
    fig = build_chart(df, orbh, orbl, day_levels, signals)
    out_file = script_dir / f"{SYMBOL}_orb_chart.html"
    fig.write_html(out_file)
    print(f"Chart saved: {out_file}")
    print()
    explain(day_levels, signals[4], df)
    fig.show()

if __name__ == "__main__":
    main()</code></pre>

<p>Run it with <code>python orb_chart.py</code> and it will open an interactive chart in your browser, save an HTML copy you can share, and print the trade log to your terminal.</p>

<h3>Reading the chart output</h3>


<p>A few things to look for once your chart renders:</p>
<ul>
<li>The light blue shaded band at the start of each day marks the 09:15 to 09:30 opening range window</li>
<li>The green and red horizontal lines are ORBH and ORBL, holding flat for the rest of that trading day</li>
<li>The dashed yellow and cyan lines only appear while a trade is open, showing exactly where the stop loss and target sat for that specific trade</li>
<li>Green up-triangles mark long entries, red down-triangles mark short entries, and the X markers in orange or violet mark exits, with the hover text giving the exact exit reason and P&amp;L</li>
<li>The dotted gray vertical line marks the 15:15 square-off point on days where a position was still open</li>
</ul>

<p>Comparing sessions side by side this way makes it obvious, for example, that a wide opening range tends to produce fewer breakout signals than a narrow one, something that is much harder to notice from a plain trade log.</p>

<h3>Ideas to extend this strategy</h3>
<ul>
<li>Swap SL_PCT and TARGET_PCT for ATR-based levels instead of fixed percentages, so risk adapts to each stock's volatility</li>
<li>Loop the whole script over a watchlist and rank symbols by opening range width to find the cleanest breakout candidates each morning</li>
<li>Feed the same day_levels and run_signals logic into OpenAlgo's place_smart_order in analyze mode first, then live, once you are satisfied with the backtest</li>
<li>Replace the single-day one-trade-per-direction rule with a re-entry rule that allows a second attempt after a stopped-out breakout</li>
</ul>

<h3>Wrapping up</h3>
<p>This script is intentionally kept in plain pandas and numpy rather than a backtesting framework, so every line of the entry, exit and plotting logic stays visible and easy to modify. That makes it a good starting template regardless of whether your next step is tightening the ORB rules, testing it across a basket of NIFTY stocks, or wiring it into OpenAlgo's live order placement once you are ready to move from analyze mode to real trades.</p>
Rajandran R Creator of OpenAlgo - OpenSource Algo Trading framework for Indian Traders. Building GenAI Applications. Telecom Engineer turned Full-time Derivative Trader. Mostly Trading Nifty, Banknifty, High Liquid Stock Derivatives. Trading the Markets Since 2006 onwards. Using Market Profile and Orderflow for more than a decade. Designed and published 100+ open source trading systems on various trading tools. Strongly believe that market understanding and robust trading frameworks are the key to the trading success. Building Algo Platforms, Writing about Markets, Trading System Design, Market Sentiment, Trading Softwares & Trading Nuances since 2007 onwards. Author of Marketcalls.in

Leave a Reply

Get Notifications, Alerts on Market Updates, Trading Tools, Automation & More