OHLC.dev editorialIDX

IDX OHLCV Price Analysis with Python and Historical Stock Data

This article explains how to build an IDX OHLCV price analysis workflow with Python by combining getLatestOHLCV and getOHLCV, cleaning both datasets, removing duplicate dates, calculating price movement metrics, visualizing close price and volume, and exporting the final result into CSV and Excel files.

May 26, 202613 min readRafatar
IDX OHLCV Price Analysis with Python and Historical Stock Data

Stock price analysis becomes stronger when the data is complete, clean, and structured.

In many stock analysis workflows, we often need two types of OHLCV data. The first is the latest OHLCV data, which gives a recent view of price movement. The second is historical OHLCV data based on a selected date range, which helps us focus on a specific analysis period.

This notebook builds a practical case: creating an IDX OHLCV price analysis workflow using Python.

The project combines two APIs from the Indonesia Stock Exchange data source: getLatestOHLCV and getOHLCV. The getLatestOHLCV API retrieves the latest daily OHLCV records, while getOHLCV retrieves OHLCV data based on a custom date range.

Instead of treating both API results separately, the notebook combines them into one clean dataset, removes duplicate dates, calculates daily return and candle metrics, visualizes close price and volume, and exports the final analysis.

Cell 1 — Import Library + Config API

import os
import json
import time
from typing import Any, Dict, List, Optional, Union

import requests
import pandas as pd
import matplotlib.pyplot as plt


RAPIDAPI_HOST = "indonesia-stock-exchange-idx.p.rapidapi.com"
BASE_URL = f"https://{RAPIDAPI_HOST}"

# Ganti dengan RapidAPI Key Anda
RAPIDAPI_KEY = "ISI_RAPIDAPI_KEY_ANDA_DI_SINI"

# Alternatif jika memakai environment variable:
# RAPIDAPI_KEY = os.getenv("RAPIDAPI_KEY")

if not RAPIDAPI_KEY or RAPIDAPI_KEY == "ISI_RAPIDAPI_KEY_ANDA_DI_SINI":
    raise ValueError("Masukkan RapidAPI Key Anda dulu pada variabel RAPIDAPI_KEY.")

HEADERS = {
    "Content-Type": "application/json",
    "x-rapidapi-host": RAPIDAPI_HOST,
    "x-rapidapi-key": RAPIDAPI_KEY,
}

print("API configuration ready.")

Explanation

Cell 1 prepares the notebook environment and API configuration.

The notebook imports several required libraries. os is prepared for environment variable usage. json is used to preview raw API responses in readable format. time is used to add delays between API requests. requests is used to call the API endpoints. pandas is used to process the API response into dataframes. matplotlib.pyplot is used later for visualization.

The typing module imports Any, Dict, List, Optional, and Union. These type hints make the helper functions easier to understand and maintain.

The API setup starts with RAPIDAPI_HOST, which stores the RapidAPI host for the Indonesia Stock Exchange IDX API. BASE_URL is then created from that host.

The RAPIDAPI_KEY variable stores the API key. In a public article, the real API key should never be exposed, so it is replaced with a placeholder.

The notebook also provides an alternative approach using environment variables:

# RAPIDAPI_KEY = os.getenv("RAPIDAPI_KEY")

This is useful for safer credential management.

The validation block checks whether the API key is empty or still using the placeholder. If it is, the notebook raises a ValueError.

Finally, the HEADERS dictionary is created. It contains Content-Type, x-rapidapi-host, and x-rapidapi-key. These headers are used for every API request.

Cell 2 — Helper Function

