DEV Community

Cover image for Create a Market Profile Dashboard Using Python
Shridhar G Vatharkar
Shridhar G Vatharkar

Posted on

Create a Market Profile Dashboard Using Python

In this guide, you’ll learn how to build a Market Profile dashboard using Python. We’ll walk through how to use Streamlit, Plotly, and our Forex, CFD, and Crypto Data API to bring market data to life. Whether you're a trader, analyst, or developer, this tutorial will help you visualize key market structure components like Value Areas, Points of Control (POC), and TPO (Time Price Opportunity) Counts.

Here’s what you’ll learn:

  • How to retrieve minute-level market data using the TraderMade API
  • How to calculate Market Profile elements such as Value Areas and POC
  • How to display your analysis interactively using Streamlit and Plotly

What You’ll Need Before You Start

To follow along, you should have a basic understanding of Python, some familiarity with Market Profile concepts, and access to a TraderMade Forex API key.

Let’s Get Started

We’ve broken down this tutorial into clear, manageable steps to make it easy to follow. First, we’ll begin by setting up your environment.

Step 1: Setting Up Your Environment

Make sure the following tools and libraries are installed:

pip install streamlit plotly pandas numpy python-dateutil tradermade
Enter fullscreen mode Exit fullscreen mode

Getting Started

Begin by importing the necessary libraries and setting up the API connection.

import streamlit as st
import plotly.graph_objects as go
import pandas as pd
import numpy as np
import datetime
from dateutil.relativedelta import relativedelta
import streamlit.components.v1 as components
import tradermade as tm

tm.set_rest_api_key('Your_API_Key')  # Replace with your TraderMade API Key
Enter fullscreen mode Exit fullscreen mode

Retrieve Minute-Level Data Using the TraderMade API

In this step, we'll create a function to fetch the market data required for building our Market Profile.

def get_data_raw(currency_pair, start_time, end_time, interval='minute'):
    try:
        # Fetch data using TraderMade SDK
        data = tm.timeseries(
            currency=currency_pair,
            start=start_time,
            end=end_time,
            interval=interval,
            period=30,
            fields=["open", "high", "low", "close"]
        )
        # print(data)
        # Print the response to see its structure
        # print("API Response:", data)

        # Convert the response directly into a DataFrame
        df = pd.DataFrame(data)
        df['volume'] = df['close']
        # Convert the 'date' column to datetime if it exists
        df = df.replace(0, np.nan)
        df = df.dropna()
        df.set_index("date", inplace=True)
        df = np.round(df, 4)
        if 'date' in df.columns:
            df['date'] = pd.to_datetime(df['date'])
            df.set_index('date', inplace=True)

        # Sort the DataFrame by index
        df.sort_index(inplace=True)
        return df


    except KeyError as e:
        print(f"KeyError: {e}")
        return pd.DataFrame()  # Return an empty DataFrame in case of an error

    except Exception as e:
        print(f"An error occurred: {e}")
        return pd.DataFrame()  # Return an empty DataFrame in case of a general error

def get_data_MP(g, p, f, F, cur, st, ed):
    df = get_data_raw(cur, st, ed)
    return df
Enter fullscreen mode Exit fullscreen mode

Create Helper Functions

We’ll start by defining a function that identifies the most recent working day—this helps us skip non-trading days like weekends.

def last_work_date(date, format):
    if datetime.datetime.strptime(date, "%Y-%m-%d").weekday() == 6:
        return (datetime.datetime.strptime(date, "%Y-%m-%d")-relativedelta(days=2)).strftime(format)
    elif datetime.datetime.strptime(date, "%Y-%m-%d").weekday() == 0:
        return (datetime.datetime.strptime(date, "%Y-%m-%d")-relativedelta(days=3)).strftime(format)
    else:
        return (datetime.datetime.strptime(date, "%Y-%m-%d")-relativedelta(days=1)).strftime(format)
Enter fullscreen mode Exit fullscreen mode

Next, we'll define two additional functions to set the desired level of detail for our TPO and Market Profile views.

