Automate Financial Asset Tracking

Automate tracking financial assets with Python, Docker, AWS & CRON.

Zach Wolpe
11 min readDec 5, 2023

Being a programmer is about spending a week to automate something that takes 5 seconds to do manually — and this project is testimony!

I work fully remotely and that means I do a LOT of traveling. As a consequence,

Foreign Exchange Rates Matter 💵 💶 💷

I’m not trying to predict Forex assets, but monitoring exchange rates (appears) to help me get slightly better prices (although as a statistician I understand my best bet is to buy at random consistently).

Whatever your motives, monitoring financial assets is tedious so why not automate it?

How to Automate Financial Asset Tracking with Python, Docker, AWS & CRON

Project Architecture

The project requires 4 steps:

  1. Fetch & analyse up-to-date forex data (Python).
  2. Store and configure the results (AWS S3).
  3. Packaging the code and dependencies (Docker).
  4. Automate the pipeline (CRON & Shell Scripts).

Fetch & Analyse Forex Data (Python)

I’ve written a Python Library to fetch & model financial data. The Python package bundles dependencies, API queries and statistical functions.

ForexFlaggr

The package:

  • Fetches financial data with the yfinance(Yahoo Finance) API.
  • Generates plots.
  • Fits statistical models (GAMs & LOESS) to the data.
  • Generates an output HTML file to share the results.

The package is git installable (hosted on PyPi) & the source code is available on GitHub.

Intentionally very high level, the purpose of the package is to abstract functionality making it easy to run in many different environments. Usage is self-explanatory:

I. Fetching & structuring data

Taking a look at the source code, the package is designed to be simple but extendable — making it easy to add modules in future. The src contains a ForexFlaggr class that fetches the data and constructs a data frame containing raw data and moving averages.

"""
------------------------------------------------------------------------------------------------
forex_flagger.py

ForexFlagger class.

- fetch data from yfinance
- construct dataframe
- compute (weighted) moving averages
- generate plotly graph

: 18.11.23
: Zach Wolpe
: zach.wolpe@mlxgo.com
------------------------------------------------------------------------------------------------
"""
from .dependencies import (dt, yf, go, np)

class ForexFlaggr:
def __init__(self) -> None:
self.data = None

@staticmethod
def get_price(ff):
"""
Return the most recent price data.

Returns:
datetime, timezone, close, open, high, low
"""
x = ff.df_all.iloc[-1, :]
return str(x.name), str(x.name.tz), x['Close'], x['Open'], x['High'], x['Low']


@staticmethod
def moving_average(signal, period=20):
return signal.rolling(window=period).mean()

@staticmethod
def weighted_moving_average(signal, period=14):
weights = np.linspace(0,1,period)
sum_weights = np.sum(weights)
return signal\
.rolling(window=period)\
.apply(lambda x: np.sum(weights*x)/sum_weights)

def filter_by_date(self, start_date, end_date):
return self.data[start_date:end_date]


def fetch_data(self, stock="USDZAR=X", sample_interval='1h', n_days=500):
self.title = stock
start_date = dt.datetime.today() - dt.timedelta(n_days)
end_date = dt.datetime.today()
self.data = yf.download(stock, start_date, end_date, interval=sample_interval)
return self

def join_df(self, ma, wma):
self.ma = ma
self.wma = wma
self.wma.name = 'wma'
self.ma.name = 'ma'
self.df_all = self.data.join(ma).join(wma)

def plot_signal(self, MA_periods=None):
if self.data is None:
return self

if MA_periods is None:
MA_periods = 12*15 # three weeks (exclude weekends)
ma = ForexFlaggr.moving_average(self.data.Close, MA_periods)
wma = ForexFlaggr.weighted_moving_average(self.data.Close, MA_periods)

fig = go.Figure()
fig.add_trace(go.Line(x=self.data.index, y=self.data.Close, name=self.title))
fig.add_trace(go.Line(x=self.data.index, y=ma, name='Moving avg.'))
fig.add_trace(go.Line(x=self.data.index, y=wma, name='Weighted ma.'))
fig.update_layout(template='none', title=self.title)
self.join_df(ma, wma)
self.fig = fig

