Back
Featured image of post Lightning fast stock market data parsing. Get a boost of 70%

Lightning fast stock market data parsing. Get a boost of 70%

An example of how to convert stock readed pricings from string to float64 in Go. Fast, really fast.

Table of Content

Con el confinamiento por el Coronavirus y esta situación tan caótica, uno tiene más tiempo para estar delante del ordenador y hacer cosas locas; o frikadas como me gusta llamarlas! Una de ellas es procesar datos de la Bolsa, y como esta es mi primera experiencia con ello, he empezado con la Bolsa española: BME, que es la que más cerca tengo, aunque técnicamente da igual cual elegir porque lo importante son los datos en sí.

Aunque bien es cierto que para esta explicación nos sirve cualquier fuente de datos que nos ofrezca datos de tipo numerico. Podriamos usar, metricas de aplicaciones web, información meteorológica, criptomonedas, etc. La fuente de datos, es lo de menos realmente.

Mi intención

La idea es obtener datos de cotización de una o más empresas para procesarlos con el objetivo de, a posteriori, poder analizarlos sin conexión. Por lo que he podido leer en Internet, en la actualidad existen muchas fuentes de datos de donde se puede obtener esta información. Obviamente cuanto más precisa queramos que esta información más nos costará. Y es que recordad, que la información es el petroleo del siglo XXI.

Dependiendo del proveedor y la calidad de los datos, este coste podrá variar en 4€ hasta 40€ al mes por la información en tiempo real. Sin embargo, para hacer este ejercicio, no necesitamos datos en tiempo real, no somos traders.

Fuentes de información

Yo me mantengo para mi, en secreto, el origen de los datos, pero vosotros podeis obtenerlos de las siguientes forma:

NOTA: leeros las condiciones del servicio que vayais a usar, por si podeis incurrir en algún delito.

Datos a manejar

La idea es hacer un pequeño programa (algoritmo), que dada una entrada de datos en formato texto (string), nos genere su correspondiente valor en formato flotante (float64) y en caso de error, nos devuelva un valor error por defecto.

El formato de los datos de entrada representará:

  • El valor de cotización de una acción.
  • El porcentaje de variación en el precio, que podrá ser positivo o negativo.
  • La oferta.
  • La demanda.
  • El volumen.
  • Los precios de apertura y cierre.
  • Los precios máximos y mínimos de la sesión.

Como veis, algunos datos serán numéricos, otros en formato porcentaje, otros en formato moneda, etc… con lo que nuestros formatos datos de entrada serán:

  • Formato moneda: € 150,00
  • Formato porcentaje: +1,50% -1,50%
  • Formato numérico: 1.500,00

El algoritmo a diseñar tendrá que ser totalmente válido con las entradas anteriores.

Nuestro algoritmo

La primera idea que nos puede venir a la mente es hacer lo siguiente:

  • Dado el dato de entrada, eliminar del string original todos los caracteres que no sean numéricos.

Es una buena opción, vamos a ver cómo quedaría. Para ello os dejo el siguiente snippet de Go con una propuesta de solución para esta opción:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import (
	"fmt"
	"strconv"
	"strings"
)

const (
	erroredValue = -1
	missingText = " "
)

// stringToFloat converts string to a numeric currency value as float64
func stringToFloat(str string) float64 {
	if str != "" && str != missingText {
		isNeg := str[0] == '-'
		// remove from input string any character that should not be there
		value := strings.Replace(str, "€", "", 1)
		value = strings.Replace(value, " ", "", 1)
		value = strings.Replace(value, "%", "", 1)
		value = strings.Replace(value, ".", "", -1)
		value = strings.Replace(value, "+", "", 1)
		value = strings.Replace(value, ",", ".", -1)
		value = strings.Replace(value, "-", "", -1)
		if s, err := strconv.ParseFloat(value, 64); err == nil {
			if isNeg {
				return -1*s
			}
			return s
		}
		fmt.Println("failed to convert string to number: ", str, value)
	}
	return erroredValue
}

Probando el algoritmo

Lanzamos un par de tests con posibles valores de entrada y monitorizamos el rendimiento de este código. Para ello escribimos el siguiente test de evaluación del comportamiento de la función, lo lanzamos, y anotamos sus valores.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
func BenchmarkFloatParser(b *testing.B) {
	b.Run("implementation", func(b *testing.B) {
		b.ReportAllocs()
		b.SetBytes(1)
		b.ResetTimer()
		for i := 0; i < b.N; i++ {
			_ = stringToFloat("€ 2,69")
		}
	})
}

Cuando lanzamos el test, obtenemos los siguientes resultados