def get_rd(currency):
    od_list = ["UKOIL", "OIL", "XAGUSD","EVAUST","LUNUST",'LTCUST','XMRUST']
    od2_list = ["JPY"]
    od3_list = ["AAPL", "AMZN", "NFLX", "TSLA", "GOOGL", "BABA", "TWTR", "BAC", "BIDU", 
    'XAUUSD','SOLUST','BNBUST','BCHUST','DSHUST','EGLUST']
    od4_list = ["UK100", "SPX500","FRA40", "GER30","JPN225","NAS100","USA30", "HKG33", 
    "AUS200","BTC","BTCUST",'ETHUSD',"ETHUST"]
    cfd_list = ["EURNOK", "EURSEK", "USDSEK","USDNOK","NEOUST","ETCUST","DOTUST", "UNIUST"]
    if currency in od_list or currency[3:6] in od2_list:
        ad = .01
        rd = 2
    elif currency in od3_list:
        ad = 0.1
        rd = 1
    elif currency in od4_list:
        ad = 2
        rd = 0
    elif currency in cfd_list:
        ad = .001
        rd = 3
    else:
        ad = 0.0001
        rd = 4
    return rd, ad

def get_ad(ad, max, min):
    if (max-min) > ad*2000:
        return ad*50
    if (max-min) > ad*1000:
        return ad*20
    if (max-min) > ad*300:
        return ad*5
    elif (max-min) > ad*100:
        return ad*2
    else:
        return ad
Enter fullscreen mode Exit fullscreen mode

Compute TPO and Value Area

The first function, midmax_idx, helps identify the index of the highest value in an array that’s closest to its midpoint. The second function, calculate_value_area, builds a value area around the Point of Control (POC) within the market profile (mp).

It does this by accumulating TPO counts from price levels near the POC until the sum reaches a specified target volume (target_vol). The function then returns the price range—from min_idx to max_idx—that defines the value area. Note that this refers to TPO volume, which differs from actual trading volume.

def midmax_idx(array):
    if len(array) == 0:
        return None

    # Find candidate maxima
    # maxima_idxs = np.argwhere(array.to_numpy() == np.amax(array.to_numpy()))[:, 0]
    maxima_idxs = np.argwhere(array == np.amax(array))[:,0]
    if len(maxima_idxs) == 1:
        return maxima_idxs[0]
    elif len(maxima_idxs) <= 1:
        return None

    # Find the distances from the midpoint to find
    # the maxima with the least distance
    midpoint = len(array) / 2
    v_norm = np.vectorize(np.linalg.norm)
    maximum_idx = np.argmin(v_norm(maxima_idxs - midpoint))
    return maxima_idxs[maximum_idx]

def calculate_value_area(poc_volume, target_vol, poc_idx, mp):
    min_idx = poc_idx
    max_idx = poc_idx
    while poc_volume < target_vol:
        last_min = min_idx
        last_max = max_idx

        next_min_idx = np.clip(min_idx - 1, 0, len(mp) - 1)
        next_max_idx = np.clip(max_idx + 1, 0, len(mp) - 1)

        low_volume = mp.iloc[next_min_idx].vol if next_min_idx != last_min else None
        high_volume = mp.iloc[next_max_idx].vol if next_max_idx != last_max else None

        if not high_volume or (low_volume and low_volume > high_volume):
            poc_volume += low_volume
            min_idx = next_min_idx
        elif not low_volume or (high_volume and low_volume <= high_volume):
            poc_volume += high_volume
            max_idx = next_max_idx
        else:
            break
    return mp.iloc[min_idx].value, mp.iloc[max_idx].value