return self

@staticmethod
def consol_log():
msg = """
______________________________________________________________________________________________________________________________________________
______________________________________________________________________________________________________________________________________________

/$$ /$$$$$$$$ /$$$$$$$$ /$$
/$$$$$$ | $$_____/ | $$_____/| $$
/$$__ $$ | $$ /$$$$$$ /$$$$$$ /$$$$$$ /$$ /$$ | $$ | $$ /$$$$$$ /$$$$$$ /$$$$$$ /$$$$$$
| $$ \__/ | $$$$$ /$$__ $$ /$$__ $$ /$$__ $$| $$ /$$/ | $$$$$ | $$ |____ $$ /$$__ $$ /$$__ $$ /$$__ $$
| $$$$$$ | $$__/| $$ \ $$| $$ \__/| $$$$$$$$ \ $$$$/ | $$__/ | $$ /$$$$$$$| $$ \ $$| $$ \ $$| $$ \__/
\____ $$ | $$ | $$ | $$| $$ | $$_____/ $$ $$ | $$ | $$ /$$__ $$| $$ | $$| $$ | $$| $$
/$$ \ $$ | $$ | $$$$$$/| $$ | $$$$$$$ /$$/\ $$ | $$ | $$| $$$$$$$| $$$$$$$| $$$$$$$| $$
| $$$$$$/ |__/ \______/ |__/ \_______/|__/ \__/ |__/ |__/ \_______/ \____ $$ \____ $$|__/
\_ $$_/ /$$ \ $$ /$$ \ $$
\__/ | $$$$$$/| $$$$$$/
\______/ \______/
______________________________________________________________________________________________________________________________________________
______________________________________________________________________________________________________________________________________________
"""
return msg

II. Statistical models

The data can be used to fit statistical models. We want to ensure that new models can be implemented easily without breaking the pipeline or requiring substantial changes to downstream code. We also want to ensure that retraining can be done regularly, and weights can be cached. This is achieved by implementing a base class from which contrete models are built.

A model interface defines the expected behaviour of a concrete model instance.

"""
------------------------------------------------------------------------------------------------
stochastic_model_interface.py

A base class that provides an interface for stochastic models.

: 18.11.23
: Zach Wolpe
: zach.wolpe@mlxgo.com
------------------------------------------------------------------------------------------------
"""

from .dependencies import (dt)

class Stochastic_Model_Interface:
"""Interface for stochastic models"""

def __init__(self, X, y, model, model_name):
self.X = X
self.y = y
self.model = model
self.model_name = model_name

@staticmethod
def internal_logger(msg):
print(f'[{dt.datetime.now()}] {msg}')

@staticmethod
def internal_tracing(func):
"""Decorator for internal tracing (timing & logging)."""
def wrapper(*args, **kwargs):
# Stochastic_Model_Interface.internal_logger(f'Running {func.__name__}.')
start = dt.datetime.now()
result = func(*args, **kwargs)
end = dt.datetime.now()
s = ''
l = max(0,15-len(func.__name__))
Stochastic_Model_Interface.internal_logger(f'<{func.__name__}> finished in {s:<{l}}{end-start}.')
return result
return wrapper

def check_Xy(self,X,y):
if X is None:
X = self.X
if y is None:
y = self.y
self.X, self.y = X,y
return X,y

def fit(self, X, y):
raise NotImplementedError

def transform(self):
raise NotImplementedError

def fit_transform(self, X, y):
raise NotImplementedError

def predict(self, x_pred):
raise NotImplementedError

def extrapolate(self, x_pred, n_steps=500):
raise NotImplementedError

def build(self, X=None, y=None, n_steps=500):
"""Implement fit & extrapolate."""
raise NotImplementedError

def plot_prediction(self, X=None, y=None, n_steps=500):
"""Implement plot."""
raise NotImplementedError

A GAM & LOESS are fit using the model interface as a framework. Here is the GAM concrete implementation, see the source code for the LOESS implementation.

