Build a Soccer Betting Signals Dashboard

Build a Streamlit dashboard for live soccer fixtures with Poisson signals.

Introduction

30 Second Summary

A high confidence percentage can look decisive when you are scanning soccer fixtures. Without seeing the data behind it, that number is easy to trust too quickly.

In this project, you will build a browser-based soccer analytics dashboard with Python plus Streamlit. It will turn live API-Football fixture history into ranked 1X2 markets plus Over/Under 2.5 markets signals using a transparent Poisson model.

What You'll Build

Your browser will show upcoming matches ranked by their strongest signal alongside model details you can inspect.

By the end of this project, you'll have:

  • A live four-league board that loads upcoming matches with real team names plus UTC kickoff times.
  • Ranked probability signals spanning the full 1X2 market plus both sides of the 2.5-goal total.
  • A model-details view showing expected goals plus recent-match sample sizes behind every eligible recommendation.
  • Secret Mission: A stability review across lookbacks of 3, 5, plus 10 matches that produces an exclusion rule for unstable recommendations.

Are there any prerequisites?

You only need a Windows computer with internet access. Every tool plus the free API-Football account is set up inside the project.

Before We Start

Before the hands-on work begins, commit to the dashboard's purpose. This educational probability dashboard analyzes 1X2 and Over/Under 2.5 markets through uncertain model probabilities without guaranteeing profit.

Set Up the Windows Project

Your dashboard will eventually combine live soccer data with a probability model. First, it needs a dependable home on your Windows computer.

A clean project environment prevents its packages from affecting software elsewhere on your computer. Exact package versions make the setup reproducible.

A ready editor gives you one place to build the dashboard. Protected API access prepares the project for live data later.

In this step, get ready to:
  • Install Python 3.14.7 plus Visual Studio Code with its Microsoft Python extension.
  • Create an isolated environment for Streamlit 1.63.0 plus Requests 2.34.2.
  • Create protected API-Football access before running the Streamlit example app.
Install and verify Windows tools

Python runs the dashboard code. The project uses version 3.14.7 so every package starts from the same runtime.

  • Press the Windows key to open Windows search.
  • Type PowerShell and press Enter to open it.
  • Check your installed Python version by running this command:
python --version

What does this command do?

PowerShell asks the active Python installation for its version. The result determines whether you can continue or need the current runtime first.

✔️ I see version 3.14.7

Python returned Python 3.14.7. Your runtime is ready for the pinned dashboard packages.

ⓧ I see an older version

The older runtime does not match this project's pinned version. Install the current runtime before creating the project environment.

  • Visit the official Python Windows page.
  • Use the Python Install Manager to install Python 3.14.7.
  • Close the current PowerShell window after installation.
  • Reopen PowerShell through Windows search.
  • Repeat the version check from above. You should now see Python 3.14.7.

ⓧ Command not found

Windows cannot find a Python installation yet. The official install manager adds the current stable runtime.

  • Visit the official Python Windows page.
  • Install the Python Install Manager by following the Windows instructions.
  • Use the manager to install Python 3.14.7.
  • Reopen PowerShell through Windows search.
  • Repeat the version check from above. You should now see Python 3.14.7.

Your Python runtime is ready. The next tool gives you an editor for the project files plus an integrated PowerShell terminal.

  • Download the Visual Studio Code User Setup from the official Windows setup guide.
  • Run the downloaded User Setup installer.
  • Complete the guided installation with its default options.

Visual Studio Code is now installed. Add Python language support before creating the environment.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code and press Enter to open it.
  • Select the Extensions icon in the left Activity Bar.
  • Search for Microsoft Python in the Extensions panel.
  • Select the Microsoft Python extension from the results.
  • Select Install on the extension page.

The extension page now shows that the Microsoft Python extension is installed. Visual Studio Code can detect the environment you create next.

Tools not showing up?

  • Restart PowerShell after installing Python so the new runtime can be detected.
  • Restart Visual Studio Code if the extension remains unavailable after installation.

Still stuck? Help me troubleshoot my Python or Visual Studio Code installation on Windows.

Create the project environment

A virtual environment isolates this dashboard's packages inside its own folder. The requirements file records the exact package versions needed to reproduce it.

  • Select Terminal in the Visual Studio Code top menu.
  • Select New Terminal to open PowerShell.
  • Create the soccer-betting-signals folder on your Desktop by running these commands:
cd $HOME\Desktop
mkdir soccer-betting-signals
cd soccer-betting-signals

What do these commands do?

  • The first command moves PowerShell to your Windows Desktop.
  • The second command creates the soccer-betting-signals folder.
  • The final command moves PowerShell into that new folder.
  • Select File in the Visual Studio Code top menu.
  • Select Open Folder.
  • Select the soccer-betting-signals folder on your Desktop.
  • Select Select Folder to load it into Visual Studio Code.

The Explorer sidebar now shows the empty soccer-betting-signals folder. This is the single location for every project artifact.

Cannot find the project folder?

Check the PowerShell prompt for the soccer-betting-signals folder name. If the folder already existed, select that existing Desktop folder in Visual Studio Code.

Need help locating it? Help me find the project folder I created with PowerShell.

  • Select Terminal in the Visual Studio Code top menu.
  • Select New Terminal to open PowerShell inside soccer-betting-signals.
  • Create the local .venv environment by running this command:
python -m venv .venv

What does this command do?

Python creates an isolated environment in the .venv folder. Packages installed after activation stay attached to this project.

  • Confirm that .venv appears in the Visual Studio Code Explorer sidebar.

Missing the virtual environment?

  • Confirm the terminal path ends with soccer-betting-signals before creating the environment.
  • Confirm the Python version check still returns version 3.14.7.

Still missing it? Help me create the .venv environment in my Windows project folder.

  • Activate the virtual environment by running this command:
.venv\Scripts\Activate.ps1

What does activation change?

Activation points Python package commands at the local .venv environment. Your terminal prompt displays the environment name while it is active.

You should now see the environment name at the start of the PowerShell prompt. Future package installations stay inside this project.

  • Press Ctrl+Shift+P to open the Visual Studio Code Command Palette.
  • Type Python: Select Interpreter.
  • Select the interpreter marked .venv from the results.

Activation blocked by PowerShell?

Confirm that the terminal is using PowerShell inside soccer-betting-signals. Confirm that the activation path matches the .venv folder in the Explorer sidebar.

Need help with the activation error? Help me activate my .venv environment in Windows PowerShell.

  • Select the New File control in the Visual Studio Code Explorer sidebar.
  • Name the file requirements.txt.
  • Pin the dashboard dependencies by pasting this content into requirements.txt:
streamlit==1.63.0
requests==2.34.2

What does this file do?

  • The streamlit==1.63.0 line pins the dashboard framework to one exact release.
  • The requests==2.34.2 line pins the library that later sends API requests.
  • Exact pins keep the package versions consistent across future installations.
  • Select File in the Visual Studio Code top menu.
  • Select Save to store requirements.txt.
  • Install both pinned packages into the active environment by running this command:
pip install -r requirements.txt

What does this command do?

The command reads each exact requirement from requirements.txt. It installs Streamlit 1.63.0 plus Requests 2.34.2 inside the active .venv environment.

The installation finishes when PowerShell returns to its prompt without a package installation error. Your local environment now contains both pinned dependencies.

Package installation failed?

  • Confirm the PowerShell prompt shows the active environment name before installing packages.
  • Confirm that requirements.txt contains both lines exactly as shown.
  • Confirm your internet connection can reach the Python package registry.

Still seeing an installation problem? Help me diagnose my pinned package installation error.

✔️ Awesome, I've got everything!

Great. Double-check that requirements.txt is saved in the soccer-betting-signals folder.

ⓧ I'd like to double check the full code

streamlit==1.63.0
requests==2.34.2

What should match?

Your file should contain these two exact pins in this order. A final newline follows the Requests requirement.

Secure API access and test Streamlit

API-Football supplies the live soccer data used later in the project. Its free plan includes 100 requests per day without requiring a credit card.

Protect your API key

Your API key grants access to your account quota. Store it in a secure credential manager instead of a project file.

The dashboard later accepts the key through a password input. This keeps the credential out of app.py plus requirements.txt.

  • Visit the API-Football pricing page.
  • Create an API-Football account on the Free plan.
  • Complete the account prompts.
  • Sign in to the API-Football dashboard.
  • Locate the API key in the current dashboard controls.
  • Copy the API key into a secure credential manager.
  • Leave the API key out of every project file.

The Streamlit example app is the final environment check. It proves that the active Python runtime can import the pinned package plus start a local web app.

Before you run the final check, what do you expect the browser to show?

  • Prove the active environment can run Streamlit by running this command:
python -m streamlit hello

What does this command do?

The python -m form runs Streamlit through the active Python environment. Streamlit starts its example app in your browser.

The PowerShell terminal remains occupied while the local example server runs. You can stop it with Ctrl+C after capturing the result.

You'll see the Streamlit example app open in your browser. This confirms that Python 3.14.7 can run the installed Streamlit package from .venv.

Example app did not open?

  • Confirm the PowerShell prompt shows the active environment name.
  • Confirm the earlier package installation finished without an error.
  • Check whether your browser opened the example app in another tab or window.

Still blocked? Help me troubleshoot why the Streamlit example app does not open.

That is the setup hurdle cleared. Your pinned environment can now serve a Streamlit app in the browser.

  • Return to the PowerShell terminal in Visual Studio Code.
  • Press Ctrl+C to stop the example server.

Your Windows workspace is ready for real project code. Next, you'll turn this clean environment into the first visible dashboard shell.

Build the Dashboard Shell

Your Windows development setup is ready. The Streamlit example app proved that your activated environment can serve a browser interface.

Live fixture data from API-Football introduces several moving parts. A sample-data shell lets you shape the dashboard first. You can confirm the interface before live data enters the flow.

In this step, get ready to:
  • Create a wide dashboard frame for educational probability signals.
  • Add the complete sidebar control panel.
  • Display summary cards with sample recommendation tables.
Create the dashboard frame

A Streamlit app starts as a Python script. The first valid version sets the browser layout. It also gives the dashboard a clear purpose.

  • Create app.py inside the project folder from earlier by selecting the new file icon in the VS Code Explorer sidebar.

Good start. app.py is now the home for your dashboard interface.

  • Build the wide page header by pasting this code into app.py:
import streamlit as st