def request_get(
    endpoint: str,
    params: Optional[Dict[str, Any]] = None,
    timeout: int = 30,
    max_retries: int = 5,
    wait_seconds: int = 3
) -> Union[Dict[str, Any], List[Any]]:

    url = f"{BASE_URL}{endpoint}"

    for attempt in range(1, max_retries + 1):
        time.sleep(wait_seconds)

        response = requests.get(
            url,
            headers=HEADERS,
            params=params,
            timeout=timeout
        )

        try:
            payload = response.json()
        except Exception:
            payload = response.text

        if response.ok:
            return payload

        if response.status_code == 429:
            print(
                f"Rate limit 429. Percobaan {attempt}/{max_retries}. "
                f"Tunggu {wait_seconds} detik..."
            )
            time.sleep(wait_seconds)
            continue

        raise RuntimeError(
            f"Request gagal. Status: {response.status_code}\n"
            f"URL: {response.url}\n"
            f"Response: {payload}"
        )

    raise RuntimeError(
        f"Request gagal karena rate limit 429 setelah {max_retries} percobaan."
    )


def find_first_list(obj: Any) -> List[Dict[str, Any]]:
    if isinstance(obj, list):
        return obj if all(isinstance(item, dict) for item in obj) else []

    if isinstance(obj, dict):
        priority_keys = [
            "data", "result", "results", "items", "rows",
            "content", "chart", "prices", "ohlcv", "candles"
        ]

        for key in priority_keys:
            if key in obj:
                found = find_first_list(obj[key])
                if found:
                    return found

        for value in obj.values():
            found = find_first_list(value)
            if found:
                return found

    return []


def json_to_dataframe(payload: Union[Dict[str, Any], List[Any]]) -> pd.DataFrame:
    records = find_first_list(payload)

    if records:
        return pd.json_normalize(records)

    if isinstance(payload, dict):
        return pd.json_normalize(payload)

    return pd.DataFrame()


def detect_column(df: pd.DataFrame, keywords: List[str]) -> Optional[str]:
    if df.empty:
        return None

    lower_map = {col.lower(): col for col in df.columns}

    for keyword in keywords:
        if keyword.lower() in lower_map:
            return lower_map[keyword.lower()]

    for col in df.columns:
        col_lower = col.lower()
        if any(keyword.lower() in col_lower for keyword in keywords):
            return col

    return None


def safe_series(df: pd.DataFrame, col: Optional[str], default_value=None) -> pd.Series:
    if col is None or col not in df.columns:
        return pd.Series([default_value] * len(df), index=df.index)

    return df[col]

Explanation

Cell 2 contains helper functions for requesting and processing API data.

The first function is request_get. It sends a GET request to an API endpoint. The function combines BASE_URL with the endpoint path, waits for a few seconds, and sends the request using requests.get.

This function also includes retry handling. If the API returns status code 429, which usually means rate limit, the notebook waits and tries again. The maximum retry attempt is set to 5.

If the request succeeds, the function returns the payload. If another error occurs, it raises a RuntimeError containing the response status, request URL, and payload.

The second function is find_first_list. It searches through nested JSON data and tries to find the first list of dictionaries. This is useful because OHLCV data may appear under different keys such as data, chart, prices, ohlcv, or candles.

The third function is json_to_dataframe. It converts a JSON payload into a pandas dataframe. If records are found, the function uses pd.json_normalize. If not, it normalizes the dictionary directly.

The fourth function is detect_column. It detects dataframe columns based on possible keyword names. This helps the notebook handle different response formats.

The final function is safe_series. It safely returns a dataframe column. If the column is missing, it returns a default-value series with the same index length.

Together, these helper functions make the IDX OHLCV price analysis workflow more flexible when handling API responses.

Cell 3 — Ambil Data dari Dua API

# Parameter utama
SYMBOL = "BBCA"
INTERVAL = "daily"

# Parameter getLatestOHLCV
LATEST_LIMIT = 100

# Parameter getOHLCV by date range
START_DATE = "2026-02-10"
END_DATE = "2026-02-18"
RANGE_LIMIT = 0


print("Mengambil data getLatestOHLCV...")
latest_ohlcv_payload = request_get(
    endpoint=f"/api/chart/{SYMBOL}/{INTERVAL}/latest",
    params={
        "limit": LATEST_LIMIT
    },
    wait_seconds=3
)

time.sleep(5)

