OHLC.dev editorialIDX

IDX Sector Rotation and Breakout Alerts Dashboard Using Python

This project demonstrates how to build an IDX Sector Rotation and Breakout Alerts Dashboard using Python and RapidAPI. It retrieves retail sector rotation analysis together with breakout alert data, normalizes nested JSON responses, converts them into pandas DataFrames, and generates a concise dashboard for monitoring market momentum on the Indonesia Stock Exchange.

August 5, 202611 min readRafatar
IDX Sector Rotation and Breakout Alerts Dashboard Using Python

Understanding market momentum requires more than monitoring individual stock prices. Investors often analyze sector rotation to identify which industries are strengthening or weakening, while breakout alerts help detect stocks that may be entering significant price movements.

By combining these two datasets into a single workflow, traders can obtain both a macro view of sector performance and a micro view of potential trading opportunities.

In this project, we will build a Python dashboard using two Indonesia Stock Exchange API endpoints:

  • getSectorRotation

  • getBreakoutAlerts

The notebook consists of five cells. The first two cells configure the API connection and retrieve data from both endpoints. The following cells normalize different JSON response structures, convert the results into pandas DataFrames, inspect the available columns, and finally generate a dashboard summarizing sector rotation together with breakout alerts.

For security purposes, the RapidAPI key should be replaced with YOUR_RAPIDAPI_KEY. Apart from that replacement, every notebook cell should remain identical to the original implementation.

Cell 1 — Import Libraries and Configure the API

The first cell imports the required Python libraries and prepares the API configuration that will be reused throughout the notebook.

It defines the RapidAPI host, authentication headers, pandas display settings, and confirms that the configuration has been completed before any API request is executed.

# ============================================================
# CELL 1 - IMPORT LIBRARY & KONFIGURASI
# ============================================================

import requests
import pandas as pd
import json
import time
from datetime import datetime

pd.set_option("display.max_columns", None)
pd.set_option("display.max_colwidth", None)
pd.set_option("display.width", 200)

API_KEY = "YOUR_API_KEY"

BASE_URL = "https://indonesia-stock-exchange-idx.p.rapidapi.com"

headers = {
    "x-rapidapi-key": API_KEY,
    "x-rapidapi-host": "indonesia-stock-exchange-idx.p.rapidapi.com",
    "Content-Type": "application/json"
}

print("Konfigurasi berhasil.")

The notebook imports several Python libraries required for API communication and data processing.

  • requests sends HTTP requests to the Indonesia Stock Exchange API.

  • pandas converts normalized JSON responses into structured DataFrames.

  • json formats nested API responses for inspection and debugging.

  • time introduces delays between API requests to reduce the likelihood of rate limiting.

  • datetime records the processing timestamp displayed in the final dashboard.

Several pandas display options are also configured to ensure that DataFrames are rendered clearly inside Google Colab, even when the API returns a large number of columns.

The RapidAPI configuration is stored in the headers dictionary, allowing every request in the notebook to reuse the same authentication settings.

Cell 2 — Retrieve Sector Rotation and Breakout Alerts

The second cell defines a reusable API request function before retrieving data from both Indonesia Stock Exchange endpoints.

# ============================================================
# CELL 2 - REQUEST API
# ============================================================

def call_api(endpoint):

    print("="*90)
    print("Endpoint :", endpoint)

    try:
        r = requests.get(
            BASE_URL + endpoint,
            headers=headers,
            timeout=30
        )

        print("Status   :", r.status_code)

        try:
            data = r.json()
        except:
            data = None

        if r.status_code != 200:
            print(json.dumps(data, indent=2))
        else:
            print("Request berhasil.")

        return data

    except Exception as e:
        print(e)
        return None


sector_rotation = call_api(
    "/api/analysis/retail/sector-rotation"
)

time.sleep(3)

breakout_alerts = call_api(
    "/api/analysis/retail/breakout/alerts"
)

The notebook introduces the reusable helper function:

call_api()

