# ============================================================================
# TIPO DE CAMBIO OFICIAL DEL DÓLAR ESTADOUNIDENSE (TCO)
# Distribución de operaciones y estadístico oficial por monto
#
# DESCRIPCIÓN
#   Con este script se procesa el archivo, organizando los datos ya
#   publicados, normaliza la información publicada manteniendo su
#   estructura original, elabora una representación gráfica de
#   operaciones por entidad y tipos de cambio.
#
# FUENTE DE DATOS
#   Archivo CSV, correspondiente a una única fecha de corte, descargado
#   del portal institucional del BCB.
#
# REPRESENTACIÓN GRÁFICA
#   Cada punto corresponde a una combinación Entidad - Tipo de cambio:
#
#       Eje X        : Tipo de cambio (Bs/USD)
#       Eje Y        : Monto transado (USD)
#       Tamaño/color : Número de operaciones
#
# ESTADÍSTICO DE REFERENCIA
#   Hasta el 24/09/2026:
#       Promedio del tipo de cambio ponderado por el monto transado.
#
#   Desde el 25/09/2026:
#       Mediana del tipo de cambio ponderada por el monto transado.
#
# Banco Central de Bolivia
# Asesoría de Política Económica
# ============================================================================


# ============================================================================
# 0. VERIFICACIÓN DE DEPENDENCIAS
# ============================================================================

import sys
import subprocess
import importlib


def verificar_libreria(modulo, paquete=None):
    """
    Verifica si una librería está instalada.
    Si no está disponible, intenta instalarla automáticamente.
    """

    if paquete is None:
        paquete = modulo

    try:
        importlib.import_module(modulo)

    except ImportError:

        print(f"La librería '{paquete}' no está instalada.")
        print("Instalando automáticamente...")

        try:
            subprocess.check_call([
                sys.executable,
                "-m",
                "pip",
                "install",
                paquete
            ])

            print(f"'{paquete}' instalada correctamente.\n")

        except subprocess.CalledProcessError:

            raise RuntimeError(
                f"No fue posible instalar automáticamente '{paquete}'. "
                "Verifique la conexión a Internet y los permisos de Python."
            )


verificar_libreria("numpy")
verificar_libreria("pandas")
verificar_libreria("matplotlib")
verificar_libreria("mplcursors")


# ============================================================================
# 1. IMPORTAR LIBRERÍAS
# ============================================================================

import csv
from decimal import Decimal, ROUND_HALF_UP, localcontext
from pathlib import Path
from datetime import datetime

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import mplcursors

from matplotlib.colors import LinearSegmentedColormap

import tkinter as tk
from tkinter import filedialog, messagebox


# ============================================================================
# 2. FUNCIONES AUXILIARES
# ============================================================================

def limpiar_entero_bcb(valor):
    """Lee números del CSV del BCB sin redondear los montos.

    Admite enteros y decimales con formato español:
        ="22.135.660"     -> 22135660.0
        ="22.135.660,50"  -> 22135660.5
        ="3.086,50"       -> 3086.5
        ="0,75"           -> 0.75
    El nombre se conserva por compatibilidad con el resto del script.
    """
    if valor is None:
        return np.nan

    texto = str(valor).strip().replace('="', '').replace('"', '')
    texto = texto.replace('.', '').replace(',', '.').strip()
    if texto in ('', '-'):
        return np.nan

    try:
        return float(texto)
    except ValueError:
        return np.nan


def limpiar_tipo_cambio(valor):
    """
    Convierte el tipo de cambio del formato del CSV.

    Ejemplo:
        "12,0500" -> 12.05
    """

    if valor is None:
        return np.nan

    texto = str(valor).strip()

    texto = texto.replace('"', '')
    texto = texto.replace(',', '.')
    texto = texto.strip()

    try:
        return float(texto)

    except ValueError:
        return np.nan


def interpretar_fecha(texto):
    """
    Interpreta los formatos de fecha admitidos por el archivo.
    """

    formatos = [
        "%Y-%m-%d",
        "%d/%m/%Y",
        "%d-%m-%Y",
        "%Y/%m/%d"
    ]

    for formato in formatos:

        try:
            return datetime.strptime(texto, formato)

        except ValueError:
            pass

    return None