print("Mengambil data getOHLCV...")
range_ohlcv_payload = request_get(
    endpoint=f"/api/chart/{SYMBOL}/{INTERVAL}",
    params={
        "limit": RANGE_LIMIT,
        "from": START_DATE,
        "to": END_DATE
    },
    wait_seconds=3
)


latest_ohlcv_df = json_to_dataframe(latest_ohlcv_payload)
range_ohlcv_df = json_to_dataframe(range_ohlcv_payload)

latest_ohlcv_df["source_api"] = "getLatestOHLCV"
range_ohlcv_df["source_api"] = "getOHLCV"

print("Latest OHLCV shape:", latest_ohlcv_df.shape)
print("Range OHLCV shape:", range_ohlcv_df.shape)

display(latest_ohlcv_df.head())
display(range_ohlcv_df.head())

print("Preview Latest OHLCV JSON:")
print(json.dumps(latest_ohlcv_payload, indent=2, ensure_ascii=False)[:1500])

print("\nPreview Range OHLCV JSON:")
print(json.dumps(range_ohlcv_payload, indent=2, ensure_ascii=False)[:1500])

Explanation

Cell 3 retrieves OHLCV data from two APIs.

The notebook first defines the main stock parameters:

SYMBOL = "BBCA"

INTERVAL = "daily"

This means the analysis focuses on BBCA daily OHLCV data.

For getLatestOHLCV, the notebook sets:

LATEST_LIMIT = 100

This means the notebook requests up to 100 latest OHLCV records.

For getOHLCV, the notebook defines a date range:

START_DATE = "2026-02-10"

END_DATE = "2026-02-18"

RANGE_LIMIT = 0

The first API request calls:

/api/chart/{SYMBOL}/{INTERVAL}/latest

This endpoint represents getLatestOHLCV.

After that, the notebook waits for 5 seconds using time.sleep(5) to reduce the risk of rate limits.

The second API request calls:

/api/chart/{SYMBOL}/{INTERVAL}

with parameters limit, from, and to. This endpoint represents getOHLCV by date range.

Both payloads are converted into dataframes using json_to_dataframe.

The resulting dataframes are:

latest_ohlcv_df

and

range_ohlcv_df

The notebook then adds a source_api column to each dataframe. This helps identify whether each row comes from getLatestOHLCV or getOHLCV.

At the end of the cell, the notebook prints the shape of each dataframe, displays sample rows, and prints raw JSON previews.

This cell is the main data collection step for the IDX OHLCV price analysis workflow.

Cell 4 — Cleaning Data OHLCV

# Deteksi kolom dari data latest
latest_date_col = detect_column(
    latest_ohlcv_df,
    ["date", "time", "timestamp", "datetime", "createdAt"]
)

latest_open_col = detect_column(
    latest_ohlcv_df,
    ["open", "o"]
)

latest_high_col = detect_column(
    latest_ohlcv_df,
    ["high", "h"]
)

latest_low_col = detect_column(
    latest_ohlcv_df,
    ["low", "l"]
)

latest_close_col = detect_column(
    latest_ohlcv_df,
    ["close", "c", "last", "price"]
)

latest_volume_col = detect_column(
    latest_ohlcv_df,
    ["volume", "vol", "v"]
)


# Deteksi kolom dari data range
range_date_col = detect_column(
    range_ohlcv_df,
    ["date", "time", "timestamp", "datetime", "createdAt"]
)

range_open_col = detect_column(
    range_ohlcv_df,
    ["open", "o"]
)

range_high_col = detect_column(
    range_ohlcv_df,
    ["high", "h"]
)

range_low_col = detect_column(
    range_ohlcv_df,
    ["low", "l"]
)

range_close_col = detect_column(
    range_ohlcv_df,
    ["close", "c", "last", "price"]
)

range_volume_col = detect_column(
    range_ohlcv_df,
    ["volume", "vol", "v"]
)