LEAGUES = {
    "Premier League": 39,
    "La Liga": 140,
    "Serie A": 135,
    "Ligue 1": 61,
}

st.set_page_config(page_title="Soccer Betting Signals", layout="wide")
st.title("Soccer Betting Signals Dashboard")
st.write(
    "Educational probability signals only. This dashboard does not place bets, "
    "use bookmaker prices, or guarantee profit."
)

What does this code do?

  • The LEAGUES dictionary connects each competition name to its stable league identifier.
  • The page configuration gives the dashboard room for wide tables.
  • The educational-use notice defines the dashboard as an analysis tool. It makes clear that probability never guarantees an outcome.
  • Save app.py.
  • Start the dashboard from the activated PowerShell terminal by running:
python -m streamlit run app.py

What does this command do?

This starts the Streamlit development server for app.py. The server opens your local dashboard in the browser.

Your first dashboard view is live. You can see the wide page title with the educational-use notice directly underneath it.

Dashboard not opening?

  • Confirm that the PowerShell prompt still shows the active .venv environment.
  • Confirm that app.py is saved inside the project folder already open in VS Code.

Still stuck? Help me troubleshoot why my Streamlit app is not opening from the activated Windows environment.

The page now has its identity. The sidebar gives every model setting a consistent place without crowding the recommendation tables.

  • Add the sidebar controls below the educational-use notice by pasting:
with st.sidebar:
    st.write("Dashboard controls")
    api_key = st.text_input("API-Football key", type="password")
    selected_leagues = st.multiselect(
        "Leagues",
        list(LEAGUES),
        default=list(LEAGUES),
    )
    upcoming_count = st.slider("Games per league", 1, 5, 3)
    lookback = st.slider("Recent matches per team", 3, 10, 5)
    minimum_confidence = st.slider("Minimum confidence", 50, 80, 55)

How do the controls work?

  • The password input masks the API key in the browser. The key stays out of app.py.
  • The league selector starts with all four competitions selected.
  • The sliders control the fixture count. They also control the recent-form window plus the confidence threshold.
  • Save app.py.
  • Refresh the dashboard in your browser.

The sidebar now shows the masked API key field with the league selector. You can also see the three model sliders.

Sidebar controls missing?

  • Check that the entire sidebar section sits below the closing parenthesis of the educational-use notice.
  • Check that every line inside the sidebar block uses four spaces of indentation.

Need another pair of eyes? Help me find why my Streamlit sidebar controls are not appearing.

Add KPI cards with a ranked sample

A KPI turns a larger dataset into one quick status value. Sample recommendations give the cards stable values while you shape the interface.

  • Add the sample recommendation board below the sidebar block by pasting:
sample_rows = [
    {
        "League": "Premier League",
        "Kickoff (UTC)": "Sample fixture",
        "Match": "Home Team vs Away Team",
        "1X2 signal": "Home Team win",
        "1X2 confidence": "58.0%",
        "Totals signal": "Over 2.5",
        "Totals confidence": "61.0%",
        "Strongest signal": "Over 2.5",
    }
]

kpi_columns = st.columns(4, border=True)
kpi_columns[0].metric("Leagues loaded", 4)
kpi_columns[1].metric("Games modeled", 1)
kpi_columns[2].metric("Strongest signal", "61.0%")
kpi_columns[3].metric("Average expected goals", "2.60")

ranked_tab, details_tab = st.tabs(["Ranked signals", "Model details"])

with ranked_tab:
    st.dataframe(sample_rows, width="stretch", hide_index=True)

What does this code do?

  • The sample_rows list stores one predictable recommendation for the shell.
  • The four bordered columns summarize league coverage. They also summarize modeled games plus the strongest probability and expected goals.
  • The st.dataframe table stretches across its tab. It hides the generated row index so the recommendation columns stay prominent.
  • Save app.py.
  • Refresh the dashboard in your browser.

You can now see four bordered summary cards. The Ranked signals tab shows one sample match across the interactive table.

That sample row makes the shell feel like a working analysis board before any API request enters the project.

Cards or sample row missing?

  • Check that the KPI section starts after the closing bracket of sample_rows.
  • Check that the table line remains indented inside the Ranked signals tab block.

Need help with the layout? Help me debug why my Streamlit KPI cards or ranked sample table are missing.

Fill the model details tab

A recommendation is easier to evaluate when its inputs are visible. The details view exposes expected goals with the number of recent matches represented by each sample.

  • Insert the sample model details directly above the kpi_columns line by pasting:
sample_details = [
    {
        "League": "Premier League",
        "Match": "Home Team vs Away Team",
        "Home expected goals": 1.5,
        "Away expected goals": 1.1,
        "Home sample": 5,
        "Away sample": 5,
        "Status": "Sample data",
    }
]

What do these details represent?

  • The expected-goals values represent the model inputs for each team.
  • The sample values show how many recent completed matches contributed to those inputs.
  • The status makes sample data easy to distinguish from live modeled results.
  • Add the details table below the Ranked signals tab block by pasting:
with details_tab:
    st.dataframe(sample_details, width="stretch", hide_index=True)

How does the second tab help?

The Model details tab separates model inputs from recommendations. This keeps the ranked board readable while preserving the evidence behind each signal.

  • Save app.py.
  • Switch back to the PowerShell terminal from earlier.
  • Stop the current Streamlit process by pressing Ctrl+C.

Before you restart the dashboard, which interface elements do you expect to appear together?

  • Restart the completed dashboard shell by running:
python -m streamlit run app.py

Why restart the dashboard?

The fresh process reads the saved version of app.py from the beginning. This verifies that the complete shell runs without relying on an earlier browser state.

You'll see the wide Soccer Betting Signals Dashboard with its educational-use notice. The sidebar shows the masked API key field plus the four filters.

Four bordered KPI cards sit above the Ranked signals and Model details tabs. Each tab contains a sample table with sortable columns.

That is the shell working: every visible area is ready for live fixture data.

Final shell not loading?

  • Check the terminal for a line number that points to an indentation or bracket mismatch in app.py.
  • Compare the order of sample_rows with sample_details before checking the KPI section.
  • Confirm that each dataframe line sits inside its matching tab block.

Still seeing a problem? Help me compare my app.py with the completed Streamlit dashboard shell.

✔️ Awesome, I've got everything!

Your dashboard shell has the sidebar controls, KPI cards, recommendation table, and model details table in place.

ⓧ I'd like to double check the full code

  • Compare app.py with the complete dashboard shell below.
import streamlit as st

LEAGUES = {
    "Premier League": 39,
    "La Liga": 140,
    "Serie A": 135,
    "Ligue 1": 61,
}

st.set_page_config(page_title="Soccer Betting Signals", layout="wide")
st.title("Soccer Betting Signals Dashboard")
st.write(
    "Educational probability signals only. This dashboard does not place bets, "
    "use bookmaker prices, or guarantee profit."
)

with st.sidebar:
    st.write("Dashboard controls")
    api_key = st.text_input("API-Football key", type="password")
    selected_leagues = st.multiselect(
        "Leagues",
        list(LEAGUES),
        default=list(LEAGUES),
    )
    upcoming_count = st.slider("Games per league", 1, 5, 3)
    lookback = st.slider("Recent matches per team", 3, 10, 5)
    minimum_confidence = st.slider("Minimum confidence", 50, 80, 55)

sample_rows = [
    {
        "League": "Premier League",
        "Kickoff (UTC)": "Sample fixture",
        "Match": "Home Team vs Away Team",
        "1X2 signal": "Home Team win",
        "1X2 confidence": "58.0%",
        "Totals signal": "Over 2.5",
        "Totals confidence": "61.0%",
        "Strongest signal": "Over 2.5",
    }
]
sample_details = [
    {
        "League": "Premier League",
        "Match": "Home Team vs Away Team",
        "Home expected goals": 1.5,
        "Away expected goals": 1.1,
        "Home sample": 5,
        "Away sample": 5,
        "Status": "Sample data",
    }
]

kpi_columns = st.columns(4, border=True)
kpi_columns[0].metric("Leagues loaded", 4)
kpi_columns[1].metric("Games modeled", 1)
kpi_columns[2].metric("Strongest signal", "61.0%")
kpi_columns[3].metric("Average expected goals", "2.60")

ranked_tab, details_tab = st.tabs(["Ranked signals", "Model details"])

with ranked_tab:
    st.dataframe(sample_rows, width="stretch", hide_index=True)

with details_tab:
    st.dataframe(sample_details, width="stretch", hide_index=True)

What should match?

The full file keeps the page frame first. It places the sidebar before the sample data. The cards and both table tabs come last.

Keep the key private

The API key field uses password masking. Leave the field empty for your screenshot so no credential appears in your project evidence.

Your interface is ready to demonstrate with safe sample data. Next, you'll replace that sample fixture with current league schedules from API-Football.

Load Live Fixtures

Your Streamlit shell already turns sample rows into a working dashboard layout. You can adjust its controls and inspect its tables in the browser.

Sample data cannot update with the soccer calendar or prove that your data pipeline works. This step connects the dashboard to API-Football so each selected league loads its current season and upcoming fixtures.

In this step, get ready to:
  • Create a cached request layer that authenticates with your password-only API key.
  • Load current seasons and full fixture schedules for the selected leagues.
  • Replace the sample rows with upcoming matches from live data.
Create the authenticated request layer

A REST API lets your dashboard request structured data from another service. Requests sends the request. The response arrives as JSON that Python can turn into dictionaries and lists.

  • In app.py, replace the existing import and league block by pasting this code:
from datetime import datetime, timezone

import requests
import streamlit as st

BASE_URL = "https://v3.football.api-sports.io"
LEAGUES = {
    "Premier League": 39,
    "La Liga": 140,
    "Serie A": 135,
    "Ligue 1": 61,
}

What does this setup define?

  • The datetime imports let the app compare fixture times against the current UTC time.
  • The Requests import gives the app an HTTP client for API calls.
  • The base URL points every request at the API-Football service.
  • The league dictionary maps each sidebar name to its stable API-Football ID.
  • Save app.py.
  • Add the cached request function directly below the LEAGUES dictionary by pasting this code:
@st.cache_data(ttl=3600, show_spinner=False, scope="session")
def api_get(path, params_items, api_key):
    response = requests.get(
        f"{BASE_URL}/{path}",
        params=dict(params_items),
        headers={"x-apisports-key": api_key},
        timeout=20,
    )
    response.raise_for_status()
    payload = response.json()

    if payload.get("errors"):
        raise RuntimeError(f"API-Football error: {payload['errors']}")

    return payload.get("response", [])

What does the request function do?

  • The session cache keeps matching responses for one hour within the current browser session.
  • The authentication header sends the API key entered through the password field.
  • The timeout stops a request after 20 seconds without server data.
  • The response checks surface HTTP failures and API-reported problems before returning data.
  • Save app.py.
  • Confirm that the expanded file still compiles by running the dashboard:
python -m streamlit run app.py

What does this command check?

The command starts app.py through Streamlit. A normal dashboard load confirms that Python can import Requests and define the new cached function.

Good progress. Your dashboard shell still loads with its four cards and two tabs, which confirms the new request layer compiles cleanly.

  • Add the request wrapper below api_get by pasting this code:
def call_api(path, params, api_key):
    params_items = tuple(sorted((str(key), str(value)) for key, value in params.items()))
    return api_get(path, params_items, api_key)

Why normalize the parameters?

The wrapper converts each parameter into a sorted tuple. Identical requests therefore receive an identical cache key, even when their original dictionaries were assembled in a different order.

  • Save app.py.
  • Confirm that the browser reloads the dashboard without a Python traceback.

Dashboard stopped loading?

  • Check that the imports sit above BASE_URL.
  • Check that both request functions sit below the closing brace of LEAGUES.
  • Confirm that every indented line uses the same amount of spacing.

Still stuck? Help me debug the API request layer in my Streamlit app.

Load current seasons and schedules

League IDs remain stable across seasons, but the active season year changes. The dashboard first asks for current seasons so every fixtures request uses the right year.

  • Add the current-season lookup below call_api by pasting this function:
def current_seasons(api_key):
    seasons = {}
    for item in call_api("leagues", {"current": "true"}, api_key):
        league_id = item["league"]["id"]
        current_years = [
            season["year"]
            for season in item.get("seasons", [])
            if season.get("current") is True
        ]
        if current_years:
            seasons[league_id] = max(current_years)
    return seasons

How does the season lookup work?

  • The function requests leagues whose season metadata is currently active.
  • The list comprehension keeps years marked as current.
  • The returned dictionary connects each league ID to its latest active year.
  • Save app.py.
  • Add the fixture request and kickoff parser below current_seasons by pasting this code:
def season_fixtures(api_key, league_id, season):
    return call_api(
        "fixtures",
        {"league": league_id, "season": season},
        api_key,
    )


def parse_kickoff(match):
    value = match["fixture"]["date"]
    return datetime.fromisoformat(value.replace("Z", "+00:00"))

What do these helpers return?

  • The fixture helper requests the complete schedule for one league season.
  • The kickoff parser converts each API date string into a timezone-aware Python datetime.
  • Timezone-aware values let the app compare each kickoff with the current UTC time.
  • Save app.py.
  • Check that all three data helpers compile by running the dashboard again:
python -m streamlit run app.py

What does this check prove?

A successful reload confirms that current_seasons, season_fixtures, and parse_kickoff are valid Python. The live request begins after you connect these helpers to the sidebar.

Your data helpers are ready. The dashboard can now identify the active year for each league and translate fixture timestamps into UTC-aware values.

Seeing an import or syntax problem?

  • Check that datetime and timezone are both present in the first import line.
  • Check that parse_kickoff sits outside season_fixtures.
  • Match the parentheses around the call_api arguments with the reference code.

Need another pair of eyes? Help me find the syntax problem in my season and fixture helper functions.

Replace the sample rows with live fixtures

The sidebar already captures the API key without writing it into app.py. Input guards now stop the script before any request when the key or league selection is missing.

  • Insert these guards immediately below the sidebar block:
if not api_key:
    st.write("Enter your API-Football key in the sidebar to load live data.")
    st.stop()

if not selected_leagues:
    st.write("Select at least one league.")
    st.stop()

all_detail_rows = []
loaded_leagues = 0
all_ranked_rows = []

Why stop before requesting data?

Each guard gives the learner a specific next action before stopping the current Streamlit run. The empty lists also prepare the dashboard to collect live fixture rows and preserve the KPI layout.

  • Save app.py.
  • Clear the password field in the browser sidebar.

You should see the message asking for an API-Football key. This proves the app stops before making an unnecessary request.

  • In app.py, select the block that starts with sample_rows and ends after sample_details.
  • Replace the selected block by pasting this live fixture loader:
season_map = current_seasons(api_key)
for league_name in selected_leagues:
    league_id = LEAGUES[league_name]
    season = season_map.get(league_id)
    if season is None:
        continue
    fixtures = season_fixtures(api_key, league_id, season)
    upcoming = [
        match
        for match in fixtures
        if match["fixture"]["status"]["short"] == "NS"
        and parse_kickoff(match) >= datetime.now(timezone.utc)
    ]
    upcoming.sort(key=parse_kickoff)
    for match in upcoming[:upcoming_count]:
        kickoff = parse_kickoff(match)
        home = match["teams"]["home"]
        away = match["teams"]["away"]
        home_goals = match["goals"]["home"]
        away_goals = match["goals"]["away"]
        matchup = f"{home['name']} vs {away['name']}"
        all_detail_rows.append({
            "League": league_name,
            "Kickoff (UTC)": kickoff.astimezone(timezone.utc).strftime(
                "%Y-%m-%d %H:%M"
            ),
            "Match": matchup,
        })
    loaded_leagues += 1

How are upcoming fixtures selected?

  • The outer loop requests one current-season schedule for each selected league.
  • The fixture filter keeps matches with the not-started status and a kickoff at or after the current UTC time.
  • The sorted slice limits each league to the number chosen in the sidebar.
  • Each displayed row contains the league, UTC kickoff, and real home-versus-away matchup.
  • Select the existing KPI and tab block from kpi_columns through the final sample dataframe.
  • Replace the selected block by pasting this live rendering code:
strongest = (
    f"{all_ranked_rows[0]['_rank']:.1%}" if all_ranked_rows else "N/A"
)
average_total = (
    sum(row["_total_xg"] for row in all_ranked_rows) / len(all_ranked_rows)
    if all_ranked_rows
    else 0
)

kpi_columns = st.columns(4, border=True)
kpi_columns[0].metric("Leagues loaded", loaded_leagues)
kpi_columns[1].metric("Games modeled", len(all_ranked_rows))
kpi_columns[2].metric("Strongest signal", strongest)
kpi_columns[3].metric("Average expected goals", f"{average_total:.2f}")

ranked_tab, details_tab = st.tabs(["Ranked signals", "Model details"])

with ranked_tab:
    if all_detail_rows:
        st.dataframe(
            all_detail_rows,
            width="stretch",
            hide_index=True,
        )
    else:
        st.write("No model details are available for the current selection.")

with details_tab:
    st.write("No model details are available for the current selection.")

What changed in the shell?

The first tab now renders the rows collected from live fixtures. The model KPIs remain at their empty values because this step has connected the schedule without calculating probabilities.

  • Save app.py.

Before you load the data, do you think a real fixture schedule can identify the strongest outcome by itself?

  • Enter your API-Football key in the password field.
  • Select at least one league in the Leagues control.

You should see real team names with UTC kickoff times in the first tab. The probability columns are absent because a schedule contains match facts without a model for ranking outcomes.

What did the live data prove?

The dashboard now authenticates successfully and reaches the current fixture schedule. The table proves that league IDs, season discovery, fixture filtering, and date parsing work together.

The schedule still cannot identify a home win, draw, or away win. That visible gap sets up the recent-form model in the next step.

Live fixtures not appearing?

  • Re-enter the API key in the password field if the request reports an authentication problem.
  • Select another league if the current competition has no upcoming fixtures in the returned season.
  • Wait before retrying if several rapid requests have reached the service limit.

Still missing data? Help me diagnose why my Streamlit dashboard is not showing upcoming API-Football fixtures.

An unavailable season needs a visible row instead of disappearing silently. Update the short season check before adding the final request safety net.

  • In app.py, find this season check:
if season is None:
    continue

What does the current check do?

This reference shows the current early exit for a league without an active season. The loop skips that league without giving the dashboard a row to explain why.

  • Replace the season check with this visible unavailable state:
if season is None:
    all_detail_rows.append(
        {
            "League": league_name,
            "Match": "No current season returned",
            "Status": "Unavailable",
        }
    )
    continue

Why keep an unavailable row?

The row tells you which selected league returned no current season. This keeps missing data visible instead of making the league appear forgotten.

Network and response failures also need a readable message. Wrap the live loader so the dashboard can catch request, API, and data-shape problems.

  • Replace the season_map line with this protected opening:
try:
    season_map = current_seasons(api_key)

What does the protected opening do?

The try block marks the start of the live request workflow. Python can now pass any supported failure to a matching exception handler.

  • Select the loader lines from for league_name in selected_leagues: through loaded_leagues += 1.
  • Press the Tab key once to indent the selected loader inside try.
  • Add this exception block immediately below the indented loader:
except (requests.RequestException, RuntimeError, KeyError, TypeError, ValueError) as exc:
    st.write(f"Could not load dashboard data: {exc}")
    st.stop()

Which failures are handled?

The exception tuple covers request failures, API-reported errors, missing fields, unexpected value types, and invalid parsed values. Streamlit displays the problem before stopping the current run cleanly.

  • Save app.py.

✔️ Awesome, I've got everything!

Great. Confirm that app.py is saved before the final live-data check.

ⓧ I'd like to double check the full code

from datetime import datetime, timezone

import requests
import streamlit as st

BASE_URL = "https://v3.football.api-sports.io"
LEAGUES = {
    "Premier League": 39,
    "La Liga": 140,
    "Serie A": 135,
    "Ligue 1": 61,
}


@st.cache_data(ttl=3600, show_spinner=False, scope="session")
def api_get(path, params_items, api_key):
    response = requests.get(
        f"{BASE_URL}/{path}",
        params=dict(params_items),
        headers={"x-apisports-key": api_key},
        timeout=20,
    )
    response.raise_for_status()
    payload = response.json()

    if payload.get("errors"):
        raise RuntimeError(f"API-Football error: {payload['errors']}")

    return payload.get("response", [])


def call_api(path, params, api_key):
    params_items = tuple(sorted((str(key), str(value)) for key, value in params.items()))
    return api_get(path, params_items, api_key)