This function centralizes the API communication process by:

  • Building the complete request URL.

  • Sending authenticated HTTP GET requests.

  • Displaying the requested endpoint.

  • Printing the returned HTTP status code.

  • Returning the parsed JSON response when the request succeeds.

  • Displaying API error messages whenever the request fails.

The first request retrieves Sector Rotation analysis through the Retail Analysis endpoint. This dataset provides information about sectors that are strengthening, weakening, improving, or lagging within the Indonesian stock market.

After the first request completes, the notebook waits for three seconds before requesting the second endpoint. This delay helps reduce the possibility of exceeding RapidAPI rate limits.

The second request retrieves Breakout Alerts, which identify stocks showing technical breakout signals and potential trading opportunities.

Finally, both API responses are stored in:

  • sector_rotation

  • breakout_alerts

These variables will be normalized and transformed into pandas DataFrames in the following notebook cells before generating the final dashboard.


Cell 3 — Normalize API Responses

The third cell identifies the most relevant record collections inside each API response before converting them into a standardized format.

# ============================================================
# CELL 3 - NORMALISASI RESPONSE API
# ============================================================

import pandas as pd
import json

def find_record_lists(obj, parent_key="root"):
    """
    Mencari seluruh list yang berisi dictionary
    di dalam response JSON secara rekursif.
    """
    results = []

    if isinstance(obj, list):
        dict_items = [item for item in obj if isinstance(item, dict)]

        if dict_items:
            results.append({
                "source": parent_key,
                "records": dict_items
            })

        for index, item in enumerate(obj):
            if isinstance(item, (dict, list)):
                results.extend(
                    find_record_lists(
                        item,
                        f"{parent_key}[{index}]"
                    )
                )

    elif isinstance(obj, dict):
        for key, value in obj.items():
            new_key = f"{parent_key}.{key}"

            if isinstance(value, (dict, list)):
                results.extend(
                    find_record_lists(
                        value,
                        new_key
                    )
                )

    return results


def select_best_records(response, preferred_words=None):
    """
    Memilih kumpulan record yang paling relevan.
    """

    if response is None:
        return [], "Response kosong"

    candidates = find_record_lists(response)

    if not candidates:
        if isinstance(response, dict):
            return [response], "root"

        return [], "Tidak ditemukan list data"

    preferred_words = preferred_words or []

    scored_candidates = []

    for candidate in candidates:
        source = candidate["source"].lower()
        records = candidate["records"]

        score = len(records)

        for word in preferred_words:
            if word.lower() in source:
                score += 1000

        scored_candidates.append(
            (
                score,
                candidate["source"],
                records
            )
        )

    scored_candidates.sort(
        key=lambda item: item[0],
        reverse=True
    )

    _, selected_source, selected_records = scored_candidates[0]

    return selected_records, selected_source


sector_list, sector_source = select_best_records(
    sector_rotation,
    preferred_words=[
        "sectors",
        "rotation",
        "leading",
        "improving",
        "weakening",
        "lagging"
    ]
)

breakout_list, breakout_source = select_best_records(
    breakout_alerts,
    preferred_words=[
        "alerts",
        "breakouts",
        "stocks",
        "results",
        "data"
    ]
)

print("=" * 100)
print("HASIL NORMALISASI")
print("=" * 100)

print("Sumber Sector Rotation :", sector_source)
print("Jumlah Sector Records  :", len(sector_list))

print()

print("Sumber Breakout Alerts :", breakout_source)
print("Jumlah Breakout Records:", len(breakout_list))

print("\nPreview Sector Rotation:")
if sector_list:
    print(json.dumps(sector_list[0], indent=2, default=str))
else:
    print("Tidak ada data.")

print("\nPreview Breakout Alerts:")
if breakout_list:
    print(json.dumps(breakout_list[0], indent=2, default=str))
else:
    print("Tidak ada data.")

Discovering Record Collections

Rather than assuming that every endpoint returns data in the same location, the notebook introduces the helper function:

find_record_lists()

This function recursively traverses the entire JSON response and searches for every list containing dictionary objects.

Each discovered collection is recorded together with its source path, allowing the notebook to identify where the actual dataset is stored.