# ============================================================================
# 3. SELECCIONAR ARCHIVO CSV
# ============================================================================

root = tk.Tk()
root.withdraw()

archivo = filedialog.askopenfilename(
    title="Seleccione el archivo TCO descargado del BCB",
    filetypes=[
        ("Archivos CSV", "*.csv"),
        ("Todos los archivos", "*.*")
    ]
)

if not archivo:

    root.destroy()
    print("\nOperación cancelada.")
    raise SystemExit

archivo = Path(archivo)

print("\nArchivo seleccionado:")
print(archivo)
print()


# ============================================================================
# 4. LEER ARCHIVO
# ============================================================================

with open(
    archivo,
    "r",
    encoding="utf-8-sig"
) as f:

    lineas = f.read().splitlines()


# ============================================================================
# 5. LOCALIZAR CABECERA PRINCIPAL
# ============================================================================

fila_cabecera = None

for i, linea in enumerate(lineas):

    if (
        "Fecha de corte" in linea
        and "TC (En Bs/USD)" in linea
    ):

        fila_cabecera = i
        break


if fila_cabecera is None:

    root.destroy()

    raise ValueError(
        "El archivo no corresponde al formato CSV esperado del TCO del BCB."
    )


# La siguiente fila contiene N° / Monto.
# Los datos comienzan dos filas después.

inicio_datos = fila_cabecera + 2


# ============================================================================
# 6. INTERPRETAR CABECERA
# ============================================================================

cabecera = next(
    csv.reader(
        [lineas[fila_cabecera]],
        delimiter=";",
        quotechar='"'
    )
)

n_columnas = len(cabecera)


# ============================================================================
# 7. IDENTIFICAR ENTIDADES
# ============================================================================

entidades = []

# Python comienza a contar desde cero:
#
# columna MATLAB 4 -> índice Python 3
# columna MATLAB 5 -> índice Python 4

col = 3

# Se excluyen las dos últimas columnas:
# TOTAL BANCOS N° y TOTAL BANCOS Monto.

while col < n_columnas - 2:

    nombre_entidad = cabecera[col].strip()

    if nombre_entidad:

        entidades.append({
            "nombre": nombre_entidad,
            "col_n": col,
            "col_monto": col + 1
        })

    col += 2


if not entidades:

    root.destroy()

    raise ValueError(
        "No se pudieron identificar las entidades en el archivo."
    )


# ============================================================================
# 8. PREPARAR ESTRUCTURAS
# ============================================================================

# TOTAL BANCOS

total_registros = []

# ENTIDAD x TC

registros_entidad = []

# FECHAS DE CORTE

fechas_corte = []


# ============================================================================
# 9. PROCESAR OBSERVACIONES
# ============================================================================

for linea in lineas[inicio_datos:]:

    if not linea.strip():
        continue

    campos = next(
        csv.reader(
            [linea],
            delimiter=";",
            quotechar='"'
        )
    )

    # La fila debe conservar la estructura de la cabecera.

    if len(campos) != n_columnas:
        continue


    # ------------------------------------------------------------------------
    # 9.1 TIPO DE CAMBIO
    # ------------------------------------------------------------------------

    tc = limpiar_tipo_cambio(campos[2])

    # Excluir TOTAL, TCO y cualquier fila no numérica.

    if np.isnan(tc):
        continue


    # ------------------------------------------------------------------------
    # 9.2 FECHA DE CORTE
    # ------------------------------------------------------------------------

    texto_fecha = campos[0].strip().replace('"', '')

    if texto_fecha:
        fechas_corte.append(texto_fecha)


    # ------------------------------------------------------------------------
    # 9.3 TOTAL BANCOS
    # ------------------------------------------------------------------------

    n_total = limpiar_entero_bcb(campos[-2])
    monto_total = limpiar_entero_bcb(campos[-1])

    if not np.isnan(n_total) and not np.isnan(monto_total):

        total_registros.append({
            "tc": tc,
            "tc_decimal": campos[2].strip().replace('"', '').replace(',', '.'),
            "monto_decimal": campos[-1].strip().replace('="', '').replace('"', '').replace('.', '').replace(',', '.'),
            "operaciones": n_total,
            "monto": monto_total
        })


    # ------------------------------------------------------------------------
    # 9.4 INFORMACIÓN POR ENTIDAD
    # ------------------------------------------------------------------------

    for entidad_item in entidades:

        n = limpiar_entero_bcb(
            campos[entidad_item["col_n"]]
        )

        monto = limpiar_entero_bcb(
            campos[entidad_item["col_monto"]]
        )

        # Para representar un punto deben existir
        # operaciones y monto positivo.

        if (
            not np.isnan(n)
            and not np.isnan(monto)
            and n > 0
            and monto > 0
        ):

            registros_entidad.append({
                "entidad": entidad_item["nombre"],
                "tc": tc,
                "operaciones": n,
                "monto": monto
            })