print("Detected Latest OHLCV columns:")
print("date   :", latest_date_col)
print("open   :", latest_open_col)
print("high   :", latest_high_col)
print("low    :", latest_low_col)
print("close  :", latest_close_col)
print("volume :", latest_volume_col)

print("\nDetected Range OHLCV columns:")
print("date   :", range_date_col)
print("open   :", range_open_col)
print("high   :", range_high_col)
print("low    :", range_low_col)
print("close  :", range_close_col)
print("volume :", range_volume_col)


latest_clean = pd.DataFrame({
    "symbol": SYMBOL,
    "date": safe_series(latest_ohlcv_df, latest_date_col, ""),
    "open": pd.to_numeric(safe_series(latest_ohlcv_df, latest_open_col, None), errors="coerce"),
    "high": pd.to_numeric(safe_series(latest_ohlcv_df, latest_high_col, None), errors="coerce"),
    "low": pd.to_numeric(safe_series(latest_ohlcv_df, latest_low_col, None), errors="coerce"),
    "close": pd.to_numeric(safe_series(latest_ohlcv_df, latest_close_col, None), errors="coerce"),
    "volume": pd.to_numeric(safe_series(latest_ohlcv_df, latest_volume_col, None), errors="coerce"),
    "source_api": "getLatestOHLCV"
})


range_clean = pd.DataFrame({
    "symbol": SYMBOL,
    "date": safe_series(range_ohlcv_df, range_date_col, ""),
    "open": pd.to_numeric(safe_series(range_ohlcv_df, range_open_col, None), errors="coerce"),
    "high": pd.to_numeric(safe_series(range_ohlcv_df, range_high_col, None), errors="coerce"),
    "low": pd.to_numeric(safe_series(range_ohlcv_df, range_low_col, None), errors="coerce"),
    "close": pd.to_numeric(safe_series(range_ohlcv_df, range_close_col, None), errors="coerce"),
    "volume": pd.to_numeric(safe_series(range_ohlcv_df, range_volume_col, None), errors="coerce"),
    "source_api": "getOHLCV"
})


latest_clean["date_parsed"] = pd.to_datetime(latest_clean["date"], errors="coerce")
range_clean["date_parsed"] = pd.to_datetime(range_clean["date"], errors="coerce")

latest_clean = latest_clean.dropna(subset=["date_parsed"]).copy()
range_clean = range_clean.dropna(subset=["date_parsed"]).copy()

display(latest_clean.head(20))
display(range_clean.head(20))

Explanation

Cell 4 cleans and standardizes the OHLCV data from both APIs.

The first part detects columns from latest_ohlcv_df. The notebook searches for date, open, high, low, close, and volume columns. Because API responses may use different names, the notebook checks several alternatives. For example, date may appear as date, time, timestamp, datetime, or createdAt.

The second part performs the same detection process for range_ohlcv_df.

After detecting columns, the notebook prints the detected field names for both datasets. This helps users verify whether the correct OHLCV columns were found.

Next, the notebook creates latest_clean. This dataframe standardizes latest OHLCV data into:

symbol

date

open

high

low

close

volume

source_api

Then, it creates range_clean with the same standardized structure.

Price and volume fields are converted into numeric format using pd.to_numeric. The date fields are converted into datetime format using pd.to_datetime.

Rows with invalid dates are removed using dropna.

At the end of the cell, the notebook displays the cleaned latest OHLCV data and cleaned range OHLCV data.

This cell prepares both datasets so they can be merged into one final OHLCV analysis dataset.

Cell 5 — Gabungkan Data dan Buat Analisis

project_output = pd.concat(
    [latest_clean, range_clean],
    ignore_index=True
)

project_output = project_output.sort_values(
    by=["date_parsed", "source_api"],
    ascending=[True, True]
).reset_index(drop=True)

# Hapus duplikasi tanggal jika data sama muncul dari dua API.
# Prioritas: data range getOHLCV dipakai lebih dulu jika tanggal sama.
project_output["source_priority"] = project_output["source_api"].map({
    "getOHLCV": 1,
    "getLatestOHLCV": 2
})