mp_dict = {"0000":"A","0030":"B","0100":"C","0130":"D","0200":"E","0230":"F","0300":"G","0330":"H","0400":"I","0430":"J","0500":"K","0530":"L","0600":"M","0630":"N","0700":"O","0730":"P","0800":"Q","0830":"R","0900":"S","0930":"T","1000":"U","1030":"V","1100":"W","1130":"X","1200":"a","1230":"b","1300":"c","1330":"d","1400":"e","1430":"f","1500":"g","1530":"h","1600":"i","1630":"j","1700":"k","1730":"l","1800":"m","1830":"n","1900":"o","1930":"p","2000":"q","2030":"r","2100":"s","2130":"t","2200":"u","2230":"v","2300":"w","2330":"x",}
Enter fullscreen mode Exit fullscreen mode

Bringing It All Together

To wrap things up, we’ll use the following function to generate the complete Market Profile.

def cal_mar_pro(currency, study, freq, period, mode, fp, mp_st, mp_ed, date):
    st = datetime.datetime.strptime(mp_st, "%Y-%m-%d-%H:%M")
    print(currency, mp_st, mp_ed)
    # try:
    rf = get_data_MP("M", str(freq), "%Y-%m-%d-%H:%M" , "%Y-%m-%d-%H:%M",currency, mp_st, mp_ed)
    hf = rf.copy()
    last_price = rf.iloc[-1]["close"]

    rf = rf[["high","low"]]
    rf.index = pd.to_datetime(rf.index)
    #rf = rf[:96]
    rd, ad = get_rd(currency)
    max = round(rf.high.max(), rd)
    min = round(rf.low.min(), rd)
    ad = get_ad(ad, max, min)
    try:
      x_data = np.round(np.arange(min, max, ad).tolist(), rd)
    except:
      print("MP loop failed", currency)
    # x_data = x_data[::-1]
    y_data= []
    z_data = []
    y_data1= []
    z_data1 = []
    tocount1 = 0
    for item in x_data:
        alpha = ""
        alpha1 = ""
        alpha2 = ""
        for i in range(len(rf)):
            if rf.index[i] < date:
                if round(rf.iloc[i]["high"], rd) >= item >= round(rf.iloc[i]["low"], rd):
                    alpha += mp_dict[rf.index[i].strftime("%H%M")]
            elif rf.index[i] >= date:
                if round(rf.iloc[i]["high"], rd) >= item >= round(rf.iloc[i]["low"], rd):
                    alpha1 += mp_dict[rf.index[i].strftime("%H%M")]
        tocount1 += len(alpha1)
        y_data.append(len(alpha))
        y_data1.append(len(alpha1))
        z_data.append(alpha)
        z_data1.append(alpha1)
    # y_data = y_data[::-1]
    # y_data1 = y_data1[::-1]
    mp = pd.DataFrame([x_data, y_data1]).T
    mp = mp[::-1]
    mp = mp.rename(columns={0:"value",1:"vol"})
    #poc_idx = midmax_idx(mp.vol)
    poc_idx = midmax_idx(mp.vol.values)
    poc_vol = mp.iloc[poc_idx].vol
    poc_price = mp.iloc[poc_idx].value
    target_vol = 0.7*tocount1


    value_high,value_low = calculate_value_area(poc_vol, target_vol, poc_idx, mp)
    print("Value area",tocount1, 0.7*tocount1)


    return x_data, y_data,y_data1, z_data, z_data1, value_high, value_low, poc_price, last_price, hf

Enter fullscreen mode Exit fullscreen mode

Now that we’ve set up the Market Profile data, it’s time to build the dashboard.

Streamlit Dashboard

We’ll structure the Streamlit app into two main sections: the sidebar and the main display area. The sidebar will handle user inputs and configuration options to make the dashboard interactive, while the main area will present the Market Profile charts and tables.

Sidebar

Let’s start by setting up the sidebar—it’s straightforward and intuitive.

# Streamlit code to take input
st.set_page_config(layout="wide")
st.title("Market Profile Dashboard")

# currency = st.sidebar.text_input("Enter Currency Pair (e.g., EURUSD):", "EURUSD")
category = st.sidebar.selectbox("Select Category", ["CFD", "Forex"], index=["CFD", "Forex"].index(st.session_state.category))