# ============================================================================
# 10. VALIDACIONES BÁSICAS
# ============================================================================

df_total = pd.DataFrame(total_registros)

df_entidades = pd.DataFrame(registros_entidad)


if df_total.empty:

    root.destroy()

    raise ValueError(
        "No se encontraron datos válidos en TOTAL BANCOS."
    )


if df_entidades.empty:

    root.destroy()

    raise ValueError(
        "No se encontraron datos válidos por entidad."
    )


# ============================================================================
# 11. VALIDAR QUE EL ARCHIVO CORRESPONDA A UNA SOLA FECHA
# ============================================================================

fechas_unicas = sorted(
    set(
        fecha
        for fecha in fechas_corte
        if fecha.strip()
    )
)


if len(fechas_unicas) == 0:

    messagebox.showerror(
        "Fecha de corte no identificada",
        "No se pudo identificar la fecha de corte del archivo.\n\n"
        "No se realizará el cálculo."
    )

    root.destroy()
    raise SystemExit


if len(fechas_unicas) != 1:

    messagebox.showerror(
        "Archivo con múltiples fechas",
        "El archivo seleccionado contiene información de más de una "
        "fecha de corte.\n\n"
        "Para realizar el cálculo debe seleccionar un archivo "
        "correspondiente a una única fecha."
    )

    root.destroy()
    raise SystemExit


fecha_corte_texto = fechas_unicas[0]


# ============================================================================
# 12. INTERPRETAR FECHA DE CORTE
# ============================================================================

fecha_corte = interpretar_fecha(
    fecha_corte_texto
)


if fecha_corte is None:

    messagebox.showerror(
        "Fecha de corte no válida",
        "No fue posible interpretar la fecha de corte del archivo:\n\n"
        f"{fecha_corte_texto}\n\n"
        "No se realizará el cálculo."
    )

    root.destroy()
    raise SystemExit


fecha_visible = fecha_corte.strftime(
    "%d/%m/%Y"
)


# ============================================================================
# 13. DETERMINAR METODOLOGÍA
# ============================================================================

fecha_cambio_metodologia = datetime(
    2026,
    9,
    25
)


if fecha_corte < fecha_cambio_metodologia:

    metodologia = "PROMEDIO"
    nombre_estadistico = "Promedio ponderado"

    respuesta = messagebox.askokcancel(
        "Metodología de cálculo",
        "La fecha de corte seleccionada es anterior al 25/09/2026.\n\n"
        "Para esta fecha corresponde calcular el promedio ponderado "
        "del tipo de cambio por el monto transado.\n\n"
        "Presione Aceptar para continuar."
    )

    if not respuesta:

        root.destroy()
        print("\nOperación cancelada.")
        raise SystemExit

else:

    metodologia = "MEDIANA"
    nombre_estadistico = "Mediana ponderada"


# Ya no se necesitan más ventanas de Tkinter.

root.destroy()


# ============================================================================
# 14. CALCULAR TOTALES
# ============================================================================