project_output = (
    project_output
    .sort_values(["date_parsed", "source_priority"])
    .drop_duplicates(subset=["symbol", "date_parsed"], keep="first")
    .sort_values("date_parsed")
    .reset_index(drop=True)
)

project_output["daily_return"] = project_output["close"].pct_change() * 100
project_output["price_range"] = project_output["high"] - project_output["low"]
project_output["body_size"] = (project_output["close"] - project_output["open"]).abs()
project_output["average_price"] = (
    project_output["open"] +
    project_output["high"] +
    project_output["low"] +
    project_output["close"]
) / 4

project_output["trend_status"] = project_output.apply(
    lambda row: "Bullish" if row["close"] > row["open"]
    else "Bearish" if row["close"] < row["open"]
    else "Neutral",
    axis=1
)

project_output["project_note"] = (
    "Data getLatestOHLCV dan getOHLCV digabungkan menjadi satu dataset historis OHLCV "
    "untuk analisis pergerakan harga saham."
)


summary_df = pd.DataFrame({
    "description": [
        "Symbol",
        "Interval",
        "Total data getLatestOHLCV",
        "Total data getOHLCV",
        "Total data setelah digabung dan deduplicate",
        "Tanggal awal final dataset",
        "Tanggal akhir final dataset",
        "Rata-rata close",
        "Rata-rata volume"
    ],
    "value": [
        SYMBOL,
        INTERVAL,
        len(latest_clean),
        len(range_clean),
        len(project_output),
        project_output["date_parsed"].min(),
        project_output["date_parsed"].max(),
        project_output["close"].mean(),
        project_output["volume"].mean()
    ]
})


display(project_output.head(30))
display(summary_df)

Explanation

Cell 5 merges both cleaned OHLCV datasets and creates the analysis metrics.

The notebook combines latest_clean and range_clean using pd.concat. The result is stored in project_output.

Then, the data is sorted by date_parsed and source_api.

Because the same date can appear in both getLatestOHLCV and getOHLCV, the notebook creates a source_priority column. The priority is:

getOHLCV = 1

getLatestOHLCV = 2

This means if duplicate dates exist, the row from getOHLCV is kept first. The notebook removes duplicates using drop_duplicates based on symbol and date_parsed.

After deduplication, the dataset is sorted again by date.

Next, the notebook calculates several analysis metrics.

daily_return is calculated from the percentage change of close price.

price_range is calculated from high price minus low price.

body_size is calculated from the absolute difference between close and open price.

average_price is calculated from open, high, low, and close divided by four.

The notebook also creates trend_status. If close is higher than open, the status is Bullish. If close is lower than open, the status is Bearish. If both are equal, the status is Neutral.

Then, a project_note is added to explain that getLatestOHLCV and getOHLCV are combined into one historical OHLCV dataset for stock price movement analysis.

Finally, the notebook creates summary_df, which contains key project metrics such as symbol, interval, total rows from each API, total rows after merging and deduplication, first and last date, average close price, and average volume.

This cell is the core of the IDX OHLCV price analysis workflow.

Cell 6 — Visualisasi dan Export

plt.figure(figsize=(12, 6))
plt.plot(project_output["date_parsed"], project_output["close"], marker="o")
plt.xlabel("Date")
plt.ylabel("Close Price")
plt.title(f"{SYMBOL} Close Price - Combined OHLCV")
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()


plt.figure(figsize=(12, 5))
plt.bar(project_output["date_parsed"], project_output["volume"])
plt.xlabel("Date")
plt.ylabel("Volume")
plt.title(f"{SYMBOL} Volume - Combined OHLCV")
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()


latest_clean.to_csv("idx_latest_ohlcv_clean.csv", index=False)
range_clean.to_csv("idx_range_ohlcv_clean.csv", index=False)
project_output.to_csv("idx_combined_ohlcv_project.csv", index=False)
summary_df.to_csv("idx_combined_ohlcv_summary.csv", index=False)