def current_seasons(api_key):
    seasons = {}
    for item in call_api("leagues", {"current": "true"}, api_key):
        league_id = item["league"]["id"]
        current_years = [
            season["year"]
            for season in item.get("seasons", [])
            if season.get("current") is True
        ]
        if current_years:
            seasons[league_id] = max(current_years)
    return seasons


def season_fixtures(api_key, league_id, season):
    return call_api(
        "fixtures",
        {"league": league_id, "season": season},
        api_key,
    )


def parse_kickoff(match):
    value = match["fixture"]["date"]
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


st.set_page_config(page_title="Soccer Betting Signals", layout="wide")
st.title("Soccer Betting Signals Dashboard")
st.write(
    "Educational probability signals only. This dashboard does not place bets, "
    "use bookmaker prices, or guarantee profit."
)

with st.sidebar:
    st.write("Dashboard controls")
    api_key = st.text_input("API-Football key", type="password")
    selected_leagues = st.multiselect(
        "Leagues",
        list(LEAGUES),
        default=list(LEAGUES),
    )
    upcoming_count = st.slider("Games per league", 1, 5, 3)
    lookback = st.slider("Recent matches per team", 3, 10, 5)
    minimum_confidence = st.slider("Minimum confidence", 50, 80, 55)

if not api_key:
    st.write("Enter your API-Football key in the sidebar to load live data.")
    st.stop()

if not selected_leagues:
    st.write("Select at least one league.")
    st.stop()

all_detail_rows = []
loaded_leagues = 0
all_ranked_rows = []

try:
    season_map = current_seasons(api_key)

    for league_name in selected_leagues:
        league_id = LEAGUES[league_name]
        season = season_map.get(league_id)

        if season is None:
            all_detail_rows.append(
                {
                    "League": league_name,
                    "Match": "No current season returned",
                    "Status": "Unavailable",
                }
            )
            continue

        fixtures = season_fixtures(api_key, league_id, season)
        upcoming = [
            match
            for match in fixtures
            if match["fixture"]["status"]["short"] == "NS"
            and parse_kickoff(match) >= datetime.now(timezone.utc)
        ]
        upcoming.sort(key=parse_kickoff)

        for match in upcoming[:upcoming_count]:
            kickoff = parse_kickoff(match)
            home = match["teams"]["home"]
            away = match["teams"]["away"]
            home_goals = match["goals"]["home"]
            away_goals = match["goals"]["away"]
            matchup = f"{home['name']} vs {away['name']}"

            all_detail_rows.append(
                {
                    "League": league_name,
                    "Kickoff (UTC)": kickoff.astimezone(timezone.utc).strftime(
                        "%Y-%m-%d %H:%M"
                    ),
                    "Match": matchup,
                }
            )

        loaded_leagues += 1

except (requests.RequestException, RuntimeError, KeyError, TypeError, ValueError) as exc:
    st.write(f"Could not load dashboard data: {exc}")
    st.stop()

strongest = (
    f"{all_ranked_rows[0]['_rank']:.1%}" if all_ranked_rows else "N/A"
)
average_total = (
    sum(row["_total_xg"] for row in all_ranked_rows) / len(all_ranked_rows)
    if all_ranked_rows
    else 0
)

kpi_columns = st.columns(4, border=True)
kpi_columns[0].metric("Leagues loaded", loaded_leagues)
kpi_columns[1].metric("Games modeled", len(all_ranked_rows))
kpi_columns[2].metric("Strongest signal", strongest)
kpi_columns[3].metric("Average expected goals", f"{average_total:.2f}")

ranked_tab, details_tab = st.tabs(["Ranked signals", "Model details"])

with ranked_tab:
    if all_detail_rows:
        st.dataframe(
            all_detail_rows,
            width="stretch",
            hide_index=True,
        )
    else:
        st.write("No model details are available for the current selection.")

with details_tab:
    st.write("No model details are available for the current selection.")

How does the live flow fit together?

The full file moves from sidebar inputs to current-season discovery. It then requests schedules, keeps upcoming matches, and renders the resulting fixture rows.

The request cache limits repeated API calls during the current session. The exception handler keeps request failures inside the dashboard instead of exposing a raw traceback.

  • Return to the terminal running Streamlit.
  • Stop the current app using the terminal interrupt shortcut.

Before you run the final check, which parts of each live fixture do you expect the table to show?

  • Start the completed live fixture dashboard by running:
python -m streamlit run app.py

What does the final run test?

This run exercises the cumulative file from imports through rendering. The browser remains stopped at the key message until you provide the password-only credential.

  • Enter your API-Football key in the sidebar password field.
  • Select at least one league.
  • Open the Ranked signals tab.

You should see upcoming fixtures with real team names and UTC kickoff times. The rows are sorted by kickoff within each selected league.

That connection is working. Your dashboard now replaces sample matchups with current API-Football schedules while keeping the key out of the code file.

Your dashboard can now load the matches it needs to analyze. Next, you will turn completed scores into ranked home-win, draw, and away-win probabilities.

Calculate 1X2 Signals

Your Streamlit dashboard now loads real upcoming fixtures from API-Football. The schedule tells you who plays next.

A fixture list cannot identify the strongest outcome. This step measures recent team form. It feeds those averages into a Poisson model. The resulting 1X2 probabilities rank home wins, draws, or away wins.

In this step, get ready to:
  • Calculate each team's recent scoring record before an upcoming fixture.
  • Convert expected goals into normalized home-win, draw, or away-win probabilities.
  • Rank eligible fixtures by their strongest 1X2 signal.
Measure recent team form

Recent form summarizes how many goals a team scores or concedes across its latest completed matches. The fixture kickoff acts as a cutoff so the model only uses information available before that game.

  • In app.py, find the closing brace of LEAGUES.
  • Add the completed-match statuses below that dictionary using this line:
FINISHED_STATUSES = {"FT", "AET", "PEN"}

What Does This Line Do?

The FINISHED_STATUSES set identifies fixtures with final scores. Scheduled or in-progress games stay out of the form calculation.

  • Find the parse_kickoff() function in app.py.
  • Add the first part of recent_form() below that function using this code:
def recent_form(fixtures, team_id, lookback, before):
    history = []

    for match in fixtures:
        if match["fixture"]["status"]["short"] not in FINISHED_STATUSES:
            continue

        kickoff = parse_kickoff(match)
        if kickoff >= before:
            continue

        home = match["teams"]["home"]
        away = match["teams"]["away"]
        home_goals = match["goals"]["home"]
        away_goals = match["goals"]["away"]

        if home_goals is None or away_goals is None:
            continue

        if home["id"] == team_id:
            history.append((kickoff, home_goals, away_goals))
        elif away["id"] == team_id:
            history.append((kickoff, away_goals, home_goals))

How Does the History Filter Work?

  • The status check keeps completed fixtures with usable final scores.
  • The kickoff check prevents future information from entering an earlier prediction.
  • Each history tuple stores the kickoff time. It also stores goals scored from the selected team's perspective. The final value holds goals conceded.
  • Continue recent_form() immediately below its final history.append() line using this code:
    history.sort(key=lambda row: row[0], reverse=True)
    sample = history[:lookback]

    if not sample:
        return None

    return {
        "scored": sum(row[1] for row in sample) / len(sample),
        "conceded": sum(row[2] for row in sample) / len(sample),
        "matches": len(sample),
    }

What Does the Form Summary Contain?

  • The descending sort places the newest completed fixture first.
  • The lookback slice limits the sample to the selected number of matches.
  • The returned dictionary contains average goals scored. It also contains average goals conceded. The matches value records the available sample size.

Seeing a Form Calculation Error?

Check that both continuation chunks remain indented inside recent_form(). Confirm that FINISHED_STATUSES appears near the other constants.

Still stuck? Help me check the indentation and fixture fields in my recent_form function.

Calculate normalized score probabilities

A Poisson distribution assigns a probability to each possible goal count from an expected-goals value. Combining the home distribution with the away distribution produces a grid of possible scorelines.

  • Add the mathematics module above the existing datetime import in app.py using this line:
import math

Why Import Math?

The probability formula uses exponential and factorial operations. Python provides both through the math module.

  • Add poisson_probability() below recent_form() using this code:
def poisson_probability(expected_goals, goals):
    return (
        math.exp(-expected_goals)
        * expected_goals**goals
        / math.factorial(goals)
    )

What Does This Function Calculate?

The function calculates the probability of scoring one exact goal count. The model calls it repeatedly for values from zero through eight.

  • Add the first part of market_probabilities() below poisson_probability() using this code:
def market_probabilities(home_expected, away_expected):
    score_grid = []

    for home_goals in range(9):
        for away_goals in range(9):
            probability = poisson_probability(
                home_expected, home_goals
            ) * poisson_probability(away_expected, away_goals)
            score_grid.append((home_goals, away_goals, probability))

    total_mass = sum(row[2] for row in score_grid)
    if total_mass == 0:
        raise ValueError("The score grid produced no probability mass.")

How Is the Score Grid Built?

The nested loops evaluate every scoreline from zero through eight goals for each team. Multiplying the two goal probabilities gives the probability of that exact score.

The grid has a finite upper limit. Its included probability mass must therefore be measured before the market outcomes are normalized.

  • Complete market_probabilities() immediately below its probability-mass check using this code:
    home_win = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals > away_goals
    ) / total_mass
    draw = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals == away_goals
    ) / total_mass
    away_win = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals < away_goals
    ) / total_mass

    return {
        "home": home_win,
        "draw": draw,
        "away": away_win,
    }

How Do Scorelines Become 1X2 Probabilities?

  • Scorelines with more home goals contribute to the home-win probability.
  • Scorelines with equal goal counts contribute to the draw probability.
  • Scorelines with more away goals contribute to the away-win probability.
  • Dividing each sum by total_mass makes the three outcomes add up to one.

Seeing a Probability Error?

Confirm that import math appears at the top of app.py. Check that the second probability chunk stays indented inside market_probabilities().

Need another pair of eyes? Help me debug my Poisson score grid and normalized 1X2 probabilities.

Build and rank the 1X2 rows

The probability functions now need fixture context. The row builder combines each team's attack with its opponent's defense. It excludes small samples before ranking the strongest outcome.

  • Add the opening part of build_dashboard_rows() below market_probabilities() using this code:
def build_dashboard_rows(league_name, fixtures, lookback, upcoming_count):
    now = datetime.now(timezone.utc)
    upcoming = [
        match
        for match in fixtures
        if match["fixture"]["status"]["short"] == "NS"
        and parse_kickoff(match) >= now
    ]
    upcoming.sort(key=parse_kickoff)

    ranked_rows = []
    detail_rows = []

    for match in upcoming[:upcoming_count]:
        kickoff = parse_kickoff(match)
        home = match["teams"]["home"]
        away = match["teams"]["away"]
        matchup = f"{home['name']} vs {away['name']}"

        home_form = recent_form(fixtures, home["id"], lookback, kickoff)
        away_form = recent_form(fixtures, away["id"], lookback, kickoff)

How Are Upcoming Games Prepared?

The function keeps future fixtures with the NS status. It sorts them by kickoff before applying the selected game limit.

Each upcoming fixture receives a separate recent-form summary for its home team. It receives another summary for its away team.

  • Continue build_dashboard_rows() below the away_form assignment using this code:
        if (
            home_form is None
            or away_form is None
            or home_form["matches"] < 3
            or away_form["matches"] < 3
        ):
            detail_rows.append(
                {
                    "League": league_name,
                    "Match": matchup,
                    "Status": "Excluded: fewer than 3 completed matches",
                }
            )
            continue

        home_expected = (home_form["scored"] + away_form["conceded"]) / 2
        away_expected = (away_form["scored"] + home_form["conceded"]) / 2
        probabilities = market_probabilities(home_expected, away_expected)

        outcome_options = {
            f"{home['name']} win": probabilities["home"],
            "Draw": probabilities["draw"],
            f"{away['name']} win": probabilities["away"],
        }
        outcome_signal = max(outcome_options, key=outcome_options.get)
        outcome_confidence = outcome_options[outcome_signal]

How Does the Model Choose a Signal?

A team needs at least three completed matches before its fixture can be modeled. Excluded fixtures remain visible in the details data with a specific status.

Expected home goals average the home team's scoring rate with the away team's conceding rate. Expected away goals apply the inverse pairing.

The largest probability among the three outcomes becomes outcome_signal. Its probability becomes outcome_confidence.

  • Add each eligible recommendation to ranked_rows using this continuation:
        ranked_rows.append(
            {
                "League": league_name,
                "Kickoff (UTC)": kickoff.astimezone(timezone.utc).strftime(
                    "%Y-%m-%d %H:%M"
                ),
                "Match": matchup,
                "1X2 signal": outcome_signal,
                "1X2 confidence": f"{outcome_confidence:.1%}",
                "Strongest signal": outcome_signal,
                "_rank": outcome_confidence,
            }
        )

Why Store a Hidden Rank?

The visible confidence is formatted as a percentage for the dashboard. The numeric _rank value preserves a sortable probability.

  • Finish build_dashboard_rows() with model details using this code:
  • Add public_rows() immediately below the completed function using the same code block:
        detail_rows.append(
            {
                "League": league_name,
                "Match": matchup,
                "Home expected goals": round(home_expected, 2),
                "Away expected goals": round(away_expected, 2),
                "Home sample": home_form["matches"],
                "Away sample": away_form["matches"],
                "Status": "Modeled",
            }
        )

    ranked_rows.sort(key=lambda row: row["_rank"], reverse=True)
    return ranked_rows, detail_rows


def public_rows(rows):
    return [
        {key: value for key, value in row.items() if not key.startswith("_")}
        for row in rows
    ]

What Do These Final Helpers Return?

Each model-detail row exposes expected goals and sample sizes. Sorting by _rank places the strongest probability first.

The public_rows() helper removes internal fields whose names begin with an underscore. Learners see the useful columns without the sorting metadata.

  • Find the live-data block that calls current_seasons() near the bottom of app.py.
  • Replace its current fixture-row collection with the first integration chunk below:
all_ranked_rows = []
all_detail_rows = []
loaded_leagues = 0

try:
    season_map = current_seasons(api_key)

    for league_name in selected_leagues:
        league_id = LEAGUES[league_name]
        season = season_map.get(league_id)

        if season is None:
            all_detail_rows.append(
                {
                    "League": league_name,
                    "Match": "No current season returned",
                    "Status": "Unavailable",
                }
            )
            continue

What Does the Collection Block Track?

The ranked list stores eligible signals from every selected league. The details list also records leagues whose current season is unavailable.

  • Continue the same try block below continue using this code:
        fixtures = season_fixtures(api_key, league_id, season)
        ranked_rows, detail_rows = build_dashboard_rows(
            league_name,
            fixtures,
            lookback,
            upcoming_count,
        )
        all_ranked_rows.extend(ranked_rows)
        all_detail_rows.extend(detail_rows)
        loaded_leagues += 1

except (requests.RequestException, RuntimeError, KeyError, TypeError, ValueError) as exc:
    st.write(f"Could not load dashboard data: {exc}")
    st.stop()

How Is Each League Modeled?

Each selected league supplies one season of fixtures to build_dashboard_rows(). Its ranked results join the shared recommendation list.

The existing error boundary now also catches model failures. A bad response or unusable value produces a message instead of a broken page.

  • Replace the previous sample tab contents with this ranked 1X2 output:
all_ranked_rows.sort(key=lambda row: row["_rank"], reverse=True)

ranked_tab, details_tab = st.tabs(["Ranked signals", "Model details"])

with ranked_tab:
    if all_ranked_rows:
        st.dataframe(
            public_rows(all_ranked_rows),
            width="stretch",
            hide_index=True,
        )

with details_tab:
    st.write(
        "Expected goals blend each team's recent scoring average with its "
        "opponent's recent conceding average. At least three completed matches "
        "are required for each team."
    )
    if all_detail_rows:
        st.dataframe(
            all_detail_rows,
            width="stretch",
            hide_index=True,
        )
    else:
        st.write("No model details are available for the current selection.")

What Changes in the Dashboard?

The ranked tab displays eligible 1X2 recommendations from strongest to weakest. Internal rank fields stay hidden.

The details tab explains each modeled fixture through expected goals and sample counts. It also exposes exclusions caused by insufficient history.

Dashboard Fails Before Rendering the Tables?

Check that every continuation inside build_dashboard_rows() uses eight spaces where it belongs inside the fixture loop. Confirm that public_rows() starts at the left edge as a separate function.

Still seeing a failure? Help me trace how my live fixtures move through build_dashboard_rows and into the Streamlit tabs.

Use the comparison below to catch misplaced code before running the model.

✔️ Awesome, I've got everything!

Great. Save app.py before running the dashboard.

ⓧ I'd like to double check the full code

import math
from datetime import datetime, timezone

import requests
import streamlit as st

BASE_URL = "https://v3.football.api-sports.io"
LEAGUES = {
    "Premier League": 39,
    "La Liga": 140,
    "Serie A": 135,
    "Ligue 1": 61,
}
FINISHED_STATUSES = {"FT", "AET", "PEN"}


@st.cache_data(ttl=3600, show_spinner=False, scope="session")
def api_get(path, params_items, api_key):
    response = requests.get(
        f"{BASE_URL}/{path}",
        params=dict(params_items),
        headers={"x-apisports-key": api_key},
        timeout=20,
    )
    response.raise_for_status()
    payload = response.json()

    if payload.get("errors"):
        raise RuntimeError(f"API-Football error: {payload['errors']}")

    return payload.get("response", [])


def call_api(path, params, api_key):
    params_items = tuple(sorted((str(key), str(value)) for key, value in params.items()))
    return api_get(path, params_items, api_key)


def current_seasons(api_key):
    seasons = {}
    for item in call_api("leagues", {"current": "true"}, api_key):
        league_id = item["league"]["id"]
        current_years = [
            season["year"]
            for season in item.get("seasons", [])
            if season.get("current") is True
        ]
        if current_years:
            seasons[league_id] = max(current_years)
    return seasons


def season_fixtures(api_key, league_id, season):
    return call_api(
        "fixtures",
        {"league": league_id, "season": season},
        api_key,
    )


def parse_kickoff(match):
    value = match["fixture"]["date"]
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


def recent_form(fixtures, team_id, lookback, before):
    history = []

    for match in fixtures:
        if match["fixture"]["status"]["short"] not in FINISHED_STATUSES:
            continue

        kickoff = parse_kickoff(match)
        if kickoff >= before:
            continue

        home = match["teams"]["home"]
        away = match["teams"]["away"]
        home_goals = match["goals"]["home"]
        away_goals = match["goals"]["away"]

        if home_goals is None or away_goals is None:
            continue

        if home["id"] == team_id:
            history.append((kickoff, home_goals, away_goals))
        elif away["id"] == team_id:
            history.append((kickoff, away_goals, home_goals))

    history.sort(key=lambda row: row[0], reverse=True)
    sample = history[:lookback]

    if not sample:
        return None

    return {
        "scored": sum(row[1] for row in sample) / len(sample),
        "conceded": sum(row[2] for row in sample) / len(sample),
        "matches": len(sample),
    }


def poisson_probability(expected_goals, goals):
    return (
        math.exp(-expected_goals)
        * expected_goals**goals
        / math.factorial(goals)
    )


def market_probabilities(home_expected, away_expected):
    score_grid = []

    for home_goals in range(9):
        for away_goals in range(9):
            probability = poisson_probability(
                home_expected, home_goals
            ) * poisson_probability(away_expected, away_goals)
            score_grid.append((home_goals, away_goals, probability))

    total_mass = sum(row[2] for row in score_grid)
    if total_mass == 0:
        raise ValueError("The score grid produced no probability mass.")

    home_win = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals > away_goals
    ) / total_mass
    draw = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals == away_goals
    ) / total_mass
    away_win = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals < away_goals
    ) / total_mass

    return {
        "home": home_win,
        "draw": draw,
        "away": away_win,
    }