operaciones_total_csv = (
    df_total["operaciones"].sum()
)

monto_total_csv = (
    df_total["monto"].sum()
)


# ============================================================================
# 15. CALCULAR ESTADÍSTICO
# ============================================================================

# El estadístico para publicación se obtiene de las cadenas decimales originales.
# No se redondean ni los tipos de cambio ni los montos intermedios.
with localcontext() as ctx:
    ctx.prec = 60
    registros_decimales = [
        (Decimal(fila["tc_decimal"]), Decimal(fila["monto_decimal"]))
        for fila in total_registros
    ]
    monto_exacto = sum((m for _, m in registros_decimales), Decimal(0))
    if monto_exacto <= 0:
        raise ValueError("El monto total debe ser positivo.")

    if metodologia == "PROMEDIO":
        estadistico_exacto = (
            sum((tc * monto for tc, monto in registros_decimales), Decimal(0))
            / monto_exacto
        )
    else:
        acumulado = Decimal(0)
        estadistico_exacto = None
        for tc, monto in sorted(registros_decimales, key=lambda par: par[0]):
            acumulado += monto
            if acumulado >= monto_exacto / 2:
                estadistico_exacto = tc
                break
        if estadistico_exacto is None:
            raise ValueError("No fue posible calcular la mediana ponderada.")

    # Regla general, no específica del ejemplo del11,725: 0,005 redondea hacia arriba. Verificaod 
    estadistico_publicado = estadistico_exacto.quantize(
        Decimal("0.01"), rounding=ROUND_HALF_UP
    )
    estadistico_texto = format(estadistico_publicado, ".2f")
    estadistico = float(estadistico_exacto)  # Sólo para posición de línea gráfica.


# ============================================================================
# 16. RESULTADOS PRINCIPALES
# ============================================================================

print()
print("=" * 72)
print(" BANCO CENTRAL DE BOLIVIA")
print(" ASESORÍA DE POLÍTICA ECONÓMICA")
print("=" * 72)
print(" TIPO DE CAMBIO OFICIAL DEL DÓLAR ESTADOUNIDENSE (TCO)")
print(" Distribución de operaciones y estadístico oficial por monto")
print("-" * 72)

print(
    f" Archivo                    : "
    f"{archivo.name}"
)

print(
    f" Fecha de corte             : "
    f"{fecha_visible}"
)

print(
    f" Entidades identificadas    : "
    f"{len(entidades):,}"
)

print(
    f" Entidades con operaciones  : "
    f"{df_entidades['entidad'].nunique():,}"
)

print(
    f" Observaciones Entidad x TC : "
    f"{len(df_entidades):,}"
)

print(
    f" Total operaciones publicado: "
    f"{operaciones_total_csv:,.0f}"
)

print(
    f" Monto total publicado      : "
    f"USD {monto_total_csv:,.2f}"
)

print("-" * 72)

print(
    f" {nombre_estadistico:<27}: "
    f"{estadistico_texto} Bs/USD"
)

print("=" * 72)


# ============================================================================
# 17. PREPARAR VARIABLES PARA EL GRÁFICO
# ============================================================================

x = (
    df_entidades["tc"]
    .to_numpy()
)

y = (
    df_entidades["monto"]
    .to_numpy()
)

operaciones = (
    df_entidades["operaciones"]
    .to_numpy()
)

entidad = (
    df_entidades["entidad"]
    .to_numpy()
)


# Tamaño de los puntos.
#
# La raíz cuadrada evita que las observaciones con muchas
# operaciones dominen excesivamente la representación.

tamano = (
    25
    + 8 * np.sqrt(operaciones)
)


# ============================================================================
# 18. CREAR PALETA DE COLORES
# ============================================================================

# Valores bajos  = azul petróleo
# Valores altos  = rojo intenso
#
# El extremo superior de la escala corresponde exactamente al rojo.

color_bajo = (
    0.07,
    0.32,
    0.38
)

color_alto = (
    0.70,
    0.00,
    0.12
)

mapa_color = LinearSegmentedColormap.from_list(
    "tco_operaciones",
    [
        color_bajo,
        color_alto
    ],
    N=256
)


