csv typescript parcel

Estructuración de Datos en el Ejemplo del Curso CHF NBP

Aprende a escribir código que normalice y estructure datos basado en un estudio de caso en el campo de las finanzas.

Daniel Gustaw

Daniel Gustaw

29 min read

Estructuración de Datos en el Ejemplo del Curso CHF NBP

La estructura de datos le da una forma a los datos que permite su análisis y procesamiento conveniente. En esta entrada, mostraré cómo podría lucir dicho proceso utilizando datos del NBP, que se almacenan en archivos donde la convención de disposición de encabezados ha cambiado a lo largo de los años.

Los datos del NBP no son aptos para su uso inmediato y necesitan ser organizados si queremos procesarlos.

Quiero enfatizar que los tipos de cambio históricos están excelentemente presentados en el sitio web:

https://stooq.com/

Tomemos como ejemplo el tipo de cambio del franco suizo:

Para descargar estos datos, simplemente ve a la página:

https://stooq.com/q/d/?s=chfpln

y haz clic en el botón debajo de la tabla.

En este artículo, no estoy resolviendo un problema real, sino presentando posibles métodos de estructuración de datos a través del ejemplo de un conjunto específico de archivos con convenciones inconsistentes e impredecibles.

Pasaremos secuencialmente por los problemas:

  1. Adquisición de datos
  2. Procesamiento de datos
  3. Visualización de gráficos

El principal valor para el lector es seguir todo el proceso de principio a fin y aprender sobre las herramientas utilizadas aquí.


Descargaremos los datos con tasas de cambio de la página

https://www.nbp.pl/home.aspx?f=/kursy/arch_a.html

Los datos están divididos en hojas xls separadas.

Recuperación de datos

Comenzaremos recuperando estos datos. Leemos el selector del código HTML.

En la consola del navegador, escribimos:

[...document.querySelectorAll('.normal_2 a')]
    .map(a => `wget ${a.href}`)
    .filter(link => /archiwum_tab/.test(link))
    .join(' && ')

El resultado es una lista combinada de comandos wget con && que descargan archivos consecutivos.

wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2020.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2021.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2010.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2011.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2012.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2013.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2014.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2015.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2016.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2017.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2018.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2019.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2000.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2001.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2002.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2003.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2004.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2005.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2006.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2007.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2008.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_2009.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1990.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1991.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1992.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1993.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1994.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1995.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1996.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1997.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1998.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1999.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1984.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1985.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1986.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1987.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1988.xls && wget https://www.nbp.pl/kursy/Archiwum/archiwum_tab_a_1989.xls

Después de pegarlos en la terminal, los archivos se descargarán en nuestro computadora.

Recomiendo usar una convención en la que esos archivos en bruto descargados de internet vayan a un directorio separado, por ejemplo, raw.

Conversión

Convertimos todos los archivos al formato csv porque es más conveniente para el procesamiento por máquina que xls.

for i in *.xls; do  libreoffice --headless --convert-to csv "$i" ; done

Después de ejecutar este comando en nuestro directorio, veremos tanto archivos xls como sus correspondientes csv.

Estructuración

Desafortunadamente, las personas que prepararon estos archivos no se preocuparam por mantener una convención común, y la primera fila a veces necesita ser descartada, en otras ocasiones contiene los nombres de monedas, países y en otras ocasiones, el código de la moneda.

¿Qué podemos hacer al respecto?

Es mejor establecer nuestro propio estándar de grabación y unificar la estructura de datos en todo el conjunto de datos.

Convención de grabación de fecha, moneda y tipo de cambio:

Convención de estructura de datos (composición):

Una vez que tengamos la convención, podemos escribir el código. Usaremos typescript para eso.

Configuración del proyecto

Comenzamos el proyecto con comandos

tsc --init
npm init -y
npm install chai
npm i --save-dev @types/node @types/chai
touch app.ts

El paquete que instalamos - chai nos permitirá escribir pruebas automatizadas que verifiquen la conformidad de los resultados con nuestras expectativas. Esto nos ahorrará tiempo en su verificación manual.

Para la tarea, debemos elegir una estructura de directorios y un paradigma. En nuestro caso, asumimos un máximo de 100 líneas de código de procesamiento, y por esta razón, un archivo con código procedural es suficiente con el esqueleto:

// declarations
imports ...

constants ...

functions ...

main function