Selecting the Most Relevant Records

After collecting every candidate list, the notebook evaluates each one using:

select_best_records()

Each candidate receives a score based on:

  • Number of available records.

  • Matching keywords related to the endpoint.

For Sector Rotation, priority is given to sources containing keywords such as:

  • sectors

  • rotation

  • leading

  • improving

  • weakening

  • lagging

For Breakout Alerts, preferred keywords include:

  • alerts

  • breakouts

  • stocks

  • results

  • data

The collection with the highest score is selected automatically, making the notebook more resilient to future API structure changes.

Normalization Summary

Once the best record collections have been selected, the notebook reports:

  • Selected data source

  • Total Sector Rotation records

  • Total Breakout Alert records

  • Preview of the first normalized record

These diagnostics confirm that the response has been normalized successfully before moving on to DataFrame creation.

Cell 4 — Create DataFrames and Inspect Available Columns

After identifying the normalized record collections, the notebook converts them into pandas DataFrames for further analysis.

# ============================================================
# CELL 4 - MEMBENTUK DATAFRAME DAN MEMERIKSA KOLOM
# ============================================================

sector_df = (
    pd.json_normalize(sector_list, sep=".")
    if sector_list
    else pd.DataFrame()
)

breakout_df = (
    pd.json_normalize(breakout_list, sep=".")
    if breakout_list
    else pd.DataFrame()
)

print("=" * 100)
print("SECTOR ROTATION DATA")
print("=" * 100)

if not sector_df.empty:
    print("Jumlah Data :", len(sector_df))
    print("Kolom Tersedia:")
    print(sector_df.columns.tolist())

    print("\nPreview Data:")
    display(sector_df.head(10))
else:
    print("Tidak ada data Sector Rotation.")

print("\n" + "=" * 100)
print("BREAKOUT ALERTS DATA")
print("=" * 100)

if not breakout_df.empty:
    print("Jumlah Data :", len(breakout_df))
    print("Kolom Tersedia:")
    print(breakout_df.columns.tolist())

    print("\nPreview Data:")
    display(breakout_df.head(10))
else:
    print("Tidak ada data Breakout Alerts.")

Creating DataFrames

The notebook converts both normalized record lists using:

pd.json_normalize()

This function flattens nested JSON objects into tabular structures that are easier to inspect and analyze.

Two DataFrames are created:

  • sector_df

  • breakout_df

These DataFrames serve as the primary datasets used by the dashboard in the final notebook cell.

Inspecting the Available Columns

Before generating the dashboard, the notebook validates each DataFrame by displaying:

  • Total number of records.

  • Available column names.

  • Preview of the first ten rows.

This inspection step is important because different API versions may introduce new fields or rename existing columns. Reviewing the available columns allows users to quickly understand the structure of the retrieved data before continuing with further analysis.



Cell 5 — IDX Retail Analysis Dashboard

# ============================================================
# CELL 5 - IDX RETAIL ANALYSIS DASHBOARD
# ============================================================

from datetime import datetime
import pandas as pd


def clean_value(value, default="-"):
    """
    Membersihkan nilai kosong, NaN, list, dan dictionary.
    """

    if value is None:
        return default

    try:
        if pd.isna(value):
            return default
    except (TypeError, ValueError):
        pass

    if isinstance(value, list):
        if not value:
            return default

        return ", ".join(
            str(item) for item in value[:5]
        )

    if isinstance(value, dict):
        if not value:
            return default

        return ", ".join(
            f"{key}: {val}"
            for key, val in list(value.items())[:5]
        )

    text = str(value).strip()

    return text if text else default