"""
------------------------------------------------------------------------------------------------
gam_model.py

A GAM model class, building of the stochastic model interface.

: 18.11.23
: Zach Wolpe
: zach.wolpe@mlxgo.com
------------------------------------------------------------------------------------------------
"""

from .dependencies import (LinearGAM, go, np)
from .stochastic_model_interface import Stochastic_Model_Interface

class GAM_Model(Stochastic_Model_Interface):
"""
Generalized Additive Model
documentation: https://pygam.readthedocs.io/en/latest/api/linearGAM.html
"""
n_samples = 500

def __init__(self, X, y) -> None:
super().__init__(X, y, LinearGAM, 'GAM')
self._n_splines = 25

@property
def n_splines(self):
return self._n_splines

@n_splines.setter
def n_splines(self, n):
self._n_splines = n
self.model = LinearGAM(n_splines=n).gridsearch(self.X, self.y)

@Stochastic_Model_Interface.internal_tracing
def fit(self, X=None, y=None):
X,y = self.check_Xy(X,y)
self.model = LinearGAM(n_splines=self.n_splines).gridsearch(X, y)
self.XX = self.model.generate_X_grid(term=0, n=GAM_Model.n_samples)
return self

@Stochastic_Model_Interface.internal_tracing
def transform(self):
self.yhat, self.CI = self.predict(self.XX)
self.CI_lower, self.CI_upper = self.CI[:,0], self.CI[:,1]
return self

def fit_transform(self, X=None, y=None):
self.fit(X,y)
self.transform()
return self

def predict(self, x_pred, CI_width=.95):
"""returns the mean prediction & confidence intervals"""
return self.model.predict(x_pred), self.model.prediction_intervals(x_pred, width=CI_width)

@Stochastic_Model_Interface.internal_tracing
def extrapolate(self, CI_width=0.95, n_steps=500):
"""Extrapolate forward n_steps."""
# self.internal_logger('Extrapolating {n_steps} forward...')
# m = self.X.min()
M = self.X.max()
self.Xforward = np.linspace(M, M + n_steps, n_steps)
self.yhatforward, self.CIforward = self.predict(self.Xforward, CI_width)
self.CIf_lower, self.CIf_upper = self.CIforward[:,0], self.CIforward[:,1]
return self

@Stochastic_Model_Interface.internal_tracing
def build(self, X=None, y=None, n_steps=500):
self.check_Xy(X,y)
self.fit(X=X, y=y)\
.transform()\
.extrapolate(n_steps=n_steps)\
.plot_prediction()
return self

@Stochastic_Model_Interface.internal_tracing
def plot_prediction(self, c1='orange', c2='darkblue', c3='lightblue', *args, **kwargs):
"""Plot the prediction & confidence intervals."""
fig = go.Figure()
fig.add_trace(go.Scatter(x=self.X.flatten(), y=self.y.flatten(), name='raw data', line=dict(color=c1)))
fig.add_trace(go.Scatter(x=self.XX.flatten(), y=self.yhat, name='prediction', line=dict(color=c2)))
fig.add_trace(go.Scatter(x=self.XX.flatten(), y=self.CI_lower, name='confidence', line=dict(color=c3)))
fig.add_trace(go.Scatter(x=self.XX.flatten(), y=self.CI_upper, name=self.model_name, line=dict(color=c3), showlegend=False))
fig.update_layout(template='none', title=f'{self.model_name} ~ (USD/ZAR)', yaxis_title='Exchange Rate', xaxis_title='Date')

try:
# if extrapolated
# make lines dashed
fig.add_trace(go.Scatter(x=self.Xforward, y=self.yhatforward, name='GAM', line=dict(color='black', dash='dash'), showlegend=False))
fig.add_trace(go.Scatter(x=self.Xforward, y=self.CIf_lower, name='GAM', line=dict(color='black', dash='dash'), showlegend=False))
fig.add_trace(go.Scatter(x=self.Xforward, y=self.CIf_upper, name='GAM', line=dict(color='black', dash='dash'), showlegend=False))
except Exception:
pass
self.fig = fig
return self

