๐Ÿฆ‰ ~/projects/gaf — pwsh

PS andrewfry.dev> cat /projects/gaf.md

cd .. to return to /projects

GAF Feature Pipeline

Personal Project ยท AI & Machine Learning โ€” can a CNN read price charts as images?

STATUS: DISCONTINUED PIPELINE: FULLY EXECUTED POST-MORTEM: HONEST
[C#][Python][MongoDB][Keras/TensorFlow][Tiingo]
  • 4ร— 120ร—120 matrices per sample
  • 13 outcome classes
  • 3 timeframes (D/W/M)
  • 70/30 chronological train/test split

01 The Hypothesis

Can a convolutional neural network learn to predict short-term price direction by recognizing visual patterns in a GAF image?

A GAF matrix is a compact fingerprint of a price series' internal temporal structure โ€” two windows with similar momentum, reversal, and consolidation dynamics produce visually similar matrices. If patterns that look like today's consistently preceded a specific outcome, a CNN should learn the association.

Target: given the current 120-bar GAF, output a statement like "80% probability this market moves up 2โ€“5% over the next 5 bars."

fig 0 โ€” the idea
  price series (120 bars)
           โ”‚
           โ–ผ
     GAF transform
           โ”‚
           โ–ผ
  120ร—120 matrix "image"
           โ”‚
           โ–ผ
     CNN classifier
           โ”‚
           โ–ผ
  "80% probability this market
   moves +2โ€“5% over next 5 bars"

02 What Is a GAF?

A Gramian Angular Field converts a 1-D time series into a 2-D matrix that encodes temporal correlation: each cell [i, j] expresses the relationship between the price at time i and time j. The result is image-shaped โ€” exactly what CNNs are built to consume.

The variant used is the Gramian Angular Summation Field: result[i,j] = cos(phi_i + phi_j). Four steps, implemented in GAF.cs:

  • Min-max scale the series to [-1, 1]
  • Clamp floating-point overshoot so acos stays valid
  • Arc-cosine each value: phi[i] = acos(scaled[i])
  • Build the Nร—N matrix: cos(phi[i] + phi[j])
fig A1 โ€” GAF algorithm (GAF.cs)
  float[] series (N values)
         โ”‚
         โ–ผ
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  Min-Max scale to [-1, 1]           โ”‚
  โ”‚                                     โ”‚
  โ”‚  min  = series.Min()                โ”‚
  โ”‚  max  = series.Max()                โ”‚
  โ”‚  scaled[i] = (2*x[i] - max - min)   โ”‚
  โ”‚              / (max - min)          โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                     โ”‚
                     โ–ผ
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  Clamp to [-1, 1]                   โ”‚
  โ”‚                                     โ”‚
  โ”‚  if scaled[i] >  1.0 โ†’ set  1.0    โ”‚
  โ”‚  if scaled[i] < -1.0 โ†’ set -1.0    โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                     โ”‚
                     โ–ผ
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  Arc-cosine transform               โ”‚
  โ”‚                                     โ”‚
  โ”‚  phi[i] = acos(scaled[i])           โ”‚
  โ”‚           result in [0, ฯ€]          โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                     โ”‚
                     โ–ผ
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  Build Nร—N output matrix            โ”‚
  โ”‚                                     โ”‚
  โ”‚  for i in 0..N-1:                   โ”‚
  โ”‚    for j in 0..N-1:                 โ”‚
  โ”‚      result[i,j] = cos(phi[i]       โ”‚
  โ”‚                      + phi[j])      โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                     โ”‚
                     โ–ผ
             float[N, N]  (Result)

03 Windows & Labels

Each training sample is a window of 125 consecutive price bars, split 120 + 5: the first 120 bars become four GAF matrices (Open, High, Low, Close โ€” four "channels", like RGB in an image), and the last 5 bars become the label.

The label asks: what did price do next? The percent difference between the last analysis close and the average of the 5 future closes is bucketed into one of 13 classes, from DownGreaterThan20Percent through Neutral to UpGreaterThan20Percent, then one-hot encoded as float[13]. Moves beyond ยฑ30% are treated as data errors and filtered.

fig A2 โ€” window split & label derivation (GafFeature.cs)
  125 PriceData bars
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  Analysis period  (bars 0โ€“119)   โ”‚  Feature  โ”‚
  โ”‚         120 bars                 โ”‚  period   โ”‚
  โ”‚                                  โ”‚  5 bars   โ”‚
  โ”‚  GAF(Open)   โ†’ float[120,120]    โ”‚ (120โ€“124) โ”‚
  โ”‚  GAF(High)   โ†’ float[120,120]    โ”‚           โ”‚
  โ”‚  GAF(Low)    โ†’ float[120,120]    โ”‚           โ”‚
  โ”‚  GAF(Close)  โ†’ float[120,120]    โ”‚           โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”˜
                                           โ”‚
                          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                          โ–ผ
            lastClose = bar[119].Close
            avgFuture = avg(bars[120..124].Close)
            pctDiff   = (avgFuture - lastClose) / lastClose
                          โ”‚
                          โ–ผ
            โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
            โ”‚  Bucket pctDiff into 13 classes  โ”‚
            โ”‚                                  โ”‚
            โ”‚  > +30%   โ†’ (no label, filtered) โ”‚
            โ”‚  +20โ€“30%  โ†’ UpGreaterThan20      โ”‚
            โ”‚  +10โ€“20%  โ†’ Up10To20             โ”‚
            โ”‚   +5โ€“10%  โ†’ Up5To10              โ”‚
            โ”‚   +2โ€“5%   โ†’ Up2To5               โ”‚
            โ”‚   +1โ€“2%   โ†’ Up1To2               โ”‚
            โ”‚  +0.5โ€“1%  โ†’ Up05To1              โ”‚
            โ”‚ ยฑ0.5%     โ†’ Neutral              โ”‚
            โ”‚  -0.5โ€“1%  โ†’ Down05To1            โ”‚
            โ”‚   -1โ€“2%   โ†’ Down1To2             โ”‚
            โ”‚   -2โ€“5%   โ†’ Down2To5             โ”‚
            โ”‚   -5โ€“10%  โ†’ Down5To10            โ”‚
            โ”‚  -10โ€“20%  โ†’ Down10To20           โ”‚
            โ”‚  -20โ€“30%  โ†’ DownGreaterThan20    โ”‚
            โ”‚  < -30%   โ†’ (no label, filtered) โ”‚
            โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                          โ”‚
                          โ–ผ
            ChartFeatureInformation (one bool set)
                          โ”‚
                          โ–ผ
            OneHot() โ†’ float[13]
            e.g. Neutral โ†’ [0,0,0,0,0,0,1,0,0,0,0,0,0]

04 Bulk Generation

A C# ETL task (CreateGafChartFeaturesInDb) drives generation across every market in MongoDB. For each ticker and timeframe it slides the 125-bar window one bar at a time across all available history โ€” every position becomes one labeled training sample, bulk-inserted as a MongoDB document holding all four matrices and the one-hot label.

  • Markets with fewer than 500 bars are skipped โ€” not enough signal
  • Daily, weekly, and monthly passes per market
  • Optional parallel execution (Parallel.ForEach, max degree 10)
fig A3 โ€” sliding window (CreateGafChartFeaturesInDb.cs)
  All price bars for one ticker + TimeLevel
  โ”Œโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”ฌโ”€ โ”€ โ”€ โ”ฌโ”€โ”€โ”€โ”
  โ”‚ 0 โ”‚ 1 โ”‚ 2 โ”‚ 3 โ”‚...โ”‚124โ”‚      โ”‚ N โ”‚
  โ””โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”ดโ”€ โ”€ โ”€ โ”ดโ”€โ”€โ”€โ”˜

  i=0   [0 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 124]
  i=1       [1 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 125]
  i=2           [2 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ 126]
  ...
  i=N-125               [N-125 โ”€โ”€โ”€โ”€โ”€โ”€โ”€ N-1]

  Each window โ†’ GafFeature โ†’ GafChartFeatureResult
                                    โ”‚
  After all windows complete:       โ”‚
                                    โ–ผ
                       List<GafChartFeature>
                                    โ”‚
                                    โ–ผ
               MongoPriceDataFeatureDataStorage
                      .Insert(ticker, timeLevel, list)
                                    โ”‚
                                    โ–ผ
                    MongoDB: GafChartFeature collection

05 CNN Training

The Python side read the matrices from MongoDB and rendered them to PNG images โ€” pixel intensity mapped from each cell's value in [-1, +1]. Images were written into a folder tree organized by outcome class, split 70% train / 30% test chronologically, so the test set represents genuinely unseen future data.

A standard Keras/TensorFlow CNN classifier trained on the image tree, with the 13 class folders providing labels automatically. The intended production loop โ€” current window โ†’ GAF โ†’ image โ†’ CNN โ†’ trade signal above an 80% confidence threshold โ€” was designed but never built.

fig A4 โ€” full pipeline (C# ETL โ†’ Python CNN)
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  C# โ€” SharpTechnicals.FileGenerator     โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

  Program.cs
       โ”‚
       โ–ผ
  CreateGafChartFeaturesInDb.Run()
       โ”‚
       โ”œโ”€ MongoGenericDataStorage<MarketData>
       โ”œโ”€ MongoPriceDataStorage
       โ”œโ”€ TiingoConnector
       โ””โ”€ MarketUniverse
              โ”‚
              โ–ผ
       GetUpdateableMarkets()
              โ”‚
              โ–ผ
       Filter: Ticker == "SPY"
              โ”‚
              โ–ผ
       ProcessMarket.Process(mrkt)
              โ”‚
              โ”œโ”€โ”€โ–บ Daily bars
              โ”œโ”€โ”€โ–บ Weekly bars
              โ””โ”€โ”€โ–บ Monthly bars
                       โ”‚
                       โ–ผ
              bars.Count < 500?  โ”€โ”€ YES โ”€โ”€โ–บ (skip)
                       โ”‚ NO
                       โ–ผ
              Slide 125-bar window (step = 1)
                       โ”‚
                       โ–ผ
              GafFeature(window)
                       โ”‚
             โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
             โ–ผ                   โ–ผ
     GAF on Open/High/    ChartFeatureInfo
     Low/Close (120 bars) from next 5 bars
     โ†’ 4ร— float[120,120]  โ†’ OneHot float[13]
             โ”‚                   โ”‚
             โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                       โ–ผ
            GafChartFeature โ†’ MongoDB

  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  Python โ€” external                      โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

  Read GafChartFeature from MongoDB
       โ”‚
       โ–ผ
  Render float[120,120] โ†’ PNG images
       โ”‚
       โ–ผ
  Split chronologically: 70% TRAIN / 30% TEST
       โ”‚
       โ–ผ
  Folder tree by outcome class
       โ”œโ”€โ”€ TRAIN/Up2To5Percent/...
       โ””โ”€โ”€ TEST/Down1To2Percent/...
                  โ”‚
                  โ–ผ
  CNN training (Keras / TensorFlow)
  softmax over 13 outcome classes
                  โ”‚
                  โ–ผ
  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
  โ”‚  Intended inference (never built)    โ”‚
  โ”‚  window โ†’ GAF โ†’ PNG โ†’ CNN predict    โ”‚
  โ”‚  if P(class) > 0.80 โ†’ trade signal   โ”‚
  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                  โ”‚
                  โ–ผ
       Results were lackluster.
       Experiment discontinued.

06 Outcome โ€” Why It Was Shut Down

The full pipeline ran end-to-end: dataset generation, image export, CNN training. The model did not generalize well enough to be useful as a signal, and the experiment was discontinued rather than tuned into the ground. Likely contributing factors:

  • Training data was initially limited to SPY only โ€” limited pattern variety
  • 13-class label granularity was probably too fine for the data volume
  • GAF matrices encode relative temporal structure but lose absolute price level
  • A 5-bar forward average is a noisy label โ€” one large bar can flip the class

The engineering takeaway: the pipeline architecture โ€” C# feature generation, MongoDB persistence, Python training โ€” worked exactly as designed. The hypothesis didn't survive contact with the data, and knowing when to stop is part of the job.

> next: cat /projects/call-center โ€” the GenAI POC