item_list = tm.cfd_list() if category == "CFD" else ["EURUSD", "GBPUSD", "AUDUSD", "USDCAD", "EURNOK", "USDJPY", "EURGBP", "BTCUSD", "USDCHF", "NZDUSD", "USDINR", "USDZAR", "ETHUSD", "EURSEK"]

# Ensure item_list is a list and contains the default currency
if isinstance(item_list, list) and st.session_state.currency in item_list:
    index = item_list.index(st.session_state.currency)
else:
    index = 0  # Default to the first item if the currency is not in the list

currency = st.sidebar.selectbox("Select an Item", item_list, index=index)
study = st.sidebar.selectbox("Select Study Type:", ["MP"])
date = st.sidebar.date_input("Select Date")

# Subtract one day using relativedelta to get the previous day at 00:00 hours
mp_st = last_work_date(date.strftime('%Y-%m-%d'), '%Y-%m-%d-00:00')
if date != datetime.datetime.now().date():
   mp_ed = date.strftime('%Y-%m-%d-23:59')
else:
   mp_ed = datetime.datetime.now().strftime('%Y-%m-%d-%H:%M')    

# Convert to string format
# mp_st = previous_day.strftime("%Y-%m-%d-%H:%M")
freq = st.sidebar.selectbox("Select Minutes per period:", [30])
period = st.sidebar.selectbox("Select periods:", [24])
mode = st.sidebar.selectbox("Select Mode:", ["tpo"])
fp = int((60/int(freq))*period)

if date.weekday() in [5, 6]:  # 5 = Saturday, 6 = Sunday
   st.warning("Please select a weekday (Monday to Friday). Weekends are not allowed.")
   st.stop()  # Stop the app execution if the date is a weekend

# Check if the button has been clicked using session state
if "button_clicked" not in st.session_state:
   st.session_state.button_clicked = False

# Create the Market Profile chart only when the button is clicked
if st.sidebar.button("Get Market Profile"):
   st.session_state.button_clicked = True
Enter fullscreen mode Exit fullscreen mode

Visualize with Streamlit & Plotly

Before we render the charts and TPO Profile, we’ll check if the user has clicked the button. Once confirmed, we’ll go ahead and display the visual output.