III. Convert to HTML

Finally, the html_config.py module transforms all of the raw plots and statistical figures into a readable html document. This provides a clean way of interacting with the results of our analysis.

Statistical Financial Analysis: HTML Doc

I am interested in modelling USD/ZAR. My instance of ForexFlaggr has been configured to:

  • Provide recent price data of USD/ZAR.
  • Model USD/ZAR as a function of various financial indicators.
  • Make recommendations on buying or selling USD/ZAR.

I have configured an API to serve this purpose, which is contained in the package source code. Thus I only need to run the following to produce the HTML report.

import forexflaggr as fxr
import numpy as np

if __name__ == '__main__':
print('fxr version : ', fxr.__version__)
np.int = np.int64 # workaround for deprecated np.int
fxr.Build_HTML_Config(_store_path='output')

Here are samples from the output HTML document, stored in forexflaggr.html.

A GAM model is fit the USD/ZAR to model the temporal nature of the asset. Confidence intervals and projections are also provided.
Left. A recommendation returns either “DO NOT BUY”; “MONITOR” or “BUY”. This recommendation is generated by modelling the weighted average price movement of the underlying asset, capturing momentum. Centre. S&P500 & its (weighted) average price is returned. Right. A 3D plot of USD/ZAR by date, US-TBills & S&P500.
ForexFlaggr also fits multi-dimensional GAMs. Right. Raw data, a 3D plot of USD/ZAR by date, US-TBills (the yield on US debt) & S&P500 (a measure of US economic strength). Left. A 3D GAM fit to the same economic indicates. The blue dots indicate the raw data while the plane indicates the prediction surface. The jagged nature of the prediction plane warns of concern of overfitting in over small changes, however, the general smoother trend at a larger scale means the relationship has been captured adequately by the basis splines.

We have generated the entire report.

We now want to automatically schedule the build, host the results & send a notification when the runtime completes.

Hosting (AWS S3 Bucket)

The easiest way to host an HTML document is on AWS. Any S3 bucket can be configured to host static websites. Once a bucket is configured and you have logged in to the AWS CLI, a shell script can be used to put the files to S3.

Our analysis is contained in the forexflaggr.html document. The additional files are supporting documents needed to generate the HTML report.

echo 'Writing to S3 MY_BUCKET...'
MY_DIR=output
MY_BUCKET=<$S3-BUCKET-PATH>
MY_POLICY=public-read
aws s3 cp ${MY_DIR}/3d_hyperplane_scatter.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/3d_hyperplane_scatter.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/3d_scatter_prediction.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/3d_scatter.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/forexflaggr.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/gam.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/gam.png ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/loess.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/pie_chart_recommendation.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/price_data.json ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/sp500.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/usdzar.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/sp500.html ${MY_BUCKET} --acl ${MY_POLICY}
aws s3 cp ${MY_DIR}/UStreasury.html ${MY_BUCKET} --acl ${MY_POLICY}
echo Write to S3 MY_BUCKET=$MY_BUCKET successful!

Notification (Slack)

It would be nice to be notified when an upload is completed. This could be configured with AWS SNS if you plan on running the whole pipeline on AWS, but another solution is to use Slack webhooks to send a message to Slack directly.

The Slack API can be used to configure a bot. This sh script is used to send the <MESSAGE> on Slack.

echo 'Writing Slack notification...'
URL='<*************************************************>'
WEBHOOK='*************************************************'
PRICE=$(cat output/price_data.json | jq '.[2]' | cut -c 1-6)
MESSAGE="*************************************************"
curl -X POST -H 'Content-type: application/json' --data "{'text': '${MESSAGE}'}" $WEBHOOK
echo 'Slack notification successful!'

Orchestration: Build Once, Run Anyway (Docker)

Our app now contains a Python package & several shell scripts and API connections that all need to be run sequentially. One minimal orchestration option would place all the code in a single directory and use an orchestrator script to manage execution.

Alternatively (used here), we can use Docker to package the application so that it is portable and can run either locally or on the cloud.

