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
• 29 min read
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:
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:
- Adquisición de datos
- Procesamiento de datos
- 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
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:
- fecha AAAA-MM-DD - porque ordena convenientemente y es un formato de fecha natural en muchos idiomas
- moneda - usando el código ISO_4217 (código de moneda de 3 letras)
- tipo de cambio - usando un formato con un punto para denotar fracciones
Convención de estructura de datos (composición):
- JSON en el que la primera clave es la moneda y la segunda es la fecha, el valor es el valor en złoty - este formato permite una fácil búsqueda por moneda y luego por fecha, se proyecta convenientemente en relación con las monedas. A pesar de la sobrecarga en términos de volumen en comparación con CSV, la facilidad de procesamiento posterior es el factor decisivo aquí.
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.
- Necesitamos buscar el nombre del país en la primera fila.
- Basándonos en esto, determina la columna
col
donde se encuentra los datos. - En la segunda fila, la columna
col
contiene el divisordiv
- Más tarde, solo tomamos aquellas filas que contienen una fecha en la primera columna.
- En estas filas, la columna
col
contiene un valor que debe ser dividido por el divisordiv
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:
- No
- fecha
- Moneda/Moneda
- .tab
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?
- Divide el valor entre el divisor
div
y el código de moneda utilizandomatch
- No necesita una declaración
shift
adicional para extraer el divisor
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:
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:
- agregar un tipo de resultado
interface OutData {
[key: string]: {
[key: string]: number
}
}
- 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:
Si necesitas estos datos, puedes recrear todos los pasos tú mismo o descargar los datos JSON ya preparados desde el enlace
TODO: esta página no encontrada
https://chf-pnl.netlify.app/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:
- 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:
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.
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.
Fetch, Promise y Template String en el ejemplo de Lista de Tareas en JavaScript
Este proyecto simple es excelente como introducción a la programación en JavaScript. El énfasis está en los elementos de ES6 y el frontend.
Daniel Gustaw
• 14 min read
tRPC - ciclo de desarrollo súper rápido para aplicaciones fullstack en TypeScript
Estamos construyendo un cliente y servidor tRPC con consultas, mutaciones, autenticación y suscripciones. La autenticación para websocket puede ser complicada y en este caso lo es, por lo que se presentan tres enfoques para resolver este problema.
Daniel Gustaw
• 16 min read
Calendario estilo Git con fechas personalizadas
calendario estilo git creado a partir de una lista de fechas guardadas como archivo csv
Daniel Gustaw
• 2 min read