# ============================================================================
# 19. CREAR GRÁFICO
# ============================================================================

fig, ax = plt.subplots(
    figsize=(14, 7.5)
)


puntos = ax.scatter(
    x,
    y,
    s=tamano,
    c=operaciones,
    cmap=mapa_color,
    vmin=np.min(operaciones),
    vmax=np.max(operaciones),
    alpha=0.85
)


# ============================================================================
# 20. LÍNEA DEL ESTADÍSTICO
# ============================================================================

ax.axvline(
    x=estadistico,
    linestyle="--",
    linewidth=2
)


# Etiqueta junto a la línea.

ax.text(
    estadistico,
    0.50,
    f"{nombre_estadistico} = {estadistico_texto}",
    transform=ax.get_xaxis_transform(),
    rotation=90,
    va="center",
    ha="right"
)


# ============================================================================
# 21. FORMATO DEL GRÁFICO
# ============================================================================

ax.set_xlabel(
    "Tipo de cambio (Bs/USD)",
    fontsize=12
)

ax.set_ylabel(
    "Monto transado (USD)",
    fontsize=12
)


ax.set_title(
    "Distribución de montos por entidad y tipo de cambio"
    f" | Fecha de corte: {fecha_visible}",
    fontsize=14,
    fontweight="bold",
    pad=25
)


# Segundo título con los principales resultados.

ax.text(
    0.5,
    1.01,
    (
        f"{nombre_estadistico} = "
        f"{estadistico_texto} Bs/USD"
        f"   |   Operaciones = "
        f"{operaciones_total_csv:,.0f}"
        f"   |   Monto total = "
        f"USD {monto_total_csv:,.2f}"
    ),
    transform=ax.transAxes,
    ha="center",
    va="bottom",
    fontsize=11
)


ax.grid(
    True,
    alpha=0.3
)


# El eje X conserva automáticamente el rango de los datos.
# Pequeño margen, sólo visual, en laa parte inferior para que los puntos sobre Y=0
# se muestren completos y no se cortesn.

margen_y = max(y) * 0.02

ax.set_ylim(
    bottom=-margen_y,
    top=max(y) * 1.05
)

# Las marcas visibles del eje Y comienzan en cero.
ticks_y = ax.get_yticks()
ax.set_yticks(ticks_y[ticks_y >= 0])

# Separador de miles en los valores visibles del eje Y.

ax.yaxis.set_major_formatter(
    plt.FuncFormatter(
        lambda valor, posicion:
        f"{valor:,.0f}"
    )
)


# ============================================================================
# 22. BARRA DE COLOR
# ============================================================================

barra_color = fig.colorbar(
    puntos,
    ax=ax
)

barra_color.set_label(
    "Número de operaciones"
)


# Separador de miles en la barra de color.

barra_color.ax.yaxis.set_major_formatter(
    plt.FuncFormatter(
        lambda valor, posicion:
        f"{valor:,.0f}"
    )
)

barra_color.update_ticks()


# ============================================================================
# 23. INFORMACIÓN INTERACTIVA DE LOS PUNTOS
# ============================================================================

# Al mover el cursor sobre un punto se muestran:
#
#   Entidad
#   Tipo de cambio
#   Monto
#   Número de operaciones
#
# Formato:
#
#   Tipo de cambio : 2 decimales
#   Monto          : 2 decimales y separador de miles
#   Operaciones    : entero y separador de miles

cursor = mplcursors.cursor(
    puntos,
    hover=True
)


@cursor.connect("add")
def mostrar_datos(sel):

    i = sel.index

    sel.annotation.set_text(
        f"Entidad: {entidad[i]}\n"
        f"Tipo de cambio: {x[i]:.2f} Bs/USD\n"
        f"Monto: USD {y[i]:,.2f}\n"
        f"Operaciones: {operaciones[i]:,.0f}"
    )


# ============================================================================
# 24. MOSTRAR GRÁFICO
# ============================================================================

plt.tight_layout()

plt.show()