Create a directory to store the docker runtime. All of our code is placed in an ./app directory.

dockerfile.yml

The Dockerfile builds the docker image. A Debian Linux distribution is used to build a python==3.10 environment. The environment installs the AWS CLI, Python packages (including our own ForexFlaggr) and configures the file structure.

# Use Alpine Linux as base image
# FROM python:3.10.13-alpine3.18
FROM python:3

# Install AWS CLI within the container
RUN apt-get update && \
apt-get install -y awscli

# install jq
RUN apt-get update && apt-get install -y jq

# # Install Python dependencies
# RUN pip install --no-cache-dir -r requirements.txt
RUN pip install --upgrade pip
RUN pip install kaleido
RUN pip install forexflaggr==0.0.5
RUN pip install pyyaml
RUN pip install numpy==1.26.2

# copy python script into image
RUN mkdir app
COPY ./app/* ./app
RUN mkdir app/output

# change working directory to app
WORKDIR /app

RUN echo ">> DOCKER BUILD COMPLETE. <<"

Build the docker image with:

docker image build -f dockerfile.yml -t <IMAGE:TAG> <LOCATION>

# in my case:
docker build -f dockerfile.yml -t forexflaggr:0.0.1 .

docker-compose.yml

docker-compose.yml is used to launch a new container from the image. We connect our local files to the files in the docker image using a mount. We also need to configure our AWS credentials here if we want to run the app locally.

The command specifies which commands to run on launch. This orchestrates our entire process, calling each stage of our deployment:

  • run.py : builds the ForexFlaggr module.
  • ./aws-put.sh : pushed our data to S3.
  • ./slack-notification.sh : sends a message to Slack.

Note: chmod +x <file> is used to grant system execution access to the <file>. and can be removed after the initial run.

version: '3'

services:
forexflaggr:
image: forexflaggr:0.0.1
environment:
AWS_ACCESS_KEY_ID: ***************************************
AWS_SECRET_ACCESS_KEY: ***************************************
AWS_DEFAULT_REGION: ***************************************
volumes:
- ./app:/app
command: >
bash -c "echo 'launching docker compose...' &&
chmod +x ./aws-put.sh &&
chmod +x ./slack-notify.sh &&
python run.py &&
./aws-put.sh &&
./slack-notify.sh &&
echo 'docker compose finished'
"

Build

Voila! Now running our entire pipeline is as simple as

docker compose up

This command launches a new Docker container that:

  • Run ForexFlaggr to fetch and model the Forex data.
  • Pushes the results to aws/s3.
  • Send a Slack notification with the link to the results.

CRON Scheduling

Although the process now requires a single line in the terminal to execute, why not automate the entire process?

CRON can be used to schedule code on UNIX-based systems (Mac/Linux). Using crontab shell commands can be scheduled. Install crontab via HomeBrew, then enter the crontab by running crontab -e in a terminal. This will launch vim.

Vim: explanation of time structure used by CRON.

To launch the docker compose file every day at 10 am, insert the command in your Vim editor

0 10 * * * /usr/local/bin/docker compose -f $PATH_TO_DIRECTORY/docker-compose.yml up

CRON Configuration Tips:

  • You will need to grant execution access to all shell/docker scripts with chmod +=.
  • CRON may not be able to access your docker daemon due to environment variable conflicts. Run which docker to see where your docker daemon is stored (in my case, /usr/local/bin and use the full path.
  • CRON may not have access to the file system. You may be confused to see that even after granting access to CRON (via Mac/privacy settings) CRON is unable to local files. Ensure you grant access to your terminal (in my case, iTerm2), crontab application & CRON.
  • Access cron logs by starting a shell and cat /var/mail/<usr> or log show — process cron -debug | grep <usr>.
  • Vim: Add a command to Vim by pressing i (insert). Write (save) w and quit q Vim by running :wq!. Delete a line in vim by running dd.

Result

Every morning at 10 am I get a Slack message from ForexFlaggr with the latest USD/ZAR price as well as a link to the full analysis.

The report is publically accessible.

--

--