with pd.ExcelWriter("idx_combined_ohlcv_project.xlsx", engine="openpyxl") as writer:
    latest_ohlcv_df.to_excel(writer, sheet_name="latest_raw", index=False)
    range_ohlcv_df.to_excel(writer, sheet_name="range_raw", index=False)
    latest_clean.to_excel(writer, sheet_name="latest_clean", index=False)
    range_clean.to_excel(writer, sheet_name="range_clean", index=False)
    project_output.to_excel(writer, sheet_name="final_ohlcv", index=False)
    summary_df.to_excel(writer, sheet_name="summary", index=False)

print("Export selesai:")
print("- idx_latest_ohlcv_clean.csv")
print("- idx_range_ohlcv_clean.csv")
print("- idx_combined_ohlcv_project.csv")
print("- idx_combined_ohlcv_summary.csv")
print("- idx_combined_ohlcv_project.xlsx")

Explanation

Cell 6 visualizes and exports the final OHLCV analysis.

The first visualization shows the close price over time using a line chart. The X-axis represents date, while the Y-axis represents close price. The chart title is based on the selected stock symbol and combined OHLCV data.

The second visualization shows trading volume using a bar chart. This helps users compare price movement with volume activity.

After visualization, the notebook exports several CSV files:

idx_latest_ohlcv_clean.csv

idx_range_ohlcv_clean.csv

idx_combined_ohlcv_project.csv

idx_combined_ohlcv_summary.csv

Then, the notebook creates an Excel file:

idx_combined_ohlcv_project.xlsx

The Excel file contains multiple sheets:

latest_raw

range_raw

latest_clean

range_clean

final_ohlcv

summary

This makes the result easier to review, share, or continue in another analysis workflow.

Finally, the notebook prints all exported filenames.

Result:

resultresult 1

Overall Workflow

This notebook follows a structured IDX OHLCV price analysis workflow.

First, it prepares the API configuration and required Python libraries.

Second, it creates helper functions for API requests, retry handling, JSON parsing, dataframe conversion, column detection, and safe column selection.

Third, it retrieves the latest OHLCV data from getLatestOHLCV.

Fourth, it retrieves date-range OHLCV data from getOHLCV.

Fifth, it cleans and standardizes both datasets into the same column structure.

Sixth, it combines both datasets and removes duplicate dates.

Seventh, it calculates price movement metrics such as daily return, price range, candle body size, average price, and trend status.

Finally, it visualizes close price and volume, then exports the result into CSV and Excel files.

The final output is a clean OHLCV dataset that can support further stock price analysis.

Why This Case Matters

Many stock analysis projects fail not because of the charting method, but because the data layer is messy.

When OHLCV data comes from different endpoints, duplicate dates, inconsistent columns, or missing values can make the analysis unreliable. By combining getLatestOHLCV and getOHLCV, this notebook creates a cleaner and more useful data foundation.

An IDX OHLCV price analysis workflow helps users move beyond raw API responses. It turns latest data and historical range data into one structured dataset that can be used for trend review, volume comparison, return analysis, and further technical research.

This workflow does not create a buy or sell signal. Instead, it creates a clean foundation for data-driven stock analysis.

Conclusion

This tutorial shows how to build an IDX OHLCV price analysis workflow using Python, getLatestOHLCV, and getOHLCV.

The notebook retrieves latest OHLCV data and date-range OHLCV data, converts both API responses into dataframes, detects relevant columns, cleans both datasets, combines them, removes duplicate dates, and calculates useful price movement metrics.

The final output includes a clean OHLCV dataset, a summary table, close price visualization, volume visualization, CSV exports, and a multi-sheet Excel file.

For traders, investors, analysts, and data enthusiasts, this project can become a practical foundation for building stock dashboards, technical analysis tools, or historical price research workflows.

In the end, the project is not just about calling two APIs. It is about building a clean and reliable OHLCV dataset that supports better stock price analysis.