def find_column(df, candidates):
    """
    Mencari kolom berdasarkan nama lengkap atau bagian nama.
    """

    if df.empty:
        return None

    columns = list(df.columns)
    lowercase_map = {
        str(column).lower(): column
        for column in columns
    }

    # Pencarian nama kolom sama persis
    for candidate in candidates:
        candidate_lower = candidate.lower()

        if candidate_lower in lowercase_map:
            return lowercase_map[candidate_lower]

    # Pencarian berdasarkan bagian akhir kolom
    for candidate in candidates:
        candidate_lower = candidate.lower()

        for column in columns:
            column_lower = str(column).lower()

            if column_lower.endswith(f".{candidate_lower}"):
                return column

    # Pencarian berdasarkan kata yang terkandung
    for candidate in candidates:
        candidate_lower = candidate.lower()

        for column in columns:
            if candidate_lower in str(column).lower():
                return column

    return None


def get_value(row, column, default="-"):
    if column is None:
        return default

    return clean_value(
        row.get(column),
        default
    )


# ------------------------------------------------------------
# MENCARI KOLOM SECTOR ROTATION
# ------------------------------------------------------------

sector_name_col = find_column(
    sector_df,
    [
        "sector",
        "sectorName",
        "sector_name",
        "name",
        "industry",
        "label"
    ]
)

sector_status_col = find_column(
    sector_df,
    [
        "quadrant",
        "rotation",
        "status",
        "category",
        "phase",
        "signal",
        "trend",
        "momentum"
    ]
)

sector_score_col = find_column(
    sector_df,
    [
        "score",
        "rotationScore",
        "rotation_score",
        "strength",
        "momentumScore",
        "performance",
        "changePercent",
        "return"
    ]
)

sector_leaders_col = find_column(
    sector_df,
    [
        "topStocks",
        "top_stocks",
        "stocks",
        "leaders",
        "symbols",
        "companies"
    ]
)


# ------------------------------------------------------------
# MENCARI KOLOM BREAKOUT ALERTS
# ------------------------------------------------------------

breakout_symbol_col = find_column(
    breakout_df,
    [
        "symbol",
        "ticker",
        "code",
        "stockCode",
        "stock_code"
    ]
)

breakout_name_col = find_column(
    breakout_df,
    [
        "companyName",
        "company_name",
        "name",
        "company"
    ]
)

breakout_signal_col = find_column(
    breakout_df,
    [
        "signal",
        "status",
        "breakoutType",
        "breakout_type",
        "recommendation",
        "alert",
        "type"
    ]
)

breakout_price_col = find_column(
    breakout_df,
    [
        "currentPrice",
        "current_price",
        "price",
        "close",
        "lastPrice",
        "last_price"
    ]
)

breakout_level_col = find_column(
    breakout_df,
    [
        "breakoutPrice",
        "breakout_price",
        "breakoutLevel",
        "breakout_level",
        "resistance",
        "resistanceLevel",
        "resistance_level"
    ]
)

breakout_score_col = find_column(
    breakout_df,
    [
        "confidence",
        "confidenceScore",
        "confidence_score",
        "score",
        "strength",
        "probability"
    ]
)

breakout_volume_col = find_column(
    breakout_df,
    [
        "volumeRatio",
        "volume_ratio",
        "volumeSpike",
        "volume_spike",
        "volumeChange",
        "volume_change",
        "volume"
    ]
)


print("=" * 100)
print("IDX RETAIL ANALYSIS DASHBOARD")
print("=" * 100)

# ------------------------------------------------------------
# SECTOR ROTATION
# ------------------------------------------------------------

print("\n📈 SECTOR ROTATION")
print("-" * 100)

if not sector_df.empty:
    print("Jumlah Data :", len(sector_df))
    print()

    for number, (_, row) in enumerate(
        sector_df.head(10).iterrows(),
        start=1
    ):
        sector_name = get_value(
            row,
            sector_name_col
        )

        sector_status = get_value(
            row,
            sector_status_col
        )

        sector_score = get_value(
            row,
            sector_score_col
        )

        sector_leaders = get_value(
            row,
            sector_leaders_col
        )

        print(f"{number:02d}. Sektor       : {sector_name}")
        print(f"    Posisi/Status : {sector_status}")
        print(f"    Skor/Kinerja  : {sector_score}")

        if sector_leaders != "-":
            print(f"    Saham Utama   : {sector_leaders}")

        print("-" * 100)

