Drivers of Daily Liquidity Pressure in a Downstream Oil & Gas Company
Author
Juliet Okechukwu
Published
May 8, 2026
Executive Summary
This study investigates the drivers of daily liquidity pressure within the treasury function of a downstream oil and gas company. Using six months of real operational cash-position data extracted from internal treasury records, the analysis examines how opening balances, daily cash inflows, cash outflows, payment types, and calendar patterns influence the likelihood of requiring external funding support.
Exploratory data analysis and visualisation reveal significant volatility in daily net cash positions driven primarily by irregular large-value supplier and statutory payments rather than fluctuations in sales receipts. Hypothesis testing confirms statistically significant differences in outflow behaviour between days requiring funding and days that do not. Correlation analysis highlights strong relationships between funding pressure and specific payment categories, while logistic regression modelling quantifies how combinations of opening balance levels and payment types predict the probability of liquidity shortfall.
The findings demonstrate that liquidity stress is not random but structurally linked to identifiable operational patterns. The study concludes with a data-driven recommendation for improving treasury planning through payment scheduling discipline, minimum opening balance thresholds, and early warning indicators derived from the regression model. This transforms liquidity management from a reactive process into a predictive, analytics-driven decision framework.
Professional Disclosure
I work as a Finance Manager responsible for liquidity and treasury management in a downstream oil and gas company. My role involves daily monitoring of bank positions, coordinating cash inflows from sales operations, managing payment schedules to suppliers and statutory bodies, and making funding decisions to ensure uninterrupted operations across the company’s retail and depot network.
The dataset used in this study is drawn directly from the treasury records I manage as part of my routine responsibilities. It captures daily cash positions, inflows, outflows, and funding decisions over a six-month period. The analytical techniques selected for this case study are directly aligned with the real decisions I make in my role:
Exploratory Data Analysis (EDA) is used to understand the distribution and behaviour of daily cash flows and identify unusual liquidity stress days. Data Visualisation helps communicate cash flow patterns and funding pressure trends in a form similar to internal treasury dashboards. Hypothesis Testing allows me to statistically verify whether funding pressure is associated with specific payment behaviours rather than anecdotal experience. Correlation Analysis identifies the strongest relationships between liquidity pressure and operational variables such as payment type and opening balance. Logistic Regression models the probability of requiring funding support, providing a predictive tool that can enhance treasury planning.
Each technique therefore maps directly to real operational decisions within the treasury function, making this analysis a practical extension of my professional responsibilities rather than a theoretical exercise.
Disclaimer: The following analysis is based on internal sales records for the period of October to December 2025. Data accuracy is dependent on the reporting integrity of individual retail stations. Projections and statistical models (Logistic Regression/Hypothesis Testing) are intended for strategic guidance and should be used in conjunction with broader market volatility indices and regulatory updates from the downstream sector authorities.
Load Libraries
Code
library(readxl)library(tidyverse)
── Attaching core tidyverse packages ──────────────────────── tidyverse 2.0.0 ──
✔ dplyr 1.2.1 ✔ readr 2.2.0
✔ forcats 1.0.1 ✔ stringr 1.6.0
✔ ggplot2 4.0.3 ✔ tibble 3.3.1
✔ lubridate 1.9.5 ✔ tidyr 1.3.2
✔ purrr 1.2.2
── Conflicts ────────────────────────────────────────── tidyverse_conflicts() ──
✖ dplyr::filter() masks stats::filter()
✖ dplyr::lag() masks stats::lag()
ℹ Use the conflicted package (<http://conflicted.r-lib.org/>) to force all conflicts to become errors
Attaching package: 'scales'
The following object is masked from 'package:purrr':
discard
The following object is masked from 'package:readr':
col_factor
Loading required package: carData
Attaching package: 'car'
The following object is masked from 'package:dplyr':
recode
The following object is masked from 'package:purrr':
some
Code
library(lmtest) # bptest (Breusch-Pagan)
Loading required package: zoo
Attaching package: 'zoo'
The following objects are masked from 'package:base':
as.Date, as.Date.numeric
Code
library(nortest) # lillie.testlibrary(patchwork) # combine ggplotslibrary(ggrepel) # non-overlapping text labelslibrary(RColorBrewer) # paletteslibrary(effectsize) # cohen's d, eta squared
Data Ingestion
Code
# NOTE: The raw Excel file has a multi-row merged header.# Rows 1-2: blank/title; Row 3: group headers; Row 4: product sub-headers.# Actual data starts at row 5 (Excel row index).# We skip the first 3 rows and assign clean column names manually.raw <-read_excel("Q4_Sales_Data.xlsx",sheet ="2025 OCT NOV DEC",col_names =FALSE,skip =3# skip title + blank + merged group header rows)
# Some stations report zero litres across ALL products for a month.# This is operationally implausible for an open station — it likely# indicates: (a) station temporarily closed, (b) data not submitted,# or (c) data entry error (missed entry).zero_stations <- df %>%filter(total_ltrs_oct ==0| total_ltrs_nov ==0| total_ltrs_dec ==0) %>%select(station, total_ltrs_oct, total_ltrs_nov, total_ltrs_dec, total_ltrs_all)cat("Stations with at least one zero-volume month:\n")
Test 1: Do High-Tier stations realise significantly higher ─ PMS prices than Low-Tier stations?
Business logic: If high-volume stations negotiate better wholesale allocations, their realised price may differ due to less reliance on spot/expensive supply.
H0: Mean PMS price is the same for High-tier and Low-tier stations H1: High-tier stations have a different mean PMS price (two-sided)
Welch Two Sample t-test
data: high_price and low_price
t = -1.8771, df = 65.065, p-value = 0.06499
alternative hypothesis: true difference in means is not equal to 0
95 percent confidence interval:
-9.8643081 0.3056444
sample estimates:
mean of x mean of y
914.2562 919.0355
if (t1$p.value <0.05) {cat("Result: REJECT H0. High-tier and Low-tier stations charge statistically\n")cat("different average PMS prices (p < 0.05).\n")cat("Business action: High-volume stations have pricing leverage.\n")cat("Investigate whether High-tier stations absorb more of the subsidy\n")cat("differential or enjoy better NNPC direct supply allocation.\n")} else {cat("Result: FAIL TO REJECT H0. No significant price difference detected.\n")cat("Business action: PMS pricing appears relatively uniform across tiers,\n")cat("suggesting a centrally controlled pricing policy at Rainoil.\n")}
Result: FAIL TO REJECT H0. No significant price difference detected.
Business action: PMS pricing appears relatively uniform across tiers,
suggesting a centrally controlled pricing policy at Rainoil.
Test 2: Did AGO (diesel) volumes grow Oct→Dec at the network level? (One-sample t-test on growth rate)
Business logic: Q4 is the dry season and economic activity (farming, construction) ramps up, potentially boosting AGO demand.
Did AGO Volume Significantly Grow Oct→Dec? H0: Mean network AGO growth rate (Oct→Dec) = 0 (no change) H1: Mean network AGO growth rate > 0 (volumes grew)
Lilliefors (Kolmogorov-Smirnov) normality test
data: ago_growth
D = 0.3408, p-value < 2.2e-16
Code
# One-sample t-test (one-sided, mu = 0)t2 <-t.test(ago_growth, mu =0, alternative ="greater")print(t2)
One Sample t-test
data: ago_growth
t = 2.6464, df = 186, p-value = 0.004417
alternative hypothesis: true mean is greater than 0
95 percent confidence interval:
0.1682728 Inf
sample estimates:
mean of x
0.448325
Code
# Effect size: Cohen's d (one-sample, comparing to mu=0)d2 <-mean(ago_growth) /sd(ago_growth)cat("\nCohen's d (one-sample vs. 0):", round(d2, 4), "\n")
if (t2$p.value <0.05) {cat("Result: REJECT H0. AGO volumes did grow significantly from October to\n")cat("December (p < 0.05, one-sided test).\n")cat("Business action: Pre-position AGO inventory ahead of Q4 each year.\n")cat("Expand AGO storage capacity at high-growth stations ahead of dry season.\n")} else {cat("Result: FAIL TO REJECT H0. AGO growth is not statistically significant.\n")cat("Business action: AGO demand is lumpy and station-specific rather than\n")cat("a network-wide seasonal trend. Target AGO promotions station-by-station.\n")}
Result: REJECT H0. AGO volumes did grow significantly from October to
December (p < 0.05, one-sided test).
Business action: Pre-position AGO inventory ahead of Q4 each year.
Expand AGO storage capacity at high-growth stations ahead of dry season.
Warning: `aes_string()` was deprecated in ggplot2 3.0.0.
ℹ Please use tidy evaluation idioms with `aes()`.
ℹ See also `vignette("ggplot2-in-packages")` for more information.
ℹ The deprecated feature was likely used in the ggcorrplot package.
Please report the issue at <https://github.com/kassambara/ggcorrplot/issues>.
Code
print(plot_corr)
Code
cat("\n--- Top Correlations & Business Implications ---\n\n")
cat(" Stations that charge slightly lower prices tend to move higher volumes —\n")
Stations that charge slightly lower prices tend to move higher volumes —
Code
cat(" confirming price elasticity in petrol retail. Micro-pricing strategy\n")
confirming price elasticity in petrol retail. Micro-pricing strategy
Code
cat(" at competitive intersections could unlock latent volume.\n\n")
at competitive intersections could unlock latent volume.
Linear Regression — Predicting Q4 Revenue
Scenario: Build a model to predict a station’s log-total Q4 revenue from its operational characteristics. The model will guide where to invest capacity in 2026.
# Use base R par(mfrow) for the 4-panel diagnostic gridpar(mfrow =c(2, 2),bg ="white",col.main ="#1a1a2e",font.main =2)plot(model,which =c(1, 2, 3, 4),col ="#4472C4",pch =16,cex =0.6,col.smooth ="#C0392B",caption =c("Residuals vs Fitted","Normal Q-Q","Scale-Location","Cook's Distance" ))
Warning in plot.window(...): "col.smooth" is not a graphical parameter
Warning in plot.xy(xy, type, ...): "col.smooth" is not a graphical parameter
Warning in axis(side = side, at = at, labels = labels, ...): "col.smooth" is
not a graphical parameter
Warning in axis(side = side, at = at, labels = labels, ...): "col.smooth" is
not a graphical parameter
Warning in box(...): "col.smooth" is not a graphical parameter
Warning in title(...): "col.smooth" is not a graphical parameter
Warning in plot.window(...): "col.smooth" is not a graphical parameter
Warning in plot.xy(xy, type, ...): "col.smooth" is not a graphical parameter
Warning in axis(side = side, at = at, labels = labels, ...): "col.smooth" is
not a graphical parameter
Warning in axis(side = side, at = at, labels = labels, ...): "col.smooth" is
not a graphical parameter
Warning in box(...): "col.smooth" is not a graphical parameter
Warning in title(...): "col.smooth" is not a graphical parameter
Warning in plot.window(...): "col.smooth" is not a graphical parameter
Warning in plot.xy(xy, type, ...): "col.smooth" is not a graphical parameter
Warning in axis(side = side, at = at, labels = labels, ...): "col.smooth" is
not a graphical parameter
Warning in axis(side = side, at = at, labels = labels, ...): "col.smooth" is
not a graphical parameter
Warning in box(...): "col.smooth" is not a graphical parameter
Warning in title(...): "col.smooth" is not a graphical parameter
Warning in plot.window(...): "col.smooth" is not a graphical parameter
Warning in plot.xy(xy, type, ...): "col.smooth" is not a graphical parameter
Warning in axis(side = side, at = at, labels = labels, ...): "col.smooth" is
not a graphical parameter
Warning in axis(side = side, at = at, labels = labels, ...): "col.smooth" is
not a graphical parameter
Warning in box(...): "col.smooth" is not a graphical parameter
Warning in title(...): "col.smooth" is not a graphical parameter