1
BenchmarkFloatParser/implementation-12         	 3577545	       335 ns/op	   2.99 MB/s	      32 B/op	       6 allocs/op

A pesar de que podemos tener una primera visión de como se comporta nuestra función y cuanto ‘consume’, no podemos fiarnos completamente de este test porque el resultado de lanzar un único test no sirve de nada, debido a que es una muestra estadística muy pequeña. Tenemos que lanzarlo muchas veces. En este ejemplo concreto, voy a lanzarlo 10 veces y guardar los 10 resultados en un fichero de texto. Para ello hago uso del siguiente comando:

1
go test -v -run=^$ -bench=^BenchmarkFloatParser -benchtime=5s -count=10 > code-1.txt

El resultado del comando anterior, nos lanza 10 veces el mismo test con una duración individual de 5 segundos. Una vez finalizado, en el fichero code-1.txt se guardar el resultado, que en mi caso contiene:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
goos: linux
goarch: amd64
BenchmarkFloatParser
BenchmarkFloatParser/implementation
BenchmarkFloatParser/implementation-12         	18215869	       335 ns/op	   2.98 MB/s	      32 B/op	       6 allocs/op
BenchmarkFloatParser/implementation-12         	17971228	       340 ns/op	   2.94 MB/s	      32 B/op	       6 allocs/op
BenchmarkFloatParser/implementation-12         	18184957	       334 ns/op	   3.00 MB/s	      32 B/op	       6 allocs/op
BenchmarkFloatParser/implementation-12         	18079708	       335 ns/op	   2.99 MB/s	      32 B/op	       6 allocs/op
BenchmarkFloatParser/implementation-12         	18088509	       334 ns/op	   2.99 MB/s	      32 B/op	       6 allocs/op
BenchmarkFloatParser/implementation-12         	18035506	       330 ns/op	   3.03 MB/s	      32 B/op	       6 allocs/op
BenchmarkFloatParser/implementation-12         	18191371	       333 ns/op	   3.00 MB/s	      32 B/op	       6 allocs/op
BenchmarkFloatParser/implementation-12         	18232878	       332 ns/op	   3.01 MB/s	      32 B/op	       6 allocs/op
BenchmarkFloatParser/implementation-12         	18062023	       335 ns/op	   2.98 MB/s	      32 B/op	       6 allocs/op
BenchmarkFloatParser/implementation-12         	18180204	       332 ns/op	   3.01 MB/s	      32 B/op	       6 allocs/op

Ahora sí que tenemos unos resultados estadisticamente más precisos que antes, y cuantas más veces ejecutemos, más precisos serán. El resultado agregado de los 10 tests anteriores lo podemos ver con otro comando:

1
benchstat code-1.txt

Que nos enseña tanto el resultado como su desviación.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
name                           time/op
FloatParser/implementation-12     333ns ± 1%

name                           speed
FloatParser/implementation-12  2.99MB/s ± 2%

name                           alloc/op
FloatParser/implementation-12     32.0B ± 0%

name                           allocs/op
FloatParser/implementation-12      6.00 ± 0%

Ahora, con estos datos recogidos podemos hacer modificaciones en nuestro código original para ver si tenemos una mejora y poder medirla con datos reales.

Mejorando (o no) el algoritmo

La segunda aproximación que vamos a tomar, se trata en seleccionar del string original únicamente los caracteres que formen parte del numero, es decir, se seleccionarán unicamente los digitos del 0 al 9 dejando lo que no lo sea fuera (simbolos de euro, porcentaje, etc).

Modificamos el algoritmo anterior hasta llegar a ello. En mi caso, el código con las modificaciones aplicadas es el siguiente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import (
	"strconv"
)

const (
	erroredValue = -1
	missingText = " "
)

// stringToFloat converts string to a numeric currency value as float64
// This implementations expects to work better than previous stringToFloat
func stringToFloat(str string) float64 {
	if str != "" {
		dst := make([]byte, len(str))
		insert := 0
		isNeg := str[0] == '-'
		for i := 0; i < len(str); i++ {
			c := str[i]
			if c >= '0' && c <= '9' {
				dst[insert] = c
				insert++
			} else if c == ',' {
				// replace comma char by point since
				// float64 uses point char for decimals
				dst[insert] = '.'
				insert++
			}
		}
		s, err := strconv.ParseFloat(string(dst[0:insert]), 64)
		if err == nil {
			if isNeg {
				return -1 * s
			}
			return s
		}
	}
	return erroredValue
}

Con el nuevo código en la mano, volvemos a ejecutar el mismo test anterior con los mismos datos de entrada. Igual que antes, lo ejecutamos 10 veces para tener una muestra mayor de los datos.