def build_dashboard_rows(league_name, fixtures, lookback, upcoming_count):
    now = datetime.now(timezone.utc)
    upcoming = [
        match
        for match in fixtures
        if match["fixture"]["status"]["short"] == "NS"
        and parse_kickoff(match) >= now
    ]
    upcoming.sort(key=parse_kickoff)

    ranked_rows = []
    detail_rows = []

    for match in upcoming[:upcoming_count]:
        kickoff = parse_kickoff(match)
        home = match["teams"]["home"]
        away = match["teams"]["away"]
        matchup = f"{home['name']} vs {away['name']}"

        home_form = recent_form(fixtures, home["id"], lookback, kickoff)
        away_form = recent_form(fixtures, away["id"], lookback, kickoff)

        if (
            home_form is None
            or away_form is None
            or home_form["matches"] < 3
            or away_form["matches"] < 3
        ):
            detail_rows.append(
                {
                    "League": league_name,
                    "Match": matchup,
                    "Status": "Excluded: fewer than 3 completed matches",
                }
            )
            continue

        home_expected = (home_form["scored"] + away_form["conceded"]) / 2
        away_expected = (away_form["scored"] + home_form["conceded"]) / 2
        probabilities = market_probabilities(home_expected, away_expected)

        outcome_options = {
            f"{home['name']} win": probabilities["home"],
            "Draw": probabilities["draw"],
            f"{away['name']} win": probabilities["away"],
        }
        outcome_signal = max(outcome_options, key=outcome_options.get)
        outcome_confidence = outcome_options[outcome_signal]

        ranked_rows.append(
            {
                "League": league_name,
                "Kickoff (UTC)": kickoff.astimezone(timezone.utc).strftime(
                    "%Y-%m-%d %H:%M"
                ),
                "Match": matchup,
                "1X2 signal": outcome_signal,
                "1X2 confidence": f"{outcome_confidence:.1%}",
                "Strongest signal": outcome_signal,
                "_rank": outcome_confidence,
            }
        )

        detail_rows.append(
            {
                "League": league_name,
                "Match": matchup,
                "Home expected goals": round(home_expected, 2),
                "Away expected goals": round(away_expected, 2),
                "Home sample": home_form["matches"],
                "Away sample": away_form["matches"],
                "Status": "Modeled",
            }
        )

    ranked_rows.sort(key=lambda row: row["_rank"], reverse=True)
    return ranked_rows, detail_rows


def public_rows(rows):
    return [
        {key: value for key, value in row.items() if not key.startswith("_")}
        for row in rows
    ]


st.set_page_config(page_title="Soccer Betting Signals", layout="wide")
st.title("Soccer Betting Signals Dashboard")
st.write(
    "Educational probability signals only. This dashboard does not place bets, "
    "use bookmaker prices, or guarantee profit."
)

with st.sidebar:
    st.write("Dashboard controls")
    api_key = st.text_input("API-Football key", type="password")
    selected_leagues = st.multiselect(
        "Leagues",
        list(LEAGUES),
        default=list(LEAGUES),
    )
    upcoming_count = st.slider("Games per league", 1, 5, 3)
    lookback = st.slider("Recent matches per team", 3, 10, 5)
    minimum_confidence = st.slider("Minimum confidence", 50, 80, 55)

if not api_key:
    st.write("Enter your API-Football key in the sidebar to load live data.")
    st.stop()

if not selected_leagues:
    st.write("Select at least one league.")
    st.stop()

all_ranked_rows = []
all_detail_rows = []
loaded_leagues = 0

try:
    season_map = current_seasons(api_key)

    for league_name in selected_leagues:
        league_id = LEAGUES[league_name]
        season = season_map.get(league_id)

        if season is None:
            all_detail_rows.append(
                {
                    "League": league_name,
                    "Match": "No current season returned",
                    "Status": "Unavailable",
                }
            )
            continue

        fixtures = season_fixtures(api_key, league_id, season)
        ranked_rows, detail_rows = build_dashboard_rows(
            league_name,
            fixtures,
            lookback,
            upcoming_count,
        )
        all_ranked_rows.extend(ranked_rows)
        all_detail_rows.extend(detail_rows)
        loaded_leagues += 1

except (requests.RequestException, RuntimeError, KeyError, TypeError, ValueError) as exc:
    st.write(f"Could not load dashboard data: {exc}")
    st.stop()

all_ranked_rows.sort(key=lambda row: row["_rank"], reverse=True)

ranked_tab, details_tab = st.tabs(["Ranked signals", "Model details"])

with ranked_tab:
    if all_ranked_rows:
        st.dataframe(
            public_rows(all_ranked_rows),
            width="stretch",
            hide_index=True,
        )

with details_tab:
    st.write(
        "Expected goals blend each team's recent scoring average with its "
        "opponent's recent conceding average. At least three completed matches "
        "are required for each team."
    )
    if all_detail_rows:
        st.dataframe(
            all_detail_rows,
            width="stretch",
            hide_index=True,
        )
    else:
        st.write("No model details are available for the current selection.")

How to Use This Reference

Compare each section with your saved app.py. Pay special attention to function indentation and the order of the helper definitions.

Before you run the dashboard, which outcome do you expect to appear most often as the strongest signal?

  • Save app.py.
  • Run the completed 1X2 model from the activated PowerShell environment using this command:
python -m streamlit run app.py

What Does This Command Do?

This command runs app.py through Streamlit. The browser reloads the local dashboard with your new model functions.

  • Enter your API-Football key in the password field.
  • Select at least one league in the sidebar.
  • Open the Ranked signals tab.
  • Confirm that eligible fixtures show a home-team win, draw, or away-team win signal with a percentage.
  • Confirm that the strongest percentage appears in the first table row.
  • Open the Model details tab.
  • Confirm that modeled rows show expected goals and sample sizes.

That is the model working. Your live fixture list now turns recent scoring history into ranked 1X2 probabilities.

No Ranked Signals Showing?

Check the Model details tab for fixtures excluded because a team has fewer than three completed matches. Try another selected league if every visible fixture is excluded.

If the page reports a code error, compare the relevant function with the full reference. Help me diagnose why my 1X2 dashboard has no ranked rows.

You now have explainable home-win, draw, or away-win signals grounded in recent results. Next up, you will add the Over 2.5 and Under 2.5 markets to the same score grid.

Add Totals Signals and Finish the Dashboard

Your Streamlit dashboard now turns recent form into ranked 1X2 probabilities. You can compare the modeled chance of a home win, draw, or away win for each eligible fixture.

A 1X2-only board leaves total-goals opportunities hidden. This step adds Over/Under 2.5 probabilities to the same score grid. You will then finish the filters, KPI cards, model details, and empty states that make the signals useful.

In this step, get ready to:
  • Calculate Under 2.5 and Over 2.5 probabilities from the normalized score grid.
  • Rank each fixture by its strongest 1X2 or totals signal.
  • Finish the dashboard filters, KPI cards, model details, and protective messages.
Add totals probabilities and ranking

The existing score grid already assigns a probability to every result from zero through eight goals per team. You can reuse that grid by grouping scores of zero through two as Under 2.5. Scores of three or more become Over 2.5.

  • In app.py, find the market_probabilities() function.
  • Add the totals calculations after the away_win calculation by inserting this code:
    under_25 = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals + away_goals <= 2
    ) / total_mass
    over_25 = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals + away_goals >= 3
    ) / total_mass

How Are Totals Calculated?

  • The first sum gathers normalized probabilities where the combined score is no more than two goals.
  • The second sum gathers normalized probabilities where the combined score is at least three goals.
  • Both calculations reuse the same score grid as the 1X2 market. One model now supports two views of each fixture.
  • In the same function, find the dictionary returned after the probability calculations.
  • Replace that return dictionary with this expanded version:
    return {
        "home": home_win,
        "draw": draw,
        "away": away_win,
        "under_25": under_25,
        "over_25": over_25,
    }

What Does This Return?

The function now returns five normalized market probabilities. The existing home, draw, and away values remain available. The two new keys expose the totals market to the dashboard row builder.

  • In build_dashboard_rows(), find the line that assigns outcome_confidence.
  • Add the totals comparison directly below that line by inserting this code:
        totals_options = {
            "Under 2.5": probabilities["under_25"],
            "Over 2.5": probabilities["over_25"],
        }
        totals_signal = max(totals_options, key=totals_options.get)
        totals_confidence = totals_options[totals_signal]

        if outcome_confidence >= totals_confidence:
            strongest_signal = outcome_signal
            strongest_confidence = outcome_confidence
        else:
            strongest_signal = totals_signal
            strongest_confidence = totals_confidence

How Is the Strongest Signal Chosen?

  • The totals_options dictionary compares the two totals probabilities.
  • The first max() call selects the more likely totals market.
  • The conditional compares that totals confidence with the strongest 1X2 confidence. The larger value becomes the fixture's ranking score.
  • In the ranked_rows.append() dictionary, locate the fields immediately after Match.
  • Update the signal and ranking fields so they match this code:
                "1X2 signal": outcome_signal,
                "1X2 confidence": f"{outcome_confidence:.1%}",
                "Totals signal": totals_signal,
                "Totals confidence": f"{totals_confidence:.1%}",
                "Strongest signal": strongest_signal,
                "_rank": strongest_confidence,
                "_total_xg": home_expected + away_expected,

What Do These Fields Add?

  • The public fields give each market its own signal and formatted confidence.
  • The Strongest signal field names the recommendation that won the cross-market comparison.
  • The internal _rank value keeps its numeric form for sorting and filtering.
  • The internal _total_xg value stores the combined expected goals for the KPI calculation.
  • Save app.py.
  • Return to the dashboard in your browser from earlier.

Streamlit reruns the saved app. You should see separate columns for the 1X2 signal, 1X2 confidence, totals signal, totals confidence, and strongest signal.

That completes the model. Every eligible fixture now carries probabilities for match outcome and total goals.

Totals Columns Missing?

  • Check that both totals keys appear in the dictionary returned by market_probabilities().
  • Check that totals_options uses the exact keys under_25 and over_25.
  • Confirm that the new public fields sit inside the dictionary passed to ranked_rows.append().

Still stuck? Help me debug the missing totals columns.

Finish the filters and KPI cards

A ranked recommendation board needs a threshold for hiding weak signals. The same numeric ranking values can also power summary cards for the strongest signal and average modeled goals.

  • In the sidebar block in app.py, find the lookback slider.
  • Add the confidence slider directly below it by inserting this line:
    minimum_confidence = st.slider("Minimum confidence", 50, 80, 55)

What Does This Slider Control?

The slider lets you choose a minimum displayed confidence from 50 to 80 percent. Its default value is 55 percent. The filter applies to whichever market is strongest for each fixture.

  • Find the dashboard calculations immediately after the API error-handling block.
  • Replace the existing ranking and KPI calculations with this code:
all_ranked_rows.sort(key=lambda row: row["_rank"], reverse=True)
visible_rows = [
    row
    for row in all_ranked_rows
    if row["_rank"] >= minimum_confidence / 100
]

strongest = (
    f"{all_ranked_rows[0]['_rank']:.1%}" if all_ranked_rows else "N/A"
)
average_total = (
    sum(row["_total_xg"] for row in all_ranked_rows) / len(all_ranked_rows)
    if all_ranked_rows
    else 0
)

kpi_columns = st.columns(4, border=True)
kpi_columns[0].metric("Leagues loaded", loaded_leagues)
kpi_columns[1].metric("Games modeled", len(all_ranked_rows))
kpi_columns[2].metric("Strongest signal", strongest)
kpi_columns[3].metric("Average expected goals", f"{average_total:.2f}")

How Does the Dashboard Summarize the Model?

  • The first line preserves strongest-to-weakest ranking across all selected leagues.
  • The visible_rows list keeps fixtures whose numeric rank meets the chosen percentage threshold.
  • The four bordered columns summarize loaded leagues, modeled games, the strongest probability, and average combined expected goals.
  • Save app.py.
  • Return to the live dashboard in your browser.

You should see four bordered KPI cards. Changing the minimum confidence slider should change which rows qualify for the recommendation board.

KPI Cards Showing an Error?

  • Check that every modeled row stores numeric values under _rank and _total_xg.
  • Confirm that the filter divides minimum_confidence by 100 before comparing it with _rank.
  • Check that the empty-list branches return N/A and 0.

Need another pair of eyes? Help me debug the KPI calculations.

  • Find the existing tab section beneath the KPI cards.
  • Replace the tab declaration and Ranked signals block with this code:
ranked_tab, details_tab = st.tabs(["Ranked signals", "Model details"])

with ranked_tab:
    st.write(
        f"Showing signals at or above {minimum_confidence}% confidence, "
        "ranked by the stronger of the 1X2 and totals markets."
    )
    if visible_rows:
        st.dataframe(
            public_rows(visible_rows),
            width="stretch",
            hide_index=True,
        )
    else:
        st.write("No upcoming games meet the current confidence threshold.")

What Does the Ranked Tab Show?

The Ranked signals tab displays only rows that meet the selected threshold. The helper removes internal fields beginning with an underscore. The empty-state message explains when no fixture qualifies.

  • Save app.py.
  • Select the Ranked signals tab in the browser.

You should see a sortable table with both markets. Its rows should be ordered by the stronger confidence value.

Ranked Table Empty Unexpectedly?

  • Lower the Minimum confidence slider to include more fixtures.
  • Check the Model details tab for fixtures excluded because either team has fewer than three completed matches.
  • Confirm that at least one league remains selected in the sidebar.

Still missing rows? Help me trace why visible_rows is empty.

  • Find the existing with details_tab: block.
  • Replace that block with this finished model-details view:
with details_tab:
    st.write(
        "Expected goals blend each team's recent scoring average with its "
        "opponent's recent conceding average. At least three completed matches "
        "are required for each team."
    )
    if all_detail_rows:
        st.dataframe(
            all_detail_rows,
            width="stretch",
            hide_index=True,
        )
    else:
        st.write("No model details are available for the current selection.")

Why Keep Model Details Visible?

The details table exposes expected goals, recent-match sample sizes, and exclusion statuses. These fields make the recommendation board explainable. They also show when the model has too little history to produce a signal.

  • Save app.py.
  • Select the Model details tab in the browser.

You should see expected goals and sample counts for modeled fixtures. Excluded fixtures should show their data-quality status instead.

Model Details Missing?

  • Check that build_dashboard_rows() still returns both ranked_rows and detail_rows.
  • Confirm that each league extends all_detail_rows after its fixtures are modeled.
  • Check the indentation beneath with details_tab:.

Need help comparing the tab with the row builder? Help me debug the Model details tab.

✔️ Awesome, I've got everything!

Your finished app.py now includes both markets, confidence filtering, KPI cards, model details, and protective empty states.

ⓧ I'd like to double check the full code

import math
from datetime import datetime, timezone

import requests
import streamlit as st

BASE_URL = "https://v3.football.api-sports.io"
LEAGUES = {
    "Premier League": 39,
    "La Liga": 140,
    "Serie A": 135,
    "Ligue 1": 61,
}
FINISHED_STATUSES = {"FT", "AET", "PEN"}


@st.cache_data(ttl=3600, show_spinner=False, scope="session")
def api_get(path, params_items, api_key):
    response = requests.get(
        f"{BASE_URL}/{path}",
        params=dict(params_items),
        headers={"x-apisports-key": api_key},
        timeout=20,
    )
    response.raise_for_status()
    payload = response.json()

    if payload.get("errors"):
        raise RuntimeError(f"API-Football error: {payload['errors']}")

    return payload.get("response", [])


def call_api(path, params, api_key):
    params_items = tuple(sorted((str(key), str(value)) for key, value in params.items()))
    return api_get(path, params_items, api_key)


def current_seasons(api_key):
    seasons = {}
    for item in call_api("leagues", {"current": "true"}, api_key):
        league_id = item["league"]["id"]
        current_years = [
            season["year"]
            for season in item.get("seasons", [])
            if season.get("current") is True
        ]
        if current_years:
            seasons[league_id] = max(current_years)
    return seasons


def season_fixtures(api_key, league_id, season):
    return call_api(
        "fixtures",
        {"league": league_id, "season": season},
        api_key,
    )


def parse_kickoff(match):
    value = match["fixture"]["date"]
    return datetime.fromisoformat(value.replace("Z", "+00:00"))


def recent_form(fixtures, team_id, lookback, before):
    history = []

    for match in fixtures:
        if match["fixture"]["status"]["short"] not in FINISHED_STATUSES:
            continue

        kickoff = parse_kickoff(match)
        if kickoff >= before:
            continue

        home = match["teams"]["home"]
        away = match["teams"]["away"]
        home_goals = match["goals"]["home"]
        away_goals = match["goals"]["away"]

        if home_goals is None or away_goals is None:
            continue

        if home["id"] == team_id:
            history.append((kickoff, home_goals, away_goals))
        elif away["id"] == team_id:
            history.append((kickoff, away_goals, home_goals))

    history.sort(key=lambda row: row[0], reverse=True)
    sample = history[:lookback]

    if not sample:
        return None

    return {
        "scored": sum(row[1] for row in sample) / len(sample),
        "conceded": sum(row[2] for row in sample) / len(sample),
        "matches": len(sample),
    }


def poisson_probability(expected_goals, goals):
    return (
        math.exp(-expected_goals)
        * expected_goals**goals
        / math.factorial(goals)
    )


def market_probabilities(home_expected, away_expected):
    score_grid = []

    for home_goals in range(9):
        for away_goals in range(9):
            probability = poisson_probability(
                home_expected, home_goals
            ) * poisson_probability(away_expected, away_goals)
            score_grid.append((home_goals, away_goals, probability))

    total_mass = sum(row[2] for row in score_grid)
    if total_mass == 0:
        raise ValueError("The score grid produced no probability mass.")

    home_win = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals > away_goals
    ) / total_mass
    draw = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals == away_goals
    ) / total_mass
    away_win = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals < away_goals
    ) / total_mass
    under_25 = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals + away_goals <= 2
    ) / total_mass
    over_25 = sum(
        probability
        for home_goals, away_goals, probability in score_grid
        if home_goals + away_goals >= 3
    ) / total_mass

    return {
        "home": home_win,
        "draw": draw,
        "away": away_win,
        "under_25": under_25,
        "over_25": over_25,
    }


def build_dashboard_rows(league_name, fixtures, lookback, upcoming_count):
    now = datetime.now(timezone.utc)
    upcoming = [
        match
        for match in fixtures
        if match["fixture"]["status"]["short"] == "NS"
        and parse_kickoff(match) >= now
    ]
    upcoming.sort(key=parse_kickoff)

    ranked_rows = []
    detail_rows = []

    for match in upcoming[:upcoming_count]:
        kickoff = parse_kickoff(match)
        home = match["teams"]["home"]
        away = match["teams"]["away"]
        matchup = f"{home['name']} vs {away['name']}"

        home_form = recent_form(fixtures, home["id"], lookback, kickoff)
        away_form = recent_form(fixtures, away["id"], lookback, kickoff)

        if (
            home_form is None
            or away_form is None
            or home_form["matches"] < 3
            or away_form["matches"] < 3
        ):
            detail_rows.append(
                {
                    "League": league_name,
                    "Match": matchup,
                    "Status": "Excluded: fewer than 3 completed matches",
                }
            )
            continue

        home_expected = (home_form["scored"] + away_form["conceded"]) / 2
        away_expected = (away_form["scored"] + home_form["conceded"]) / 2
        probabilities = market_probabilities(home_expected, away_expected)

        outcome_options = {
            f"{home['name']} win": probabilities["home"],
            "Draw": probabilities["draw"],
            f"{away['name']} win": probabilities["away"],
        }
        outcome_signal = max(outcome_options, key=outcome_options.get)
        outcome_confidence = outcome_options[outcome_signal]

        totals_options = {
            "Under 2.5": probabilities["under_25"],
            "Over 2.5": probabilities["over_25"],
        }
        totals_signal = max(totals_options, key=totals_options.get)
        totals_confidence = totals_options[totals_signal]

        if outcome_confidence >= totals_confidence:
            strongest_signal = outcome_signal
            strongest_confidence = outcome_confidence
        else:
            strongest_signal = totals_signal
            strongest_confidence = totals_confidence

        ranked_rows.append(
            {
                "League": league_name,
                "Kickoff (UTC)": kickoff.astimezone(timezone.utc).strftime(
                    "%Y-%m-%d %H:%M"
                ),
                "Match": matchup,
                "1X2 signal": outcome_signal,
                "1X2 confidence": f"{outcome_confidence:.1%}",
                "Totals signal": totals_signal,
                "Totals confidence": f"{totals_confidence:.1%}",
                "Strongest signal": strongest_signal,
                "_rank": strongest_confidence,
                "_total_xg": home_expected + away_expected,
            }
        )

        detail_rows.append(
            {
                "League": league_name,
                "Match": matchup,
                "Home expected goals": round(home_expected, 2),
                "Away expected goals": round(away_expected, 2),
                "Home sample": home_form["matches"],
                "Away sample": away_form["matches"],
                "Status": "Modeled",
            }
        )

    ranked_rows.sort(key=lambda row: row["_rank"], reverse=True)
    return ranked_rows, detail_rows