// execution
console.log(main())

Lectura de Archivos

La primera función será main. Comenzaremos mostrando una lista de archivos.

import fs from 'fs'
import chai from 'chai'

const main = () => {
    const rawDir = process.cwd() + `/raw`

    const res = fs.readdirSync(rawDir).filter(f => f.endsWith('csv'));
    res.forEach(r => chai.expect(r).to.be.a('string'))

    return res;
}

console.dir(main(), {depth: Infinity, maxArrayLength: Infinity})

Ejecución por comando

 ts-node app.ts

Da los nombres de los archivos que procesaremos:

[
  'archiwum_tab_a_1984.csv',
  'archiwum_tab_a_1985.csv',
...

Gracias a la línea que utiliza chai, estamos seguros de que todos los resultados tienen el tipo adecuado. Esto puede no parecer impresionante ahora, pero en una etapa posterior, dichas pruebas nos permitirán detectar y corregir rápidamente errores relacionados con el descubrimiento de matices adicionales en las convenciones utilizadas en los archivos examinados.

Para mostrar el contenido del primer archivo, utilizaremos la función readFileSync. La elección de filtros y mapas no es aleatoria. Estas funciones, junto con reduce, son perfectas para el procesamiento de datos, y las veremos aquí muchas más veces.

import fs from 'fs'
import chai from 'chai'

+ const FILES_FILTER = (e: string, i: number) => i <= 0

const main = () => {
  const rawDir = process.cwd() + `/raw`

  const res = fs.readdirSync(rawDir).filter(f => f.endsWith('csv'))
+    .filter(FILES_FILTER)
+    .map((name, i) => {
+      return fs
+        .readFileSync(`${rawDir}/${name}`)
+        .toString()
+    })
  res.forEach(r => chai.expect(r).to.be.a('string'))
  return res;
}

console.dir(main(), {depth: Infinity, maxArrayLength: Infinity})

Resulta que el primer archivo no contiene códigos de moneda.

Así que nos vemos obligados a construir un diccionario que mapea los nombres de los países a los códigos de moneda.

const dict: { [key: string]: string } = {
  'Szwajcaria': 'CHF'
}

Procesamiento de Encabezados

Examinar los encabezados también define las reglas básicas para un procesamiento posterior.

  1. Necesitamos buscar el nombre del país en la primera fila.
  2. Basándonos en esto, determina la columna col donde se encuentra los datos.
  3. En la segunda fila, la columna col contiene el divisor div
  4. Más tarde, solo tomamos aquellas filas que contienen una fecha en la primera columna.
  5. En estas filas, la columna col contiene un valor que debe ser dividido por el divisor div para obtener el tipo de cambio de divisas.

Gracias a las interfaces en TypeScript, podemos definir cómo se verá nuestra estructura de datos objetivo de un solo archivo:

interface YearData {
  [key: string]: {
    col: number,
    div: number,
    values: { [key: string]: number }[]
  }
}

Línea que devuelve el contenido del archivo:

return fs.readFileSync(`${rawDir}/${name}`).toString()

cambiaremos la asignación de la constante arr a un arreglo de arreglos del archivo csv dividido por caracteres de nueva línea y comas

const arr = fs
  .readFileSync(`${rawDir}/${name}`)
  .toString()
  .split(`\n`)
  .map(l => l.split(','));

La función que utilizaremos para la distribución de la primera línea es:

const decomposeBaseSettingsFromNames = (localArr: string[]) => localArr.reduce((p: YearData, n: string, i: number): YearData => {
  if (Object.keys(dict).includes(n)) {
    p[dict[n]] = { col: i, div: 1, values: [] }
  }
  return p
}, {})

Lo usaremos justo después de descomprimir el archivo en el arreglo arr en las líneas

const head = arr.shift()
if (!head) throw Error('File do not have header line.')
let settings: YearData = decomposeBaseSettingsFromNames(head)

En caso de éxito, la configuración contendrá la clave CHF con el valor bien calculado de la columna. Para eso, necesitábamos la función decomposeBaseSettingsFromNames, sin embargo, notemos que establecí el valor del divisor en 1. Eso es porque los divisores están en la siguiente línea. Los encontraremos utilizando las siguientes líneas:

if (Object.keys(settings).length) {
  const subHead = arr.shift()
  if (!subHead) throw Error('File do not have sub-header line.')
  Object.keys(settings).forEach(key => {
    settings[key].div = parseInt(subHead[settings[key].col])
  })
}

return settings;

La prueba también cambiará y actualmente tomará la forma de:

res.forEach(r => {
        chai.expect(r).to.haveOwnProperty('CHF');
        chai.expect(r.CHF).to.haveOwnProperty('col');
        chai.expect(r.CHF).to.haveOwnProperty('div');
        chai.expect(r.CHF).to.haveOwnProperty('values');
        chai.expect(r.CHF.col).to.be.a('number');
        chai.expect(r.CHF.div).to.be.a('number');
        chai.expect(r.CHF.values).to.be.a('array');
    })

Ejecutar el código anterior nos dará

[ { CHF: { col: 25, div: 1, values: [] } } ]

Procesamiento de Valores

const getDate = (input: string) => {
  if (/\d{2}\.\d{2}\.\d{4}/.test(input)) {
    return input.split('.').reverse().join('-')
  }
  return false
}

Ahora, después de procesar los encabezados, podemos agregar código para estructurar los valores del curso.

arr.forEach(localArr => {
  const date = getDate(localArr[0])
  if (typeof date === 'string') {
    Object.keys(settings).forEach(key => {
      settings[key].values.push({ [date]: parseFloat(localArr[settings[key].col]) / settings[key].div })
    })
  }
})

Como podemos ver, los encabezados fueron la parte más difícil. Una vez que los tenemos, organizar los valores se convierte en una formalidad. La ejecución del código da:

[
  {
    CHF: {
      col: 28,
      div: 1,
      values: [
        { '1984-01-02': 140.84 },
        { '1984-01-09': 140.08 },
        { '1984-01-16': 138.62 },
...

Una prueba de estructura de datos correcta podría verse así:

    res.forEach(r => {
        chai.expect(r).to.haveOwnProperty('CHF');
        chai.expect(r.CHF).to.haveOwnProperty('col');
        chai.expect(r.CHF).to.haveOwnProperty('div');
        chai.expect(r.CHF).to.haveOwnProperty('values');
        chai.expect(r.CHF.col).to.be.a('number');
        chai.expect(r.CHF.div).to.be.a('number');
        chai.expect(r.CHF.values).to.be.a('array');
        r.CHF.values.forEach(v => {
            chai.expect(Object.keys(v)[0]).to.be.a('string');
            chai.expect(/\d{4}-\d{2}-\d{2}/.test(Object.keys(v)[0])).to.be.true;
            chai.expect(Object.values(v)[0]).to.be.a('number');
            chai.expect(Object.values(v)[0]).to.be.greaterThan(0);
        })
    })

Puedes revisar todo el código aquí:

app.ts · 9d401a925bc9e115dfaf9efe6528484f62cf2263 · gustawdaniel / nbp

Este artículo podría terminar aquí con la fusión de archivos en una función y la presentación del resultado final…

Sin embargo, ese no es el caso. Ahora comienza el trabajo sucio con la detección de inconsistencias en las convenciones de archivos de NBP.

Normalización y limpieza de datos

Si revisamos el archivo 6 usando este código, configurando la función de filtrado de archivos a:

const FILES_FILTER = (e: string, i: number) => i === 5

el resultado será sorprendentemente decepcionante

[ { CHF: { col: 27, div: 1, values: [] } } ]

Para depurarlo detrás de la línea:

.split(`\n`)

agregaremos

.filter(ROWS_FILTER)

con el valor ROWS_FILTER definido como

const ROWS_FILTER = (e: string, i: number) => i <= 4

Para facilitar la lectura, mostré temporalmente la tabla arr usando console.table y extraje solo las columnas más interesantes:

console.table(arr.map(l => l.filter((e,i) => i < 5 || Math.abs(i - 30) < 4)));

¿Qué vemos?

Que el formato de fecha ha cambiado a MM/DD/YYYY.

Manejaremos el problema extendiendo el convertidor de fechas con otro if.

if (/\d{2}\/\d{2}\/\d{4}/.test(input)) {
  const [m, d, y] = input.split('/')
  return [y, m, d].join('-')
}

También podemos agregar un filtro que eliminará los espacios de los nombres de los países:

const DROP_SPACES = (l: string): string => l.replace(/\s+/g, '')

insertado en el mapa detrás de la línea

.split(`\n`)

Esto permitirá tratar al país Reino Unido y Reino Unido de la misma manera.

Después de estos cambios, también implementaremos un cambio en las pruebas. Haremos cumplir una longitud mayor a cero para los valores de precio. También moveremos las pruebas a una función separada.

const testYearData = (r:YearData):void => {
    chai.expect(r).to.haveOwnProperty('CHF');
    chai.expect(r.CHF).to.haveOwnProperty('col');
    chai.expect(r.CHF).to.haveOwnProperty('div');
    chai.expect(r.CHF).to.haveOwnProperty('values');
    chai.expect(r.CHF.col).to.be.a('number');
    chai.expect(r.CHF.div).to.be.a('number');
    chai.expect(r.CHF.values).to.be.a('array');
    chai.expect(r.CHF.values.length).to.be.greaterThan(0);
    r.CHF.values.forEach(v => {
        chai.expect(Object.keys(v)[0]).to.be.a('string');
        chai.expect(/\d{4}-\d{2}-\d{2}/.test(Object.keys(v)[0])).to.be.true;
        chai.expect(Object.values(v)[0]).to.be.a('number');
        chai.expect(Object.values(v)[0]).to.be.greaterThan(0);
    })
};

Y lo llevamos a cabo devolviendo settings.

testYearData(settings);

Después de desbloquear filtros

const FILES_FILTER = (e: string, i: number) => i < Infinity
const ROWS_FILTER = (e: string, i: number) => i <= Infinity

La ejecución terminará con un error

Gracias a las líneas que permiten la depuración:

console.table(arr.map(l => l.filter((e,i) => i < 3 || Math.abs(i - 27) < 5)));

y

console.dir(settings, {depth: Infinity});

vemos que el problema son las líneas completamente vacías.

La causa del error es la rígida adherencia a una fila específica como un lugar donde mantenemos delimitadores o nombres de moneda, mientras que deberíamos estar eliminando líneas vacías antes de detectar encabezados.

Este es un problema común al analizar archivos de Excel. Los usuarios, al poder preparar datos en una estructura muy arbitraria, a menudo no se adhieren a la convención de colocar encabezados de la misma manera en todos los archivos.

Usaremos la función test y una expresión regular que denote ya sea comas o nada a lo largo de la línea:

const DROP_EMPTY_LINES = (e:string) => !/^,*$/.test(e)

Nos uniremos a ello después de DROP_SPACES en la función filter.

const arr = fs
  .readFileSync(`${rawDir}/${name}`)
  .toString()
  .split(`\n`)
  .map(DROP_SPACES)
  .filter(DROP_EMPTY_LINES)
  .filter(ROWS_FILTER)
  .map(l => l.split(',')

Esta vez no funciona de nuevo. La razón es una línea muy inusual en uno de los archivos.

¿Corrección de curso desde 1987? ¿Cómo es eso? En realidad, en xls tenemos algo así:

Sin embargo, se trata de la moneda ECU, por lo que es más razonable omitir esta línea ajustando los criterios de reconocimiento de fechas.

El código completo de esta etapa se puede encontrar en el enlace:

app.ts · 845527b631054744329b53293bfbf6705956b361 · gustawdaniel / nbp

Sin embargo, su ejecución aún causa errores.

[
  {
    CHF: {
      col: 27,
      div: NaN,
      values: [ { '1988-12-27': NaN }, { '1989-01-02': NaN } ]
    }
  }
]

Tras una inspección más profunda, resulta que el problema radica en una línea que estaba casi vacía, pero no completamente vacía:

Alguien colocó Nr en una columna completamente insignificante. Por lo tanto, volvemos al código y eliminaremos esta línea con el siguiente filtro: DROP_JUNK_LINES, colocado antes de DROP_EMPTY_LINES.

Cuando escribí este código, volví a este filtro varias veces. No lo reproduciré esta vez, pero simplificaré y proporcionaré el valor final de esta función:

const DROP_JUNK_LINES = (l: string): string => l.replace(/(Nr)|(data)|(WALUTA\/CURRENCY)|(\.tab)/ig, '')

Resultó que en esta línea había:

Estas cosas a veces estaban en mayúsculas, y lo que más me sorprendió fue también `M O N E D A / M O N E D A`. Afortunadamente, gracias al mapa DROP_SPACES y las flags g e i en el mapa DROP_JUNK_LINES, el filtro DROP_EMPTY_LINES trata todas estas líneas como igualmente vacías, es decir, necesarias.

     .split(`\n`)
     .map(DROP_SPACES)
+    .map(DROP_JUNK_LINES)
     .filter(DROP_EMPTY_LINES)
     .filter(ROWS_FILTER)

Después de introducir estas correcciones, podemos ver la estructura requerida para los archivos subsecuentes:

[
  {
    CHF: {
      col: 30,
      div: 1,
      values: [
        { '1988-12-27': 910.9 },
        { '1989-01-02': 904.29 },
        { '1989-01-09': 915.44 }
...

Enlace a los cambios en el código

Líneas innecesarias eliminadas (fd13a96c) · Commits · gustawdaniel / nbp

Sin embargo, es suficiente procesar algunos archivos más para volver al punto de partida, ver

[ {} ]

y reparar desde cero.

¿Qué ocurrió esta vez?

Imprimir una tabla del archivo CSV después de procesarlo nos ayudará.

console.table(arr.map(e => e.filter((e,i) => i < 10)));

ver una organización completamente nueva del encabezado y el cambio de la columna de fecha

Esta vez tanto la moneda como el divisor se colocan en la misma línea. Así que manejaremos el caso else después de la línea.

if (Object.keys(settings).length) {

usaremos la función decomposeBaseSettingsFromCodes definida como

const decomposeBaseSettingsFromCodes = (localArr: string[]) => localArr.reduce((p: YearData, n: string, i: number): YearData => {
  const [, div, curr] = n.match(/^(\d+)(\w+)$/) || []
  if (parseInt(div) && curr && Object.values(dict).includes(curr)) {
    p[curr] = { col: i, div: parseInt(div), values: [] }
  }
  return p
}, {})

¿Qué cambia?

Por esta razón, se incorporará al código de la siguiente manera

const head = arr.shift()
if (!head) throw Error('File do not have header line.')
let settings: YearData = decomposeBaseSettingsFromNames(head)
if (Object.keys(settings).length) {
  const subHead = arr.shift()
  if (!subHead) throw Error('File do not have sub-header line.')
  Object.keys(settings).forEach(key => {
    settings[key].div = parseInt(subHead[settings[key].col])
  })
} else {
  settings = decomposeBaseSettingsFromCodes(head)
}

Otro problema son los números ordinales en la primera columna en lugar de las fechas. Trataremos con las fechas reemplazando la función getDate por la función getDateFromArr.

const getDateFromArr = (arr: string[]) => {
  return getDate(arr[0]) || getDate(arr[1])
}

ahora se utiliza así:

arr.forEach(localArr => {
-  const date = getDate(localArr[0])
+  const date = getDateFromArr(localArr)
  if (typeof date === 'string') {
    Object.keys(settings).forEach(key => {
      settings[key].values.push({ [date]: parseFloat(localArr[settings[key].col]) / settings[key].div })
    })
  }
})

Las correcciones se pueden ver en el commit:

Se corrigieron códigos de decodificación y columna con índices

¿Son esos todos los problemas? Absolutamente no. En 2008, se utilizó otra convención.

Se trata de no colocar “Suiza” en ningún lugar, ni “1CHF” en ningún lugar, por lo tanto, ambos métodos de reconocimiento fallan. ¿Qué deberíamos hacer? Podemos esbozar el algoritmo de reconocimiento de encabezados de la siguiente manera:

Marcamos los elementos faltantes en naranja.

Dado que la búsqueda del divisor se repite, la separaremos en una función aparte:

const extendSettingsByDivCoefficient = (arr: string[][], settings: YearData) => {
  const subHead = arr.shift()
  if (!subHead) throw Error('File do not have sub-header line.')
  Object.keys(settings).forEach(key => {
    settings[key].div = parseInt(subHead[settings[key].col])
  })
}

No deberíamos mantener demasiado código en main porque pierde legibilidad, así que movemos toda la lógica de reconocimiento de encabezados a una función separada:

const recognizeSettingsFromHead = (arr: string[][]):YearData => {
  const head = arr.shift()
  if (!head) throw Error('File do not have header line.')
  let settings: YearData = decomposeBaseSettingsFromNames(head)
  if (Object.keys(settings).length) {
    extendSettingsByDivCoefficient(arr, settings);
  } else {
    settings = decomposeBaseSettingsFromCodes(head);
    while (Object.keys(settings).some(key => Number.isNaN(settings[key].div))) {
      extendSettingsByDivCoefficient(arr, settings);
    }
  }

  return settings;
}

En la mayoría será solo:

const settings = recognizeSettingsFromHead(arr);

Para analizar divisores, la condición se volvió clave:

Number.isNaN(settings[key].div)

Por lo tanto, en la configuración de ajustes para códigos, ya no podemos asumir optimistamente que el ajuste 1 es el valor predeterminado, ni forzar la ocurrencia de un número con el código de moneda, ni exigirlo.

Los cambios en las funciones que realizan el procesamiento del encabezado anteriormente se ven así

Así es como se ve su código actual, sin embargo.

const decomposeBaseSettingsFromNames = (localArr: string[]) => localArr.reduce((p: YearData, n: string, i: number): YearData => {
    if (Object.keys(dict).includes(n)) {
        p[dict[n]] = { col: i, div: NaN, values: [] }
    }
    return p
}, {})

const decomposeBaseSettingsFromCodes = (localArr: string[]) => localArr.reduce((p: YearData, n: string, i: number): YearData => {
    const [, div, curr] = n.match(/^(\d*)(\w+)$/) || []
    if (curr && Object.values(dict).includes(curr)) {
        p[curr] = { col: i, div: parseInt(div), values: [] }
    }
    return p
}, {})

El proyecto completo en esta etapa:

app.ts

Como puedes ver, la limpieza de datos es un proceso tedioso donde los problemas nunca terminan. Afortunadamente, estos datos llegan a razón de un archivo por año, y parece que logramos estructurarlo antes de que transcurriera este tiempo.

Ejecutando el código con el comando

ts-node app.ts

mostrará largas listas de tablas y configuraciones pero no generará ningún error.

Combinando Archivos

Los siguientes son necesarios para combinar archivos:

  1. agregar un tipo de resultado
interface OutData {
  [key: string]: {
    [key: string]: number
  }
}
  1. Preparando la función de conexión
const mergeYears = (payload: YearData[]): OutData => {
  return payload.reduce((p: OutData, n: YearData) => {
    Object.keys(n).forEach(key => {
      if (p.hasOwnProperty(key)) {
        p[key] = {...p[key], ...n[key].values.reduce((p,n) => ({...p,...n}))}
      } else {
        p[key] = n[key].values.reduce((p,n) => ({...p,...n}))
      }
    })
    return p
  }, {})
}

4. Agregando mergeYears antes de return en la función main.

return mergeYears(fs.readdirSync(rawDir).filter(f => f.endsWith('csv'))

La introducción de estos cambios te permite ver los cursos en toda la gama.

{
  CHF: {
    '1984-01-02': 140.84,
    '1984-01-09': 140.08,
    '1984-01-16': 138.62,
...

Para guardar el resultado, añadiremos la línea:

!fs.existsSync(process.cwd() + '/out') && fs.mkdirSync(process.cwd() + '/out', {recursive: true})
fs.writeFileSync(process.cwd() + '/out/chf.json', JSON.stringify(main()))

Ejecución:

time ts-node app.ts

devoluciones:

ts-node app.ts  7.67s user 0.29s system 147% cpu 5.412 total

y creará el archivo /out/chf.json con un peso de 156K.

El archivo del proyecto que contiene 126 líneas de código está disponible en el enlace:

app.ts

Si necesitas estos datos, puedes recrear todos los pasos tú mismo o descargar los datos JSON ya preparados desde el enlace

https://gitlab.com/gustawdaniel/nbp/-/blob/master/out/chf.json

Visualización

No puedo resistir la tentación de dibujar y discutir el tipo de cambio del Franco Suizo una vez que logré extraer las tasas de hace tantos años. Particularmente interesante es el período antes del comienzo del siglo actual y el auge de los préstamos en CHF de 2005-2008.

Preparación del Proyecto

Para dibujar los gráficos, utilizaremos el archivo index.html con el contenido:

<html>
<body>
<script src="./index.ts"></script>
</body>
</html>

y un archivo index.ts vacío. Ahora instalemos parcel

npm install -g parcel-bundler

Después de escribir:

parcel index.html

veremos un mensaje de construcción y un enlace a la página

Después de abrir el enlace y la consola de desarrollador, y luego agregar la línea ***console***.log("test") a index.ts, veremos que la página se recarga automáticamente y “test” se registra en la consola.

Integración de la biblioteca de gráficos

Para dibujar gráficos, utilizaremos Apex Charts.

npm install apexcharts --save

Incluiremos lo siguiente en el cuerpo del archivo index.html:

<main id='chart'></main>

para adjuntar el gráfico. Sin embargo, en index.ts la configuración de un gráfico de acciones simple

import ApexCharts from 'apexcharts'

const options = {
  series: [{
    data: [{
      x: new Date(1538778600000),
      y: [6629.81, 6650.5, 6623.04, 6633.33]
    },
      {
        x: new Date(1538780400000),
        y: [6632.01, 6643.59, 6620, 6630.11]
      }
    ]
  }],
  chart: {
    type: 'candlestick',
    height: 350
  },
  title: {
    text: 'CandleStick Chart',
    align: 'left'
  },
  xaxis: {
    type: 'datetime'
  },
  yaxis: {
    tooltip: {
      enabled: true
    }
  }
};

const chart = new ApexCharts(document.querySelector("#chart"), options);
chart.render().then(console.log).catch(console.error);

Podrías decir - super simple:

Sin embargo, esta simplicidad tiene un propósito. Permite no llenar el artículo con datos de prueba, solo cuando tengamos la estructura de datos para el gráfico podemos realizar la transformación de nuestra estructura extraída de archivos xls.

Organización de datos en el gráfico

Resumamos:

  1. Nuestra estructura
{
  CHF: {
    'YYYY-MM-DD': number,
    ...
  }
}

Estructura para el gráfico:

{
  x: Date,
  y: [number, number, number, number] // open, high, low, close
}[]

Para realizar esta transformación, necesitamos dividir nuestros datos en rangos, lo que significa que debemos elegir cuántas velas debe contener el gráfico. Luego, después de calcular las fechas límite, iteraremos a través de los rangos, seleccionando de las fechas disponibles aquellas que caen dentro del rango, de las cuales a su vez buscaremos los valores de apertura, cierre y extremos.

Comenzaremos importando el archivo con los datos guardados por el script de la sección anterior:

import {CHF} from './out/chf.json'

Para manejar esto correctamente en el archivo tsconfig.json, añadimos la opción resolveJsonModule.

{
  "compilerOptions": {
    "resolveJsonModule": true,
    ...

Ahora definimos la interfaz con los datos de salida

interface StockRecord {
  x: Date,
  y: [number, number, number, number]
}

Para la distribución de la función sobre intervalos, utilizaremos la función:

const splitDateIntoEqualIntervals = (startDate: Date, endDate: Date, numberOfIntervals: number): { start: Date, end: Date, avg: Date }[] => {
  const intervalLength = (endDate.getTime() - startDate.getTime()) / numberOfIntervals
  return [...(new Array(numberOfIntervals))]
    .map((e, i) => {
      return {
        start: new Date(startDate.getTime() + i * intervalLength),
        avg: new Date(startDate.getTime() + (i + 0.5) * intervalLength),
        end: new Date(startDate.getTime() + (i + 1) * intervalLength)
      }
    })
}

descrito en el enlace:

Stack Overflow

La asignación de datos en sí se ha organizado en otra función.

const mapToStockData = (values: { [key: string]: number }, parts: number):StockRecord[] => {
  const entries = Object.entries(values)
  const start = new Date(entries[0][0])
  const end = new Date(entries[entries.length - 1][0])
  const intervals = splitDateIntoEqualIntervals(start, end, parts)

  const stockData: StockRecord[] = []

  while (intervals.length) {
    const int = intervals.shift()
    if (!int) break
    let currDate = int.start
    stockData.push({
      x: int.avg,
      y: [NaN, NaN, NaN, NaN]
    })

    const currStock = stockData[stockData.length - 1]
    let stat = {
      min: Infinity,
      max: -Infinity
    }

    while (currDate < int.end) {
      const [dateString, value] = entries.shift() || []
      if (!dateString || typeof value !== 'number') break
      currDate = new Date(dateString)
      if (isNaN(currStock.y[0])) currStock.y[0] = value
      currStock.y[3] = value
      stat.min = Math.min(stat.min, value)
      stat.max = Math.max(stat.max, value)
    }
    currStock.y[1] = stat.max
    currStock.y[2] = stat.min
  }

  return stockData
}

Este fragmento de código más largo requiere un comentario. Esta tarea podría haberse realizado utilizando filtros de mapa y bucles forEach, pero opté por un doble while con desplazamientos dobles. No es una coincidencia. En este caso, se trata de rendimiento. Aunque esos métodos más de moda y elegantes son siempre mi primera opción, en casos donde reducir la complejidad computacional requiere mantener algún tipo de caché, hago una excepción. La comunicación entre ejecuciones separadas de los métodos map, filter, reduce, forEach es más difícil, requiriendo el uso de variables de mayor alcance. En particular, anidar bucles por defecto asume realizar operaciones n x m donde n y m son las dimensiones de los arreglos. Sin embargo, aquí quiero realizar más bien n + m ejecuciones; no quiero procesar, descartar, filtrar o verificar la misma clave en el objeto de moneda dos veces si no es necesario.

¿Qué ahorros estamos considerando?

Si este código se hubiera escrito de manera ineficiente y no hubiéramos organizado bien las iteraciones, podría parecer más legible y conciso, pero con una granularidad de 500 velas, realizaría 7200 x 500 = 3.6e6 bucles, mientras que tendríamos alrededor de 7200 + 500 = 7.7e4, lo que significa un tiempo de carga aproximadamente 50 veces más corto.

Generar opciones es simplemente una función que coloca datos en la plantilla de configuración de Apex Chart.

const generateOptions = (data: StockRecord[]) => ({
  series: [{
    data
  }],
  chart: {
    type: 'candlestick',
    height: window.innerHeight - 50,
    zoom: {
      autoScaleYaxis: true
    }
  },
  title: {
    text: 'CandleStick Chart',
    align: 'left'
  },
  xaxis: {
    type: 'datetime'
  },
  yaxis: {
    tooltip: {
      enabled: true
    }
  }
})

Al final, la ejecución del programa, es decir, adjuntar datos a la configuración y crear un gráfico utilizando esto:

const chart = new ApexCharts(document.querySelector('#chart'), generateOptions(mapToStockData(CHF, 500)))
chart.render().then(console.log).catch(console.error)

El gráfico se ve genial. Captura perfectamente las realidades del salvaje oeste de las divisas de principios de los 90. Vemos cómo en 1991 la inflación disparó el precio del franco por órdenes de magnitud, y la drástica caída a principios de 1995 causada por la implementación de la ley de denominación del 7 de julio de 1994.

Un problema no detectado resulta ser el escalado incorrecto de 1995.

De hecho, tenemos un cambio en el multiplicador durante el año 1995.

Podemos solucionar este problema agregando líneas que muevan el divisor si su cambio ocurre entre valores, no en el encabezado:

             arr.forEach(localArr => {
                 const date = getDateFromArr(localArr)
+
+                const newSettings = decomposeBaseSettingsFromCodes(localArr)
+                if (Object.keys(newSettings).length) {
+                    Object.keys(settings).forEach(key => {
+                        settings[key].div = newSettings[key].div
+                    })
+                }
+
                 if (typeof date === 'string') {
                     Object.keys(settings).forEach(key => {

El próximo cambio será la introducción de la normalización. Si queremos comparar valores en el gráfico, debemos considerar la denominación. La función nos ayudará con esto.

const denominationFactor = (date:string): number => {
    return Number.parseInt(date.substr(0,4)) <= 1994 ? 1e4 : 1;
}

y incluyendo su resultado en la línea:

settings[key].values.push({[date]: parseFloat(localArr[settings[key].col]) / settings[key].div / denominationFactor(date)})

La regeneración de datos te permite ver el gráfico.

Para realizar la implementación, utilizaremos el servicio de Netlify.

Tasa de Cambio CHF en PLN

Para este propósito, añadimos parcel a las dependencias de desarrollo del proyecto:

 npm install -D parcel-bundler

Y agregamos un comando de construcción en package.json

  "scripts": {
    "build": "parcel build index.html",
  },

Después de seleccionar el directorio dist en el panel de Netlify y ejecutar el comando npm run build, podemos disfrutar de un despliegue CI configurado.

Al final del curso CHF desde finales de los 90 hasta tiempos modernos

Conclusiones

Artículos que ayudaron en la preparación de esta entrada

Other articles

You can find interesting also.