1
go test -v -run=^$ -bench=^BenchmarkFloatParser -benchtime=5s -count=10 > code-2.txt

Esta vez, al fichero donde guardamos los datos le damos otro nombre, para no perder los datos anteriores. Después de aproximadamente 50 segundos, el test habrá acabado. Mis resultados son:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
goos: linux
goarch: amd64
BenchmarkFloatParser
BenchmarkFloatParser/implementation
BenchmarkFloatParser/implementation-12         	72433752	        72.7 ns/op	  13.75 MB/s	      16 B/op	       2 allocs/op
BenchmarkFloatParser/implementation-12         	84055232	        72.4 ns/op	  13.81 MB/s	      16 B/op	       2 allocs/op
BenchmarkFloatParser/implementation-12         	84153592	        71.6 ns/op	  13.96 MB/s	      16 B/op	       2 allocs/op
BenchmarkFloatParser/implementation-12         	84468678	        72.9 ns/op	  13.72 MB/s	      16 B/op	       2 allocs/op
BenchmarkFloatParser/implementation-12         	83495444	        71.7 ns/op	  13.96 MB/s	      16 B/op	       2 allocs/op
BenchmarkFloatParser/implementation-12         	85193980	        71.8 ns/op	  13.93 MB/s	      16 B/op	       2 allocs/op
BenchmarkFloatParser/implementation-12         	85055626	        72.3 ns/op	  13.83 MB/s	      16 B/op	       2 allocs/op
BenchmarkFloatParser/implementation-12         	85064064	        72.1 ns/op	  13.87 MB/s	      16 B/op	       2 allocs/op
BenchmarkFloatParser/implementation-12         	83808258	        71.3 ns/op	  14.03 MB/s	      16 B/op	       2 allocs/op
BenchmarkFloatParser/implementation-12         	79048755	        71.3 ns/op	  14.03 MB/s	      16 B/op	       2 allocs/op

Y agregados con benchstat:

1
benchstat code-2.txt
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
name                           time/op
FloatParser/implementation-12    72.0ns ± 1%

name                           speed
FloatParser/implementation-12  13.9MB/s ± 1%

name                           alloc/op
FloatParser/implementation-12     16.0B ± 0%

name                           allocs/op
FloatParser/implementation-12      2.00 ± 0%

Comparando la mejora

LLegados a este punto tenemos los datos de ambas ejecuciones por separado (code-1.txt y code-2.txt). La única tarea pendiente es compararlos para ver si ha habido una mejora relevante, irrelevante o empeoramiento. Para ello, el propio benchstat incluye un comparador que nos permite conocer los resultados.

1
benchstat code-1.txt code-2.txt
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
name                           old time/op    new time/op     delta
FloatParser/implementation-12     333ns ± 1%       72ns ± 1%   -78.40%  (p=0.000 n=9+10)

name                           old speed      new speed       delta
FloatParser/implementation-12  2.99MB/s ± 2%  13.89MB/s ± 1%  +364.05%  (p=0.000 n=10+10)

name                           old alloc/op   new alloc/op    delta
FloatParser/implementation-12     32.0B ± 0%      16.0B ± 0%   -50.00%  (p=0.000 n=10+10)

name                           old allocs/op  new allocs/op   delta
FloatParser/implementation-12      6.00 ± 0%       2.00 ± 0%   -66.67%  (p=0.000 n=10+10)

Por lo tanto podemos concluir que la mejora realizada es la siguiente:

  • 78% más rapido en ejecución.
  • Capaz de procesar 364% más de datos de entrada a nivel de velocidad de memoria.
  • Capaz de obtener el mismo resultado usando un 50% menos bytes
  • Capaz de obtener el mismo resultado usando un 66% menos de allocs

El código se podria llegar a mejorar un poco más eliminando la conversion a string que se usa al llamar a

1
s, err := strconv.ParseFloat(string(dst[0:insert]), 64)

pero eso requeriría usar el paquete unsafe o modificar la función ParseFloat para que reciba un []byte. En cualquier caso, eso lo dejo para otro dia.



💬 Comparte!!

¡Gracias por leer esto y espero que hayas encontrado la información útil! Si tienes alguna duda no tardes en escribirme un comentario más abajo. Y si quieres ver más contenido, sólo házmelo saber y comparte este post con tus colegas, compañeros de trabajo, amigos, etc.

You are free to use the content, but mention the author (@MrSergioAnguita) and link the post!
Last updated on May 07, 2021 21:15 CEST
Please, don't try to hack this website servers. Guess why...