else:
    print("Tidak ada data Sector Rotation.")


# ------------------------------------------------------------
# BREAKOUT ALERTS
# ------------------------------------------------------------

print("\n🚀 BREAKOUT ALERTS")
print("-" * 100)

if not breakout_df.empty:
    print("Jumlah Data :", len(breakout_df))
    print()

    for number, (_, row) in enumerate(
        breakout_df.head(20).iterrows(),
        start=1
    ):
        symbol = get_value(
            row,
            breakout_symbol_col
        )

        company_name = get_value(
            row,
            breakout_name_col
        )

        signal = get_value(
            row,
            breakout_signal_col
        )

        price = get_value(
            row,
            breakout_price_col
        )

        breakout_level = get_value(
            row,
            breakout_level_col
        )

        score = get_value(
            row,
            breakout_score_col
        )

        volume = get_value(
            row,
            breakout_volume_col
        )

        title = symbol

        if company_name != "-":
            title = f"{symbol} - {company_name}"

        print(f"{number:02d}. {title}")
        print(f"    Sinyal         : {signal}")
        print(f"    Harga          : {price}")
        print(f"    Level Breakout : {breakout_level}")
        print(f"    Skor           : {score}")
        print(f"    Volume         : {volume}")
        print("-" * 100)

else:
    print("Tidak ada data Breakout Alerts.")


# ------------------------------------------------------------
# RINGKASAN
# ------------------------------------------------------------

print("\n" + "=" * 100)
print("RINGKASAN")
print("=" * 100)

print(f"📈 Total Sector Rotation : {len(sector_df)}")
print(f"🚀 Total Breakout Alerts : {len(breakout_df)}")

print(
    "✅ Status Sector Rotation :",
    "Data tersedia"
    if not sector_df.empty
    else "Tidak ada data"
)

print(
    "✅ Status Breakout Alerts :",
    "Data tersedia"
    if not breakout_df.empty
    else "Tidak ada data"
)

if breakout_symbol_col and not breakout_df.empty:
    breakout_symbols = (
        breakout_df[breakout_symbol_col]
        .dropna()
        .astype(str)
        .head(10)
        .tolist()
    )

    if breakout_symbols:
        print(
            "🔥 Saham Breakout Teratas :",
            ", ".join(breakout_symbols)
        )

print(
    "\nSelesai diproses :",
    datetime.now().strftime("%d-%m-%Y %H:%M:%S")
)

The dashboard first defines helper functions to clean empty values and identify matching columns across different API response structures. It then searches for relevant Sector Rotation fields such as sector name, position, score, and leading stocks. For Breakout Alerts, it identifies the stock symbol, company name, signal, price, breakout level, score, and volume.

The output displays up to ten Sector Rotation records and twenty Breakout Alerts. It finishes with total record counts, processing status, the first ten breakout symbols, and a completion timestamp.

Final Result

After executing all five cells, this project can:

  • Retrieve IDX Sector Rotation analysis.

  • Retrieve current Breakout Alerts.

  • Handle failed or empty API responses.

  • Search nested JSON responses recursively.

  • Select the most relevant record collections.

  • Normalize nested data into pandas DataFrames.

  • Detect available columns dynamically.

  • Display sector names, positions, scores, and leading stocks.

  • Display breakout symbols, signals, prices, breakout levels, scores, and volume.

  • Generate a concise final dashboard and processing summary.

Conclusion

This project demonstrates how to build an IDX Sector Rotation and Breakout Alerts Dashboard using Python and RapidAPI.

Sector Rotation provides a broader view of where market momentum is moving across industries, while Breakout Alerts highlight individual stocks that may be entering important technical price movements. Combining both datasets creates a practical workflow for observing market direction and identifying potential trading opportunities.

The notebook is designed to remain flexible when API response structures change. It recursively searches for valid records, selects the most relevant data source, normalizes nested JSON, detects available columns, and handles missing values without interrupting execution.

This structure makes the project suitable as a foundation for automated IDX market monitoring, technical screening dashboards, research workflows, or more advanced retail trading analytics.