if st.session_state.button_clicked:
  #Get Market profile
  x_data, y_data,y_data1, z_data, z_data1, value_high, value_low, poc_price, last_price, hf = cal_mar_pro(currency, study, freq, period, mode, fp, mp_st,mp_ed, date)

  # Separate data for the previous day and current day
  previous_day_prices = x_data
  current_day_prices = x_data
  previous_day_counts = y_data
  current_day_counts = y_data1

  # Create the Market Profile chart for previous day
  candlestick = go.Candlestick(
      x=hf.index,
      open=hf['open'],
      high=hf['high'],
      low=hf['low'],
      close=hf['close'],
      name=f'{currency} Candlestick Chart',
  )
  tpo_dict = {}
  # Map index to date, using '00:00' hours formatting if necessary
  for i in range(len(hf.index)):
      tpo_dict[i] = hf.index[i]

  previous_day_dates = []
  current_day_dates = [] 

  for i in previous_day_counts:
      previous_day_dates.append(tpo_dict[i])
  for i in current_day_counts:
      current_day_dates.append(tpo_dict[i])

  # Create a vertical bar chart using the same x-axis (dates) and y-axis (volume)
  bar_chart = go.Bar(
      x=previous_day_dates,  # Use the same dates for x-axis
      y=previous_day_prices,  # Volume data for y-axis
      orientation='h',
      name="TPO Count previous day",
      marker=dict(
          color='rgba(50, 171, 96, 0.6)',
          line=dict(color='rgba(50, 171, 96, 1.0)', width=1)
      ),
      opacity=0.5,  # Adjust opacity to make both charts visible
      hoverinfo='skip'
  )

  bar_chart2 = go.Bar(
          x=current_day_dates,  # Use the same dates for x-axis
          y=current_day_prices,  # Volume data for y-axis
          orientation='h',
          name=f"TPO Count {date}",
          marker=dict(
          color='rgba(100, 149, 237, 0.6)',
          line=dict(color='rgba(100, 149, 237, 1.0)', width=1),
      ),
      opacity=0.5,  # Adjust opacity to make both charts visible
      hoverinfo='skip'
  )

  # Initialize the figure and add both traces
  fig1 = go.Figure()
  fig1.add_trace(candlestick)
  fig1.add_trace(bar_chart)
  fig1.add_trace(bar_chart2)
  print(y_data[0],y_data[-1:], poc_price)
  num_ticks = 6  # Number of ticks to display
  tick_indices = np.linspace(0, len(hf.index) - 1, num_ticks, dtype=int)
  tickvals = [hf.index[i] for i in tick_indices]
  ticktext = [hf.index[i] for i in tick_indices]

  # Update x-axis to show fewer date labels
  fig1.update_xaxes(
      tickvals=tickvals,  # Specify which dates to show
      ticktext=ticktext,  # Specify the labels for those dates
      tickangle=0,  # Keep the tick labels horizontal
      title_text='Date',
      showgrid=True,
  )
  fig1.update_yaxes(
      showgrid=True,
  )

  # Update layout for the combined chart
  fig1.update_layout(
      title=f'{currency} Candlestick Chart with TPO Bars',
      xaxis_title='Date/TPO Count',
      yaxis_title='Price',
      yaxis=dict(range=[x_data[0], x_data[-1]]),  # Reverse the y-axis
      height=600,
      xaxis=dict(
          type='category',  # Use 'category' type to skip missing dates
          categoryorder='category ascending',  # Order categories in ascending order
          showgrid=True,
      ),
      xaxis_rangeslider_visible=False  # Hide the range slider
  )

  # Create the Market Profile chart for current day
  fig2 = go.Figure()
  fig2.add_trace(go.Bar(
      x=previous_day_counts,  # Count on the x-axis
      y=previous_day_prices,  # Price levels on the y-axis
      orientation='h',
      name=f"TPO Count Previous day",
      marker=dict(
          color='rgba(50, 171, 96, 0.6)',
          line=dict(color='rgba(50, 171, 96, 1.0)', width=1)
      ),
      opacity=0.5
  ))

  fig2.add_trace(go.Bar(
      x=current_day_counts,  # Count on the x-axis
      y=current_day_prices,  # Price levels on the y-axis
      orientation='h',
      name=f"TPO Count {date}",
      marker=dict(
          color='rgba(100, 149, 237, 0.6)',
          line=dict(color='rgba(100, 149, 237, 1.0)', width=1),
      ),
  ))
  fig2.update_xaxes(
      showgrid=True,
  )
  fig2.update_yaxes(
      showgrid=True,
  )
  fig2.update_layout(
      title=f"{currency} Two-day Market Profile",
      xaxis_title="TPO Count",
      yaxis_title="Price Levels",
      template="plotly_white",
      height=600,
      width=600
  )

  # Create two columns to display charts side by side
  col1, col2 = st.columns([1, 1])

  with col1:
      st.plotly_chart(fig1, use_container_width=True)  # Adjusts the chart to fit the column width

  with col2:
      st.plotly_chart(fig2, use_container_width=True)

  style = """
  <style>
  table {
      width: 100%;
      border-collapse: collapse;
      font-family: 'Courier New', Courier, monospace;  /* Monospaced font */
  }
  th, td {
      border: 1px solid #ddd;
      padding: 8px;
      text-align: left;
      white-space: nowrap;  /* Ensure no text wrapping */
      font-weight: bold;
  }
  th {
      background-color: #f2f2f2;
      font-weight: bold;
  }
  tr:nth-child(even) {
      background-color: #f9f9f9;  /* Light gray background for even rows */
  }
  tr:hover {
      background-color: #f1f1f1;  /* Highlight row on hover */
  }
  </style>"""

  html_table = style
  html_table += "<table style='width:100%; border: 1px solid black; font-size: 12px; border-collapse: collapse;font-family: 'Courier New', Courier, monospace;'>"
  html_table += "<tr><th style='border: 1px solid black; padding: 2px;'>Prices</th>"
  html_table += "<th style='border: 1px solid black;padding: 2px;'>Previous Day</th>"
  html_table += f"<th style='border: 1px solid black;padding: 2px;'>{date}</th></tr>"

  # Populate the table rows
  for x, z, z1 in zip(reversed(x_data), reversed(z_data), reversed(z_data1)):
      if x in [value_low, poc_price, value_high]:
          html_table += f"<tr><td style='color:#FF6961; border: 1px solid black; padding: 0px;'>{x}</td>"
          html_table += f"<td style='color:#FF6961; border: 1px solid black; padding: 0px;'>{z}</td>"
          html_table += f"<td style='color:#FF6961; border: 1px solid black; padding: 0px;'>{z1}</td></tr>"
      else:       
          html_table += f"<tr><td style='border: 1px solid black; padding: 0px;'>{x}</td>"
          html_table += f"<td style='border: 1px solid black; padding: 0px;'>{z}</td>"
          html_table += f"<td style='border: 1px solid black; padding: 0px;'>{z1}</td></tr>"

  html_table += "</table>"

  html_table2 = style
  html_table2 += f"""
  <table style='width:100%; border: 1px solid black; border-collapse: collapse; font-size: 10px;'>
      <tr>
          <th style='border: 1px solid black; padding: 2px;'>Letter</th>
          <th style='border: 1px solid black; padding: 2px;'>Period</th>
      </tr>
  """

  # Populate the second table rows in reverse order or different formatting
  for letter, period in mp_dict.items():
      html_table2 += f"""
      <tr>
          <td style='border: 1px solid black; padding: 2px;'>{letter}</td>
          <td style='border: 1px solid black; padding: 2px;'>{period}</td>
      </tr>
      """

  html_table2 += f"</table>"

  html_table3 = style
  html_table3 += f"""
  <table style='width:100%; border: 1px solid black; border-collapse: collapse; font-size: 10px;'>
      <tr>
          <th style='border: 1px solid black; padding: 2px;'>Key Prices</th>
          <th style='border: 1px solid black; padding: 2px;'>Values</th>
      </tr>
  """

  html_table3 += f"""
      <tr>
          <td style='border: 1px solid black; padding: 2px;'>Value Area High</td>
          <td style='border: 1px solid black; padding: 2px;'>{value_high}</td>
      </tr>
      <tr>
          <td style='border: 1px solid black; padding: 2px;'>Point of Control (POC)</td>
          <td style='border: 1px solid black; padding: 2px;'>{poc_price}</td>
      </tr>
      <tr>
          <td style='border: 1px solid black; padding: 2px;'>Value Area Low</td>
          <td style='border: 1px solid black; padding: 2px;'>{value_low}</td>
      </tr>
      </table>
      """
  col3, col4 = st.columns([2, 2])
  with col1:
      components.html(html_table, height=2000, scrolling=True)
  with col2:
      components.html(html_table3,height=90, scrolling=True)
      components.html(html_table2, height=1200, scrolling=True)
Enter fullscreen mode Exit fullscreen mode

The final section of the code is somewhat lengthy, but it primarily focuses on displaying the data using HTML tables and Plotly charts. If you're interested in customizing or learning more, you can explore Plotly and HTML documentation. For now, feel free to copy and paste this part as is.

Wrapping Up

You now have a fully functional and interactive Market Profile dashboard that works with Forex, CFD, and Crypto data. In just a few minutes, you can analyze different currency pairs, timeframes, and market structures.

To view the complete code, head over to our GitHub Market Profile examples page.

You can also enhance your dashboard by:

  • Experimenting with different time intervals, such as hourly or daily
  • Incorporating Volume Profile analysis
  • Adding alerts or triggers based on your trading strategies

Please go through the original tutorial on our website:
Build a Market Profile Dashboard with Python

Top comments (0)