def public_rows(rows):
    return [
        {key: value for key, value in row.items() if not key.startswith("_")}
        for row in rows
    ]


st.set_page_config(page_title="Soccer Betting Signals", layout="wide")
st.title("Soccer Betting Signals Dashboard")
st.write(
    "Educational probability signals only. This dashboard does not place bets, "
    "use bookmaker prices, or guarantee profit."
)

with st.sidebar:
    st.write("Dashboard controls")
    api_key = st.text_input("API-Football key", type="password")
    selected_leagues = st.multiselect(
        "Leagues",
        list(LEAGUES),
        default=list(LEAGUES),
    )
    upcoming_count = st.slider("Games per league", 1, 5, 3)
    lookback = st.slider("Recent matches per team", 3, 10, 5)
    minimum_confidence = st.slider("Minimum confidence", 50, 80, 55)

if not api_key:
    st.write("Enter your API-Football key in the sidebar to load live data.")
    st.stop()

if not selected_leagues:
    st.write("Select at least one league.")
    st.stop()

all_ranked_rows = []
all_detail_rows = []
loaded_leagues = 0

try:
    season_map = current_seasons(api_key)

    for league_name in selected_leagues:
        league_id = LEAGUES[league_name]
        season = season_map.get(league_id)

        if season is None:
            all_detail_rows.append(
                {
                    "League": league_name,
                    "Match": "No current season returned",
                    "Status": "Unavailable",
                }
            )
            continue

        fixtures = season_fixtures(api_key, league_id, season)
        ranked_rows, detail_rows = build_dashboard_rows(
            league_name,
            fixtures,
            lookback,
            upcoming_count,
        )
        all_ranked_rows.extend(ranked_rows)
        all_detail_rows.extend(detail_rows)
        loaded_leagues += 1

except (requests.RequestException, RuntimeError, KeyError, TypeError, ValueError) as exc:
    st.write(f"Could not load dashboard data: {exc}")
    st.stop()

all_ranked_rows.sort(key=lambda row: row["_rank"], reverse=True)
visible_rows = [
    row
    for row in all_ranked_rows
    if row["_rank"] >= minimum_confidence / 100
]

strongest = (
    f"{all_ranked_rows[0]['_rank']:.1%}" if all_ranked_rows else "N/A"
)
average_total = (
    sum(row["_total_xg"] for row in all_ranked_rows) / len(all_ranked_rows)
    if all_ranked_rows
    else 0
)

kpi_columns = st.columns(4, border=True)
kpi_columns[0].metric("Leagues loaded", loaded_leagues)
kpi_columns[1].metric("Games modeled", len(all_ranked_rows))
kpi_columns[2].metric("Strongest signal", strongest)
kpi_columns[3].metric("Average expected goals", f"{average_total:.2f}")

ranked_tab, details_tab = st.tabs(["Ranked signals", "Model details"])

with ranked_tab:
    st.write(
        f"Showing signals at or above {minimum_confidence}% confidence, "
        "ranked by the stronger of the 1X2 and totals markets."
    )
    if visible_rows:
        st.dataframe(
            public_rows(visible_rows),
            width="stretch",
            hide_index=True,
        )
    else:
        st.write("No upcoming games meet the current confidence threshold.")

with details_tab:
    st.write(
        "Expected goals blend each team's recent scoring average with its "
        "opponent's recent conceding average. At least three completed matches "
        "are required for each team."
    )
    if all_detail_rows:
        st.dataframe(
            all_detail_rows,
            width="stretch",
            hide_index=True,
        )
    else:
        st.write("No model details are available for the current selection.")

How to Use This Reference

Compare this reference with your saved app.py from top to bottom. Pay close attention to indentation inside functions, conditionals, and tab containers.

Verify the finished dashboard

The finished interface still relies on the protective API layer you built earlier. Its one-hour session cache limits repeated API-Football requests. Its timeout and exception handling keep network or parsing failures from crashing into an unreadable traceback.

What Protections Are Included?

  • The API key stays inside a password input instead of a project file.
  • Session-scoped caching keeps successful responses for one hour.
  • The request layer stops waiting after 20 seconds and reports API or parsing problems in the dashboard.
  • The interface explains missing keys, missing league selections, unavailable seasons, small samples, and empty recommendation results.
  • In the sidebar, clear every selection from Leagues.

You should see the message asking you to select at least one league. This confirms the no-selection guard stops the API workflow cleanly.

  • Reselect at least one league in the Leagues control.

Before you restart the dashboard, which market columns and KPI values do you expect to see after setting the confidence threshold to 55 percent?

  • Stop the existing dashboard process in the activated PowerShell environment from earlier.
  • Restart the finished dashboard by running this command:
python -m streamlit run app.py

What Does This Command Do?

Python launches app.py through Streamlit. The local dashboard opens in your browser and reruns whenever its controls change.

  • Enter your API key from earlier in the API-Football key field.
  • Select at least one league in the Leagues control.
  • Set Minimum confidence to 55%.
  • Select the Ranked signals tab.
  • Select the Model details tab.

You should see four KPI cards above a ranked table containing a 1X2 signal, totals signal, and both confidence values. Every visible game should meet the 55 percent threshold based on its stronger market.

The Model details tab should show expected goals and sample counts. The educational notice should state that the dashboard does not place bets, use bookmaker prices, or guarantee profit.

Dashboard Fails During the Final Check?

  • Confirm that PowerShell still shows the active .venv environment before running the dashboard.
  • Check the first visible traceback line for the function or block whose indentation needs attention.
  • Confirm that the API key is entered only in the password field.

If the final run still fails, help me diagnose the dashboard error.

Secret mission

Stress-Test the Recommendations

A strong recommendation becomes more credible when it survives changes to the recent-form window. Test the same fixtures with lookbacks of 3, 5, and 10 matches. Then define a rule that filters out unstable signals.

Clean Up Your Resources

Clean Up Your Resources

Your dashboard runs locally on the API-Football Free plan, so the project has no ongoing compute or subscription charge. Decide whether to keep it available, pause the running process, or remove the local files and project API key.

Protect Your Daily Quota

The API-Football Free plan includes 100 requests per day. API-Football stops processing requests after you reach the quota instead of charging an overage.

Repeated browser sessions or cache clears can consume the quota faster. The one-hour session cache reduces repeated requests during the same session.

Resources you used:

  • The local project folder containing app.py, requirements.txt, stability_notes.md, and .venv.
  • The local Streamlit process that serves the dashboard in your browser.
  • The API-Football project API key used to load live fixture data.

Keep everything running

No action is needed. Choose this option if you plan to keep testing the dashboard or refining your stability rule.

  • Keep the local project folder so your dashboard code and virtual environment remain available.
  • Keep stability_notes.md so your three-lookback comparison remains available.
  • Leave the project API key active for future dashboard sessions.
  • Use the one-hour session cache to limit repeated API requests while the dashboard is running.

Pause - I'll come back to this later

Stop the local dashboard process to free up memory. Your project files and API key remain available for the next session.

  • Return to the PowerShell window that is serving the dashboard.
  • Press Ctrl+C to stop the Streamlit server.

The local server stops. Your browser can no longer load the dashboard until you run it again.

  • Exit the active virtual environment by running this command:
deactivate

What Does This Command Do?

The command exits the active virtual environment. It keeps .venv and every installed package available for your next session.

  • Leave the local project folder in its current location.
  • Leave the project API key active in your API-Football account.

Your dashboard is paused. The code, environment, API key, and stability review remain ready for your next session.

Delete - I don't want to use this again

Remove the local dashboard files and revoke the project API key if you no longer plan to use this project. Your free API-Football account can remain available.

Keep Anything You Need First

Deleting the project folder becomes permanent after you remove it from the Recycle Bin. Copy any findings from stability_notes.md that you want to keep before continuing.

  • Return to the PowerShell window that is serving the dashboard if the Streamlit server is still running.
  • Press Ctrl+C to stop the local server.
  • Exit the active virtual environment by running this command:
deactivate

Why Deactivate First?

Deactivation releases the virtual environment from your current PowerShell session. This prepares the complete project folder for removal.

  • Press the Windows key to open the search bar.
  • Type File Explorer into the search bar.
  • Press Enter to open File Explorer.

Use the known project files to identify the exact folder you created. This prevents you from deleting a different folder by mistake.

  • Find the local project folder containing app.py, requirements.txt, stability_notes.md, and .venv.
  • Select that local project folder.
  • Press the Delete key to move the folder to the Recycle Bin.

The folder is now separated from your other files. Permanently remove it only after checking that it contains the correct project artifacts.

  • Open the Recycle Bin from your Windows desktop.
  • Select the same local project folder.
  • Press the Delete key to begin permanent removal.
  • Confirm the permanent deletion.

That irreversible cleanup is complete. The dashboard code, virtual environment, and stability notes are now removed from this computer.

The project API key exists separately from your local files. Removing it prevents the deleted dashboard from authenticating future fixture requests.

  • Return to the API-Football dashboard page where your project API key is listed.
  • Remove or revoke the project API key using the control displayed beside that key.
  • Confirm that the project API key no longer appears as active.

Cleanup complete. The local app can no longer authenticate fixture requests with that key.

Nice Work!

Nice Work!

You did it! Your finished dashboard turns live fixture history into explainable soccer probability signals.

You've learned how to:

  • Build a polished Streamlit dashboard with sidebar controls. Its KPI cards summarize model output. Sortable tabs make ranked signals easy to compare.
  • Connect Python to API-Football through authenticated REST API requests. Parse live JSON fixture data. Protect requests with timeouts. Conserve the daily quota with a one-hour session cache.
  • Model recent scoring form alongside each opponent's conceding form. Convert the resulting expected goals into normalized Poisson probabilities for the 1X2 market. Reuse the same score grid for the Over/Under 2.5 market. Apply confidence thresholds to filter weak outputs. Use sample-size exclusions to expose data limits.
  • Secret Mission: Stress-test identical fixtures with lookback values of 3, 5, and 10. Record stable recommendations in stability_notes.md. Document an exclusion rule for recommendations whose strongest market changes. Extend that rule to confidence ranges above 10 percentage points.

Ready to quiz yourself?