# Migración scraper bursátil: Python → Node.js + Prisma ## Contexto Hoy el scraper vive en `Scrapper-Online` (Python): 4 scrapers (HomeBroker/pyhomebroker, IOL, PyRofex, Matriz), desordenado por iteraciones sucesivas — código duplicado entre `src/` y `utils/`, dos esquemas de DB compitiendo, credenciales en texto plano, sin tests organizados, sin chequeo de frescura de datos ni fallback entre fuentes. Ese repo escribe a Postgres (`scrapper_db`, `147.79.86.181:5498`), consumida directamente (SQL crudo) por el frontend Next.js `data_bursatil` vía las tablas `acciones`, `bonos_ars`, `bonos_usd`, `cedears`, `opciones`. **Objetivo**: reemplazarlo por un backend nuevo en Node.js + TypeScript con Prisma, en este repo (`backend_scrapper`). Restricción dura: **no romper `data_bursatil`** — sigue leyendo las mismas 5 tablas con las mismas columnas, sin tocar ese repo. ## Decisiones tomadas (no son abiertas, son restricciones de diseño) 1. **PyHomeBroker**: Fase 1 = sidecar Python mínimo (solo pyhomebroker) que empuja ticks al backend Node. Fase 2 (futura, fuera de este plan) = cliente SignalR 2.x puro en Node. 2. **Alcance de fuentes**: solo HomeBroker (vía sidecar) + IOL. PyRofex y Matriz quedan afuera. 3. **Prisma**: se introspecta la base existente (`prisma db pull`) — nunca se migra/empuja schema sobre las 5 tablas legacy. Solo se agrega `SourceStatus` con su propia migración aislada. 4. **API HTTP**: solo `/health` y `/status` (monitoreo). `data_bursatil` sigue leyendo la DB directo. 5. **Carry-forward**: se elimina en la escritura a DB (solo INSERT con tick real). Se mantiene como cache en memoria únicamente para Google Sheets (ver `07-google-sheets.md`). 6. **Google Sheets**: se mantiene, arreglando el bug actual de doble-escritura pisándose entre HomeBroker e IOL. 7. **Estado de failover**: persistido en Postgres (`SourceStatus`), sobrevive restarts/deploys. ## Hallazgos clave (verificados leyendo código fuente) - Tablas legacy son **histórico append-only** (`data_bursatil` deriva "precio actual" con `SELECT DISTINCT ON (simbolo) ... ORDER BY simbolo, timestamp DESC`). No hay tabla "latest" separada. Escrituras nuevas = INSERT, nunca upsert. - Protocolo HomeBroker: login `POST {page}/Login/Ingresar` (form-urlencoded, fallback JSON a `/Login/IngresarModal` en 500), éxito = `#usuarioLogueado` en HTML, cookies reusadas en SignalR. Broker 284 = Veta Capital, `page=http://cuentas.vetacapital.com.ar`. Real-time: `{page}/signalr/hubs`, hub `stockpriceshub`, **ASP.NET SignalR 2.x** (NO compatible con `@microsoft/signalr` de npm, que es SignalR Core). Detalle completo en `HOMEBROKER_PROTOCOL.md`. - IOL: OAuth2 (`https://api.invertironline.com/token`), 4 bulk + hasta 30 individuales, ~12 min de cadencia, ~34 req/ciclo contra cupo de 25.000/mes. Detalle completo en `IOL_API.md`. - Google Sheets hoy escribe una sola hoja `'Data'` con sobreescritura completa cada ciclo — **roto**: HomeBroker e IOL corren como procesos separados y se pisan la hoja mutuamente. El sistema nuevo lo arregla al ser un solo proceso con snapshot combinado. - **Importante, cambia el plan de corte**: la DB real de este proyecto es `db-arg-bursatil` (`147.79.86.181:5437`), separada de `scrapper_db` (`147.79.86.181:5498`) que usa `Scrapper-Online`/lee hoy `data_bursatil`. Arrancó vacía y las 5 tablas legacy se migraron a mano replicando tipos exactos (ver ADR-5 en [ARCHITECTURE.md](ARCHITECTURE.md)). Consecuencia: el corte final no es "dos escritores a la misma base, apagar uno" — en algún momento hay que cambiar el `DATABASE_URL` de `data_bursatil` para que apunte a `db-arg-bursatil`. Detalle del checklist corregido en [08-migracion-cutover.md](08-migracion-cutover.md). ## Arquitectura ``` ┌─────────────────────────┐ Veta Capital <--->│ homebroker-sidecar (Py) │--push ticks (HTTP)-->┐ (SignalR 2.x) │ solo: auth+subscribe+buf │ │ └─────────────────────────┘ │ v IOL REST API <----------------------------------- iolSource.ts ---> backend_scrapper (Node/TS) │ ┌────────────┼─────────────┐ v v v priceRepository snapshotCache statusStore (Prisma, INSERT) (en memoria, (Prisma, │ carry-forward SourceStatus) v solo Sheets) │ scrapper_db v (5 tablas legacy, GET /health sin tocar schema) GET /status ^ │ data_bursatil (Next.js) lee directo, sin cambios ``` ## Hitos (estado) | Hito | Descripción | Checklist | Estado | |------|-------------|-----------|--------| | M1 | Scaffolding + Prisma verificado | [01-scaffolding.md](01-scaffolding.md), [02-prisma.md](02-prisma.md) | ☑ Completo (Docker build sin validar en este entorno, ver nota Avast en ARCHITECTURE.md; Prisma migrado a v7/driver adapters, ver actualización en 02-prisma.md) | | M2 | Fuente IOL completa, escribiendo end-to-end | [03-iol-source.md](03-iol-source.md) | ☑ Implementado y testeado (unit tests con fixtures); corrida real contra la API de IOL con `WRITE_ENABLED=true` pendiente — no hay `IOL_USER`/`IOL_PASSWORD` reales en este entorno | | M3 | Sidecar HomeBroker + puente Node, sin carry-forward | [04-homebroker-sidecar.md](04-homebroker-sidecar.md) | ☑ Implementado y testeado (unit tests, API de pyhomebroker verificada contra el código fuente real); corrida en vivo contra Veta Capital pendiente — no hay credenciales reales (`HB_DNI`/`HB_USER`/`HB_PASSWORD`) en este entorno | | M4 | Freshness + failover + SourceStatus + API status | [05-scheduler-failover.md](05-scheduler-failover.md), [06-status-api.md](06-status-api.md) | ☑ Completo (05 y 06): `freshnessMonitor.ts`, `failoverStateMachine.ts`, `orchestrator.ts`, `secondsUntilMarketOpen`, schema de respuesta de Fastify en `/health`/`/status`, tests con `statusStore` mockeado en distintos estados. Pendiente real: prueba manual de failover forzado (sin sidecar/credenciales reales en este entorno) | | M5 | Google Sheets + docs + validación en paralelo + corte | [07-google-sheets.md](07-google-sheets.md), [08-migracion-cutover.md](08-migracion-cutover.md), [09-testing.md](09-testing.md) | ☑ Parte 1 completa (07): `snapshotCache.ts`, `googleSheetsClient.ts` (`google-spreadsheet`+`google-auth-library`), `sheetsSyncService.ts`, hook en `tickIngressServer.ts`/`iolSource.ts`, tests con cliente mockeado. Pendiente real: verificación visual contra spreadsheet real (sin credenciales reales en este entorno). Parte 3 (09) mayormente completa, ver detalle ahí (falta integración `priceRepository.ts` con testcontainers — requiere Docker corriendo). Parte 2 (08): deploy real confirmado en `https://arg-bursatil-scrapper.sitemaster.com.ar` (`/health` y `/status` operativos), checklist de corte reescrito tras descubrir que `data_bursatil` deberá repuntar su `DATABASE_URL` (ver hallazgo abajo) — pasos de corte real sin ejecutar todavía | ## Referencias técnicas (no checklists) - [HOMEBROKER_PROTOCOL.md](HOMEBROKER_PROTOCOL.md) — protocolo SignalR 2.x de pyhomebroker, para Fase 2 futura. - [IOL_API.md](IOL_API.md) — endpoints y mapeo de campos de IOL. - [ARCHITECTURE.md](ARCHITECTURE.md) — diagrama de flujo + ADRs (carry-forward, atribución de fuente). - [RUNBOOK.md](RUNBOOK.md) — operación (se completa en M5). --- # M1 (parte 1) — Scaffolding del proyecto Ver contexto general en [00-INDEX.md](00-INDEX.md). ## Objetivo Tener el esqueleto del repo Node/TypeScript funcionando (lint, build, dev) antes de escribir lógica de negocio. ## Checklist ### Tooling - [x] `package.json` inicial (npm), Node 20/22 LTS como target - [x] TypeScript: `tsconfig.json` (ES2022, module NodeNext) - [x] `tsx` para dev (`tsx watch src/index.ts`), `tsc` para build a `dist/` - [x] ESLint (`typescript-eslint`) + Prettier, ruleset mínimo - [x] Fastify como servidor HTTP (rutas `/health`, `/status`; ingreso de ticks del sidecar queda para M3) - [x] `pino` para logs estructurados (JSON, con campo `source`: homebroker/iol/scheduler/api) - [x] `luxon` para manejo de horario ART - [x] `zod` para validar env vars y configs - [x] `dotenv` para desarrollo local (`.env.example` documentado) - [x] `fetch` nativo de Node para llamadas HTTP a IOL — no agregar `axios` ### Estructura de carpetas - [x] `src/config/{env.ts, instruments.ts, market-hours.ts}` - [x] `src/db/{client.ts, priceRepository.ts}` - [x] `src/sources/types.ts` (interfaz `Source` común) - [ ] `src/sources/iol/{iolAuthClient.ts, iolHttpClient.ts, iolMapper.ts, iolSource.ts}` — diferido a M2, ver [03-iol-source.md](03-iol-source.md) - [ ] `src/sources/homebroker/{sidecarClient.ts, tickIngressServer.ts, homebrokerMapper.ts, homebrokerSource.ts}` — diferido a M3, ver [04-homebroker-sidecar.md](04-homebroker-sidecar.md) - [ ] `src/scheduler/{orchestrator.ts, freshnessMonitor.ts, failoverStateMachine.ts}` — diferido a M4, ver [05-scheduler-failover.md](05-scheduler-failover.md) - [ ] `src/sheets/{googleSheetsClient.ts, snapshotCache.ts, sheetsSyncService.ts}` — diferido a M5, ver [07-google-sheets.md](07-google-sheets.md) - [x] `src/api/{server.ts, routes/health.ts, routes/status.ts, statusStore.ts}` - [x] `src/lib/{logger.ts, time.ts}` - [x] `src/index.ts` (entrypoint: boot de config, db, api) - [x] `homebroker-sidecar/` (stub mínimo: solo `/health`; lógica real de pyhomebroker en M3 — ver [04-homebroker-sidecar.md](04-homebroker-sidecar.md)) - [x] `config/{instruments.csv, options.csv}` — versión limpia/deduplicada de los CSV viejos (deduplicado TXAR) - [x] `test/api/` (health, status) — `test/{iol/, scheduler/, db/}` se agregan cuando esas piezas existan (M2-M4) ### Scripts de package.json - [x] `dev`: `tsx watch src/index.ts` - [x] `build`: `tsc -p tsconfig.json` - [x] `start`: `node dist/index.js` - [x] `lint` / `format` - [x] `prisma:pull`: `prisma db pull` (solo introspección) - [x] `prisma:generate`: `prisma generate` - [x] `prisma:migrate:new-tables`: `prisma migrate dev` (documentado: NUNCA corre contra las 5 tablas legacy salvo la excepción de M1, ver ADR-5 en [ARCHITECTURE.md](ARCHITECTURE.md)) - [x] `test`: vitest ### Docker / Dokploy - [x] `Dockerfile` del backend Node (multi-stage: deps → build → runtime `node:20-alpine`) - [x] `docker-compose.yml` con 2 servicios: `backend` (Node) y `homebroker-sidecar` (Python), red interna compartida - [x] Healthchecks: backend pega a su propio `/health`; sidecar pega al suyo - [x] `restart: unless-stopped`, `TZ=America/Argentina/Buenos_Aires`, logging con cap de tamaño (igual que el compose viejo) - [x] `depends_on` solo advisory (sin `condition: service_healthy` obligatorio — Node debe tolerar arrancar con el sidecar caído, es parte del diseño de failover) - [x] `.dockerignore`, `.gitignore` ## Verificación - [x] `npm run dev` levanta el proceso sin errores - [x] `npm run build && npm run start` corre el build compilado - [ ] `docker compose up` levanta ambos contenedores sin crashear — bloqueado localmente por inspección TLS de Avast Antivirus en esta máquina (ver nota en [ARCHITECTURE.md](ARCHITECTURE.md)); `docker compose build`/`up` no es problema de código, falta validar en un entorno sin ese proxy (Dokploy u otra máquina) --- # M1 (parte 2) — Prisma Ver contexto general en [00-INDEX.md](00-INDEX.md). ## Objetivo Conectar Prisma a la base real `scrapper_db` por introspección, sin escribir nada todavía, y dejar la capa de repositorio lista para que las fuentes (IOL, HomeBroker) la usen en los hitos siguientes. ## Regla de oro **Nunca correr `prisma migrate` ni `prisma db push` contra las 5 tablas legacy** (`acciones`, `bonos_ars`, `bonos_usd`, `cedears`, `opciones`). Son de solo-introspección para siempre, porque `data_bursatil` las lee directo con SQL crudo y no se puede arriesgar a romper ese contrato. La única tabla con migración propia es `SourceStatus`. > **Excepción aplicada en M1**: la DB asignada a este proyecto (`db-arg-bursatil`, `147.79.86.181:5437`) arrancó vacía, sin las 5 tablas legacy. Se migraron a mano (no por introspección) replicando los tipos exactos de `Scrapper-Online/utils/iol_models.py`. Detalle completo en el ADR-5 de [ARCHITECTURE.md](ARCHITECTURE.md). A partir de ahora la regla de oro aplica normalmente: nada de cambios destructivos sobre estas 5 tablas. ## Checklist ### Introspección / creación inicial - [x] Confirmar cuál es el `DATABASE_URL` real y vigente — la DB de este proyecto es `db-arg-bursatil` en `147.79.86.181:5437` (no `scrapper_db`/`juli-vet`, que son de `Scrapper-Online`) - [x] `npx prisma db pull` no aplica (DB vacía) — en su lugar se escribió `schema.prisma` a mano y se migró (ver excepción arriba) - [x] Revisar el `schema.prisma` por sorpresas — no aplica, DB nueva sin restos de experimentos viejos (no se incluyó `futuros`, fuera de alcance) - [x] Confirmar tipos reales vs lo esperado — tomados directo de `iol_models.py` (SQLAlchemy), no inventados - [x] `npx prisma generate` - [x] Script descartable: leído `MAX(timestamp)` de `acciones` vía Prisma Client contra la DB real (`null`, como corresponde a una tabla recién creada) — conecta y tipa bien ### Columnas esperadas (confirmadas contra `Scrapper-Online/utils/iol_models.py`) - [x] `id` — Int, autoincrement - [x] `simbolo` — String, NOT NULL - [x] `descripcion` — String, nullable - [x] `currency` — String, NOT NULL - [x] `timestamp` — DateTime, NOT NULL (sin timezone — `Timestamp(6)` sin tz, igual que el `DateTime` de SQLAlchemy) - [x] `fecha` — Date, nullable - [x] `ultimo_ci, bid_ci, ask_ci` — Decimal, nullable - [x] `cant_bid_ci, cant_ask_ci` — Int, nullable - [x] `ultimo_24hs, bid_24hs, ask_24hs` — Decimal, nullable - [x] `cant_bid_24hs, cant_ask_24hs` — Int, nullable - [x] `apertura, maximo, minimo, cierre_anterior` — Decimal, nullable - [x] `variacion_pct` — Decimal, nullable - [x] `volumen` — Decimal, nullable - [x] Solo en `opciones`: `tipo_opcion, activo_subyacente, precio_ejercicio, fecha_vencimiento` - [x] Índice `(simbolo, timestamp)` — sin unique constraint, igual que el modelo viejo ### Tabla nueva: SourceStatus - [x] Agregada en `schema.prisma` (con `@@map`/`@map` a snake_case: `source_status`, `asset_class`, etc.) - [x] Migración `prisma migrate dev --name init_legacy_and_source_status` aplicada (incluyó las 5 legacy por la excepción de DB vacía + `SourceStatus`) - [x] Documentado en `ARCHITECTURE.md` (ADR-5) que las migraciones futuras solo tocan `SourceStatus`/tablas operativas nuevas, nunca las 5 legacy ya creadas ### Capa de repositorio — `src/db/priceRepository.ts` - [x] `insertAccion(row)`, `insertBonoArs(row)`, `insertBonoUsd(row)`, `insertCedear(row)`, `insertOpcion(row)` — INSERT puro (`prisma.X.create`), nunca upsert - [x] Variantes batch: `insertAccionesBatch(rows)`, etc. (`prisma.X.createMany`) - [x] Guard de dedup en memoria: `shouldSkipDuplicate(assetClass, simbolo, fecha)` — `Map` en memoria, sin pegarle a la DB en cada insert - [x] `getLastWriteTimestamp(assetClass)` — lectura usada por `freshnessMonitor.ts` (a implementar en M4, ver [05-scheduler-failover.md](05-scheduler-failover.md)) - [x] Todas las funciones reciben DTOs ya mapeados (`PriceRow`/`OptionPriceRow` de `src/sources/types.ts`) - [x] Manejo de `Decimal` de Prisma: los tipos de `PriceRow` usan `number` en el borde DTO (el mapeo fuente→DTO se define en M2/M3); Prisma mantiene `Decimal` internamente en la capa de DB, sin conversión a string/number dentro de `priceRepository.ts` ## Verificación - [x] Lectura de `MAX(timestamp)` por Prisma Client funciona contra la DB real - [ ] Insertar una fila de prueba en un esquema de prueba (testcontainers, NO producción) y confirmar que `getLastWriteTimestamp` la detecta — diferido: requiere levantar un schema/DB de prueba separado (testcontainers), no se hizo todavía ni en M2 ni en M3 porque ambos corrieron con `WRITE_ENABLED=false` (ver [03-iol-source.md](03-iol-source.md) y [04-homebroker-sidecar.md](04-homebroker-sidecar.md)). Sigue pendiente. - [x] Migración de `SourceStatus` aplicada (junto con las 5 legacy, por la excepción de DB vacía); confirmado con `prisma migrate status` y query directa a `information_schema.tables` ## Actualización posterior: migración a Prisma 7 (driver adapters) Prisma 7 eliminó el motor de conexión interno (Rust engine) y ya no soporta `url` en el bloque `datasource` del `schema.prisma` — la conexión real la maneja un **driver adapter** explícito, pasado al constructor de `PrismaClient`. Cambios aplicados: - `prisma`/`@prisma/client` actualizados a `^7.8.0`, se agregó `@prisma/adapter-pg`. - `schema.prisma`: generator cambiado a `provider = "prisma-client"` con `output = "../src/generated/prisma"` (adentro de `src/` a propósito, para que quede bajo el `rootDir` del `tsconfig.json` y `tsc` lo compile sin configuración extra). El bloque `datasource` ya no tiene `url`. - `prisma.config.ts` (nuevo, en la raíz del repo): declara `datasource.url` desde `DATABASE_URL` para que `prisma migrate`/`prisma db pull`/`prisma generate` sepan a qué base conectarse. - `src/db/client.ts`: ahora crea un `PrismaPg({ connectionString: env.DATABASE_URL })` y lo pasa como `{ adapter }` a `new PrismaClient(...)`. - `src/generated/prisma/` (gitignored) es el output del generator — no se commitea, se regenera con `npm run prisma:generate`. **Nota de entorno**: en esta máquina de desarrollo, `prisma generate` necesitó `NODE_OPTIONS=--use-system-ca` para bajar los binarios de los engines — es la misma inspección TLS de Avast ya documentada en [ARCHITECTURE.md](ARCHITECTURE.md) (notas operativas), no un problema de configuración de Prisma. --- # M2 — Fuente IOL Ver contexto general en [00-INDEX.md](00-INDEX.md) y referencia de endpoints en [IOL_API.md](IOL_API.md). ## Objetivo Portar el scraper IOL (`Scrapper-Online/src/iol/scraper.py` + `utils/iol_client.py`) a Node, escribiendo a las 5 tablas vía `priceRepository.ts`. Es la fuente más simple (REST puro, sin protocolo raro) — sirve también para validar Prisma end-to-end antes de meterse con el sidecar de HomeBroker. ## Checklist ### Config de instrumentos (arreglo de una inconsistencia del repo viejo) - [x] `config/instruments.csv` limpio: dedupe de símbolos duplicados (ej. TXAR aparecía dos veces en el CSV viejo) — ya se había hecho en M1 (ver [01-scaffolding.md](01-scaffolding.md)), confirmado sin duplicados al revisar el archivo en M2 - [x] `config/options.csv` limpio (equivalente a `options_to_track.csv`) — ya existía de M1 - [x] `src/config/instruments.ts`: carga los CSV UNA vez al boot — ya existía de M1; `iolSource.ts` lo usa como única fuente de verdad para qué símbolos trackear ### `src/sources/iol/iolAuthClient.ts` - [x] Estado en memoria: `accessToken`, `refreshToken`, `expiresAt` - [x] `authenticate()`: `POST https://api.invertironline.com/token` form-encoded `{ username, password, grant_type: "password" }` - [x] `refresh()`: mismo endpoint, `{ grant_type: "refresh_token", refresh_token }` - [x] `ensureToken()`: refresca si `Date.now()` pasó `expiresAt` (buffer de 120s, igual que el Python), si el refresh falla cae a `authenticate()` completo - [x] `getAuthHeader()` para que el http client lo consuma ### `src/sources/iol/iolHttpClient.ts` - [x] `getCotizacionesBulk(tipo)`: `GET /api/v2/Cotizaciones/{tipo}/argentina/Todos` para `tipo` en `acciones|cedears|titulospublicos|opciones`, devuelve el array `titulos` - [x] `getCotizacion(simbolo, plazo, mercado="bCBA")`: `GET /api/v2/{mercado}/Titulos/{simbolo}/Cotizacion?plazo={t0|t1}`, devuelve `null` en 404 (no es error, es "no encontrado a ese plazo") - [x] Timeout razonable (~15s), llama `ensureToken()` antes de cada request - [x] Errores no-2xx (excepto 404 manejado) se loguean y se salta ese símbolo, no se cae el ciclo completo - [x] Contador de requests del mes movido acá (`requestCounter`, en memoria, resetea por mes en hora ART) — lo consume tanto `iolSource.ts` como el futuro `/status` ### `src/sources/iol/iolMapper.ts` (funciones puras, el módulo más testeable) - [x] Mapeo `simbolo, descripcion, apertura, maximo, minimo` directo - [x] `ultimoCierre → cierre_anterior`, `variacionPorcentual → variacion_pct`, `volumen → volumen` - [x] `moneda → currency` (1/peso_argentino→ARS, 2/dolar_estadounidense→USD) - [x] `plazo` (T0→sufijo `ci`, T1→sufijo `24hs`) determina a qué columnas van `ultimoPrecio`, y los campos de `puntas[0]` (`precioCompra/precioVenta/cantidadCompra/cantidadVenta` → `bid/ask/cant_bid/cant_ask_{sufijo}`) - [x] Para opciones, además: `tipoOpcion, precioEjercicio, fechaVencimiento` (`activoSubyacente` se toma de `config/options.csv`, no viene de IOL) - [x] Caso borde confirmado en el código Python actual: `puntas` a veces viene como dict en vez de lista — replicar el manejo defensivo, no asumir siempre array ### `src/sources/iol/iolSource.ts` - [x] Implementa la interfaz `Source` real de `src/sources/types.ts` (`start()`/`stop()`/`getHealth()`, no `runCycle()/isHealthy()` como decía este checklist originalmente — esa interfaz quedó fijada en M1 y `iolSource.ts` se ajustó a ella). `runCycle(assetClasses?)` queda como método propio de la clase, no parte de `Source`, justamente para que `start()` no dispare ningún `setInterval` propio (ver siguiente punto) - [x] `runCycle()` sin parámetro = ciclo completo (4 bulk + hasta 30 individuales: 12 acciones + 14 cedears + 4 bonos, igual que el cálculo de [IOL_API.md](IOL_API.md)); opciones se mapean directo del bulk, sin enriquecimiento individual (no entran en el presupuesto de 30) - [x] `runCycle(['bonos_usd'])` = ciclo acotado a una sola clase de activo - [x] La cadencia la dispara quien llame a `runCycle()` (el `orchestrator` de M4) — `iolSource.ts` no tiene ningún `setInterval` propio - [x] Contador de requests del mes contra el cupo de 25.000 (`IOL_MONTHLY_REQUEST_LIMIT`) — loguea warning al pasar el 90% - [x] **Ajustado**: `IOL_CYCLE_MINUTES` (default 12) e `IOL_FALLBACK_CYCLE_MINUTES` (default 30) son env vars separadas — el `orchestrator` de M4 es quien debe usar la segunda cuando IOL esté cubriendo una clase en fallback ### Escritura - [x] `iolSource.ts` llama a `priceRepository.insertXBatch(...)` por tabla al final de cada ciclo, con guard de `shouldSkipDuplicate` antes de batchear - [x] Detrás de un flag `WRITE_ENABLED` (default `false`) — ver [08-migracion-cutover.md](08-migracion-cutover.md) ## Verificación - [x] Tests unitarios de `iolMapper.ts` contra fixtures JSON (`test/iol/fixtures/`), incluyendo el caso `puntas` como dict (`test/iol/iolMapper.test.ts`) - [x] Tests de `iolAuthClient.ts` con fake timers (sin red real) para el refresh/expiry (`test/iol/iolAuthClient.test.ts`) - [ ] Con `WRITE_ENABLED=true` en entorno controlado: correr un ciclo completo y verificar filas nuevas en las 5 tablas con timestamps y columnas correctas — **no hecho**: no hay credenciales reales de IOL (`IOL_USER`/`IOL_PASSWORD`) disponibles en este entorno para correr contra la API real - [ ] Confirmar que `data_bursatil` sigue respondiendo bien leyendo esas filas nuevas — depende del punto anterior --- # M3 — Sidecar HomeBroker + puente Node Ver contexto general en [00-INDEX.md](00-INDEX.md) y protocolo completo en [HOMEBROKER_PROTOCOL.md](HOMEBROKER_PROTOCOL.md). ## Objetivo Mantener viva la conexión SignalR a pyhomebroker con un sidecar Python mínimo (no el repo viejo completo), que empuja ticks al backend Node por HTTP. Esta es la pieza más riesgosa del proyecto — protocolo no oficial, dato de mercado en vivo — por eso se aísla en un proceso chico y reemplazable, no se mezcla con el resto del sistema. **Fase 2 (NO se diseña ni se implementa ahora)**: reemplazar este sidecar por un cliente SignalR 2.x puro en Node, una vez que esto corra estable en producción. El protocolo ya queda documentado en `HOMEBROKER_PROTOCOL.md` para que esa fase futura no tenga que reverse-engineer de nuevo. ## Decisión clave: sin carry-forward El sistema viejo reescribía cada símbolo cada 30s aunque no hubiera tick nuevo ("para que no desaparezca"). Esto contamina la detección de frescura: un timestamp que avanza sin que el precio se mueva de verdad enmascara una fuente caída. **En el sistema nuevo, el sidecar solo manda al buffer los símbolos con tick genuino desde el último flush.** Es un cambio de comportamiento deliberado respecto al Python viejo. (El "no desaparece de la pantalla" se resuelve aparte, solo para Google Sheets, con un cache de snapshot en Node — ver [07-google-sheets.md](07-google-sheets.md). No se mezcla con la escritura a DB.) ## Checklist ### Qué portar del repo viejo (mínimo, de `src/homebroker/scraper.py`) - [x] Lógica de `connect()`: `HomeBroker(broker_id, on_securities, on_options, on_repos, on_error, on_open, on_close)`, `hb.auth.login(...)`, `hb.online.connect()` — implementado en `homebroker_client.py`. Firma del constructor y de `auth.login(dni=, user=, password=, raise_exception=True)` verificadas contra el código real de [github.com/crapher/pyhomebroker](https://github.com/crapher/pyhomebroker) (instalado en un venv descartable para confirmar, no solo por lectura de docs) - [x] Las 8 llamadas `subscribe_securities(panel, settlement)` + `subscribe_options()` + `subscribe_repos()` — `config.py` define los 4 boards × 2 settlements (`spot`/`24hs`) = 8, coincide exactamente con el cálculo de este checklist - [x] `is_market_open()` / `seconds_until_market_open()` (ART, lun-vie, 10:30-17:00) — el sidecar ni intenta conectar fuera de horario - [x] Helpers de coerción segura (NaN-safe) para decimales/enteros que vienen de los DataFrames de pandas (`safe_float`/`safe_int`/`safe_str`/`safe_iso` en `homebroker_client.py`) ### Qué NO portar (descartar explícitamente) - [x] SQLAlchemy / motor de DB — Node es dueño de la persistencia ahora (el sidecar no tiene ninguna dependencia de DB) - [x] Integración de Google Sheets — vive en Node ahora (`src/sheets/`, fuera de alcance de M3) - [x] Variantes `scraper_nosave.py`, código de Matriz/PyRofex, scripts de debug en `scripts/` — no se portó nada de eso - [x] Construcción de la lista de símbolos a trackear desde CSV dentro del sidecar — el sidecar solo conoce los pares `(board, settlement)` estáticos de `config.py`; el filtrado a "qué símbolos nos importan" lo hace `homebrokerMapper.ts` en Node contra `config/instruments.csv`/`config/options.csv` ### Contrato sidecar → Node - [x] Buffer del sidecar: mapa símbolo(+settlement) → última fila, más un set de "modificado desde el último flush" — SIN carry-forward (ver decisión arriba) - [x] Cada `FLUSH_INTERVAL_SECONDS` (default 30, configurable): `POST {NODE_URL}/internal/homebroker/ticks` con body: ```json { "source": "homebroker", "flushedAt": "2026-06-23T14:32:10Z", "securities": [ { "symbol": "GGAL", "settlement": "spot", "bid": 0, "ask": 0, "last": 0, "bidSize": 0, "askSize": 0, "open": 0, "high": 0, "low": 0, "previousClose": 0, "changePct": 0, "volume": 0, "tradeDate": "...", "panel": "bluechips" } ], "options": [ { "symbol": "GFGC7000AG", "bid": 0, "ask": 0, "last": 0, "strike": 7000, "putOrCall": "CALL", "maturityDate": "..." } ], "repos": [] } ``` - [x] Header `X-Sidecar-Token` (secreto compartido) como defensa simple — la red de Compose ya está aislada, esto es defensa en profundidad barata - [x] Sidecar expone su propio `GET /health` → `{ ok, hbConnected, lastTickAt }`, sondeado por Node cada 15-30s (`SIDECAR_HEALTH_POLL_SECONDS`, default 20) - [x] Si Node está caído cuando el sidecar quiere flushear: loguear y descartar ese flush, NO crashear ni reintentar agresivo (el próximo flush 30s después ya trae datos frescos — no hay requerimiento de "recuperar historial perdido") ### `homebroker-sidecar/` (Python, 4 archivos, no más) - [x] `config.py`: `HB_BROKER` (default 284), `HB_DNI`, `HB_USER`, `HB_PASSWORD`, `PUSH_TARGET_URL`, `SIDECAR_TOKEN`, `FLUSH_INTERVAL_SECONDS` (default 30), `SIDECAR_HTTP_PORT` (default 8090) - [x] `homebroker_client.py`: wrapper sobre `pyhomebroker.HomeBroker` — `connect()`, `disconnect()`, registra los callbacks (`on_open`/`on_securities`/`on_options`/`on_repos`/`on_error`/`on_close`), buffer + dirty-set + lock, `get_and_clear_buffer()` - [x] `sidecar.py`: loop principal — gate de horario de mercado, conexión/reconexión con backoff (5/10/30/60s), servidor HTTP chico (`/health` + thread de flush periódico) - [x] `requirements.txt`: solo `pyhomebroker` (trae pandas/numpy/requests/signalr-client-threads/pyquery como dependencias propias) + `python-dotenv`. Se usó `http.server` de la stdlib en vez de FastAPI, igual que el stub de M1 — un solo endpoint no justifica el framework - [x] `Dockerfile` del sidecar (modelado en el Dockerfile viejo: `python:3.11-slim`, tzdata, usuario no-root) ### Lado Node (`src/sources/homebroker/`) - [x] `tickIngressServer.ts`: ruta Fastify `POST /internal/homebroker/ticks`, validación de schema del body (zod), NO expuesta fuera de la red de Compose - [x] `homebrokerMapper.ts`: traduce el payload del sidecar a `PriceRow[]`/`OptionPriceRow[]`, filtrando a los símbolos presentes en `config/instruments.csv`/`config/options.csv` (Node es el punto de enforcement de "qué trackeamos") - [x] `homebrokerSource.ts`: implementa la interfaz `Source`, expone señal de salud derivada del `/health` del sidecar (no de los ticks recibidos — ver ADR-3 en [ARCHITECTURE.md](ARCHITECTURE.md)) - [x] `sidecarClient.ts`: sondea `GET {sidecar}/health` cada `SIDECAR_HEALTH_POLL_SECONDS` (default 20s, dentro del rango 15-30s pedido) ### Docker - [x] `docker-compose.yml` con el servicio `homebroker-sidecar` en la red interna, puerto NO publicado externamente (ya era así desde M1 — solo `backend` declara `ports:`) ## Verificación - [ ] Con el sidecar corriendo en horario de mercado real, confirmar en logs que llegan ticks genuinos (no carry-forward) y terminan como filas en Postgres — **no hecho**: no hay credenciales reales de HomeBroker (`HB_DNI`/`HB_USER`/`HB_PASSWORD`) disponibles en este entorno, y es la pieza más riesgosa del proyecto (broker real, dato de mercado en vivo) — no se debe probar sin que el usuario decida cuándo y con qué cuenta - [x] Apagar manualmente el sidecar y confirmar que Node lo detecta (no crashea, lo refleja en `/status`) — verificado: con el sidecar no corriendo en este entorno, `GET /status` respondió `sidecar: { reachable: false, homebrokerConnected: false, lastTickAt: null }` sin que el backend se cayera - [ ] Confirmar que reiniciar el sidecar reconecta sin intervención manual — depende de probar contra un broker real, no hecho por la misma razón que el primer punto --- # M4 (parte 1) — Scheduler, frescura y failover Ver contexto general en [00-INDEX.md](00-INDEX.md). ## Objetivo Detectar cuando la fuente activa (HomeBroker) deja de actualizar de verdad, y conmutar automáticamente a la fuente secundaria (IOL) para la(s) clase(s) de activo afectada(s), recuperando el primario cuando vuelve a estar sano. Este es el corazón del pedido original: "revisar las horas de los precios obtenidos para verificar si efectivamente está actualizando y sino cambiar a otro scraper". ## Checklist ### `src/config/market-hours.ts` - [x] Port 1:1 de `is_market_open()` / `seconds_until_market_open()` del Python (ART vía luxon, lun-vie, 10:30-17:00 inclusive) - [x] Compartido por: el orchestrator (no evaluar frescura fuera de horario), `iolSource` (no pollear fuera de horario, igual que hoy — sin cambios, `iolSource.ts` no llama `isMarketOpen` directamente porque su cadencia la dispara el orchestrator), y expuesto indirectamente en `/status` (`marketOpen: false` en vez de marcar "stale" cuando en realidad no debería estar pasando nada — sin cambios desde M1) ### `src/scheduler/freshnessMonitor.ts` - [x] Definición de "stale" por **clase de activo**, no por símbolo individual (un símbolo poco líquido puede legítimamente no operar por horas — la señal robusta es a nivel tabla completa) - [x] Por cada una de las 5 tablas, cada ~60s (`FRESHNESS_CHECK_INTERVAL_SECONDS`): `MAX(timestamp)` vs `now()` vía `priceRepository.getLastWriteTimestamp(tabla)` - [x] Umbral diferenciado por fuente activa (un umbral global sería incorrecto en ambos sentidos): - [x] **Ajustar en prod** — HomeBroker activo: default 5 min vía `HB_STALE_THRESHOLD_MINUTES` (push-based, debería escribir cada ~30s; 5 min de silencio es señal fuerte de algo roto) - [x] **Ajustar en prod** — IOL activo: default 20 min vía `IOL_STALE_THRESHOLD_MINUTES` (su cadencia normal es ~12 min, necesita margen para no generar falsos positivos) - [x] Ambos configurables vía env vars (`HB_STALE_THRESHOLD_MINUTES`, `IOL_STALE_THRESHOLD_MINUTES`) - [x] Fuera de horario de mercado: se suspende la evaluación completa (no debe "flapear" a stale de noche/fin de semana) - [x] Escribe el resultado en el snapshot de `SourceStatus` / `statusStore.ts` (vía `upsertAssetClassStatus`, llamado desde `orchestrator.ts`) ### `src/scheduler/failoverStateMachine.ts` - [x] Una máquina de estados **por clase de activo** (5 independientes — HomeBroker puede estar sano para acciones pero no para opciones, por ejemplo) - [x] Estados: `PRIMARY_ACTIVE` (HomeBroker escribiendo) / `FALLBACK_ACTIVE` (IOL escribiendo, porque el primario se detectó stale) - [x] Transición `PRIMARY_ACTIVE → FALLBACK_ACTIVE`: frescura vencida (según el umbral de arriba) + mercado abierto → el orchestrator empieza a invocar `iolSource.runCycle([eseAssetClass])` en una cadencia propia de fallback (`IOL_FALLBACK_CYCLE_MINUTES`, ver nota de presupuesto en [03-iol-source.md](03-iol-source.md)) - [x] Transición `FALLBACK_ACTIVE → PRIMARY_ACTIVE` (recuperación): re-prueba periódica (default cada 10 min vía `FAILOVER_RECOVERY_PROBE_MINUTES`) basada en el `/health` del sidecar (`hbConnected` + `lastTickAt` reciente, reutilizando `HB_STALE_THRESHOLD_MINUTES` como ventana de "reciente") — NO basada en el timestamp de la tabla (ver nota de diseño abajo) - [x] Nota de diseño importante (documentar también en `ARCHITECTURE.md`): la democión a fallback se basa en el timestamp de la DB (la señal más verídica de "¿hay algo fresco llegando?"), pero la promoción de vuelta a primario se basa en la salud del sidecar (la señal de "¿HomeBroker específicamente volvió?") — son señales distintas y complementarias, no intercambiables. Un sidecar puede reportarse "conectado" mientras Veta deja de mandar ticks; por eso la frescura real nunca se infiere del self-report del sidecar. - [x] Restricción de schema: las 5 tablas legacy NO tienen ni van a tener columna de "fuente" — la atribución de qué fuente escribió vive solo en `SourceStatus`/memoria del proceso Node, nunca se infiere leyendo las filas de precio - [x] Extensibilidad: `activeSource` como string libre, lista de fuentes disponibles como array ordenado (`["homebroker", "iol"]` hoy) — agregar una tercera fuente a futuro (ej. Matriz) es un elemento más en la lista + una implementación de `Source`, no una reescritura de la máquina de estados ### `src/scheduler/orchestrator.ts` (no estaba en el checklist original, surgió al implementar) - [x] Conecta `freshnessMonitor` (qué tan fresca está cada clase) con `failoverStateMachine` (qué fuente debería estar activa): cada evaluación de frescura se reporta a la máquina de estados y el resultado (incluyendo `isStale`/`lastWriteAt` aunque no haya transición) se persiste en `SourceStatus` - [x] Arranca/corta el `setInterval` de `iolSource.runCycle([assetClass])` en cadencia de fallback cuando una clase entra/sale de `FALLBACK_ACTIVE` - [x] Instancia única de `IolSource` (`export const iolSource`) compartida entre el orchestrator y la ruta `/status` (`requestsThisMonth`), reemplazando el placeholder de M1 ## Verificación - [x] Tests unitarios con reloj falso y `priceRepository` mockeado: simular "fresco", "se pone stale", "se recupera", "flapea cerca del umbral" para cada clase de activo (`test/scheduler/freshnessMonitor.test.ts`, `test/scheduler/failoverStateMachine.test.ts`, `test/config/market-hours.test.ts`) - [ ] Prueba manual de failover forzado: apagar el contenedor del sidecar, confirmar que `/status` pasa `activeSource` a `iol` para las clases afectadas dentro del umbral configurado, y que se recupera al reiniciar el sidecar — **no hecho**: requiere el sidecar y credenciales reales de HomeBroker/IOL corriendo, no disponibles en este entorno --- # M4 (parte 2) — API de status/salud Ver contexto general en [00-INDEX.md](00-INDEX.md). ## Objetivo Exponer una API mínima de monitoreo (no de datos — `data_bursatil` sigue leyendo la DB directo) para poder ver de un vistazo si el sistema está sano y cuál fuente está activa por clase de activo. ## Checklist ### `GET /health` - [x] Chequeo de proceso vivo, para el healthcheck de Docker/Dokploy - [x] Shape: `{ "status": "ok", "uptimeSeconds": 12345, "timestamp": "2026-06-23T18:00:00Z" }` ### `GET /status` - [x] Shape propuesto: ```json { "timestamp": "2026-06-23T18:00:00Z", "marketOpen": true, "sidecar": { "reachable": true, "homebrokerConnected": true, "lastTickAt": "2026-06-23T17:59:42Z" }, "assetClasses": { "acciones": { "activeSource": "homebroker", "lastWriteAt": "2026-06-23T17:59:50Z", "isStale": false, "fallbackSince": null }, "bonos_ars": { "activeSource": "homebroker", "lastWriteAt": "2026-06-23T17:59:50Z", "isStale": false, "fallbackSince": null }, "bonos_usd": { "activeSource": "iol", "lastWriteAt": "2026-06-23T17:50:11Z", "isStale": false, "fallbackSince": "2026-06-23T15:10:00Z" }, "cedears": { "activeSource": "homebroker", "lastWriteAt": "2026-06-23T17:59:50Z", "isStale": false, "fallbackSince": null }, "opciones": { "activeSource": "homebroker", "lastWriteAt": "2026-06-23T17:59:50Z", "isStale": false, "fallbackSince": null } }, "iol": { "requestsThisMonth": 18234, "monthlyLimit": 25000 } } ``` - [x] Respaldado por `src/api/statusStore.ts`: el ÚNICO lugar que escribe es `orchestrator.ts` (vía `upsertAssetClassStatus`, llamado desde `failoverStateMachine.ts`/`freshnessMonitor.ts` en cada tick/transición — ver [05-scheduler-failover.md](05-scheduler-failover.md)), las rutas solo LEEN — sin lógica de negocio en las rutas - [x] `iol.requestsThisMonth` viene del contador descrito en [03-iol-source.md](03-iol-source.md), expuesto vía la instancia única `iolSource` (`src/sources/iol/iolSource.ts`) ### Implementación - [x] `src/api/server.ts`: instancia Fastify - [x] `src/api/routes/health.ts`, `src/api/routes/status.ts` - [x] Validación de schema de respuesta con Fastify (`schema.response` en ambas rutas, JSON Schema plano — consistencia de shape garantizada por `fast-json-stringify`/ajv) - [x] Puerto del backend (ej. 3000) — solo necesita exponerse externamente si Dokploy/monitoreo lo requiere directo; si no, queda en la red interna de Compose detrás del proxy de Dokploy. En Dokploy: el dominio se asigna al servicio `backend` (no a `homebroker-sidecar`, que es interno), puerto de contenedor 3000 ## Verificación - [x] Tests con `inject()` de Fastify para `/health` y `/status`, verificando que el shape coincide con lo documentado arriba (`test/api/health.test.ts`, `test/api/status.test.ts`) - [x] Test con `statusStore` mockeado en distintos estados (todo primario, uno en fallback, mercado cerrado) y confirmar que `/status` lo refleja correctamente (`test/api/status.test.ts`, describe `GET /status con statusStore mockeado`) --- # M5 (parte 1) — Google Sheets Ver contexto general en [00-INDEX.md](00-INDEX.md). ## Objetivo Mantener la integración con Google Sheets (confirmado en uso real: `utils/google_sheets.py`, llamado desde ambos scrapers viejos), pero arreglando el bug actual: hoy HomeBroker e IOL corren como procesos Python separados y cada uno sobreescribe la hoja completa `'Data'` (`resize=True`) con SOLO sus propios símbolos — el que escribe último "gana" y la hoja queda incompleta. El sistema nuevo lo resuelve por diseño al ser un solo proceso con un snapshot combinado. ## Decisión clave: carry-forward vive aquí, no en la DB [04-homebroker-sidecar.md](04-homebroker-sidecar.md) elimina el carry-forward en la escritura a Postgres (para que la frescura sea una señal real). Pero el viejo comportamiento de "el tablero de Sheets nunca tiene huecos" sigue siendo deseable como UX. Se resuelve con una capa de presentación separada: un cache en memoria en Node que sí hace carry-forward, alimentando solo a Sheets — sin tocar la lógica de frescura de la DB. ## Checklist ### `src/sheets/sheetRow.ts` - [x] Tipo `SheetRow` + `toSheetRow(assetClass, row)`: mismo layout de columnas que `_snap_to_sheets_row` del scraper Python viejo (`Tipo`, `Simbolo`, `Descripcion`, `Currency`, `UltCI*`, `Ult24hs*`, `Apertura`/`Maximo`/`Minimo`/`CierreAnt`/`VarPct`/`Volumen`, `TipoOpcion`/`Subyacente`/`Strike`/`Vencimiento`, `Timestamp`) - [x] Campos numéricos ausentes se rellenan con `0` (no hueco) para que Sheets no los interprete como fecha/texto ### `src/sheets/snapshotCache.ts` - [x] Mapa en memoria: símbolo → última fila conocida, de CUALQUIER fuente (HomeBroker o IOL) - [x] Se actualiza cada vez que llega un tick/ciclo nuevo de cualquier fuente (no espera al flush de Sheets) — hook en `tickIngressServer.ts` (HomeBroker, antes del dedup) y en `iolSource.ts` (`processSimpleClass`/`processBonos`/`processOpciones`, antes del dedup) - [x] Este es el ÚNICO lugar del sistema donde sobrevive el concepto de carry-forward — explícitamente aislado de `priceRepository.ts` ### `src/sheets/googleSheetsClient.ts` - [x] Cliente a la Sheets API — `google-spreadsheet` (v5) + `google-auth-library` (`JWT`), equivalente Node de `gspread` - [x] Credenciales vía `GOOGLE_CREDENTIALS_JSON` (mismo patrón que hoy: service account, JSON completo en una env var) - [x] Reusar el mismo spreadsheet (`GOOGLE_SPREADSHEET`, debe ser el ID del spreadsheet — el string de la URL, NO el título/nombre: a diferencia de `gspread` (Python viejo), `google-spreadsheet` no resuelve por nombre — ver hallazgo en [08-migracion-cutover.md](08-migracion-cutover.md)) y la misma hoja `'Data'` para no romper el hábito de uso del usuario - [x] Sin `GOOGLE_CREDENTIALS_JSON`/`GOOGLE_SPREADSHEET`, el cliente queda deshabilitado (`isConfigured()`) sin romper el resto del sistema ### `src/sheets/sheetsSyncService.ts` - [x] Cada N segundos (`GOOGLE_SHEETS_SYNC_INTERVAL_SECONDS`, default 60s — más lento que el flush de 30s de HomeBroker, para no pegarle a la cuota de la Sheets API) - [x] Toma el snapshot COMBINADO completo de `snapshotCache` (todas las clases de activo juntas, con columna `Tipo` — mismo formato que la hoja `'Data'` de hoy) - [x] Un solo write de sobreescritura completa a la hoja `'Data'` (`clear()` + `setHeaderRow()` + `addRows()`, equivalente a `set_with_dataframe(..., resize=True)`) - [x] Si falla, loguea y sigue — sin reintento agresivo (igual que el comportamiento actual); reintenta `connect()` en el próximo ciclo - [x] Campos numéricos ya llegan como `number` desde `toSheetRow` (no string), evitando que Sheets los interprete como fechas; columna `Timestamp` legible (`YYYY-MM-DD HH:mm:ss`) ## Verificación - [x] Unit tests con `client`/`getSnapshot` mockeados (`test/sheets/sheetsSyncService.test.ts`): conecta antes de escribir si no hay conexión, no escribe con cache vacío o si falla la conexión, no propaga errores de `writeSnapshot`, no arranca el timer si Sheets no está configurado - [x] Unit tests de `toSheetRow` y `snapshotCache` (`test/sheets/sheetRow.test.ts`, `test/sheets/snapshotCache.test.ts`): mapeo de columnas, defaults a `0`, carry-forward por símbolo, orden por `Simbolo` - [ ] Confirmar visualmente contra un spreadsheet real que la hoja `'Data'` muestra el tablero combinado completo (acciones + bonos + cedears + opciones) sin huecos, actualizándose cada ~60s — pendiente, no hay `GOOGLE_CREDENTIALS_JSON`/`GOOGLE_SPREADSHEET` reales en este entorno (mismo motivo que IOL/HomeBroker en M2-M4) - [ ] Confirmar que un símbolo sin tick reciente sigue apareciendo en Sheets con su último valor conocido (carry-forward de presentación funcionando) mientras que en `/status`/DB ese mismo símbolo correctamente refleja que no hay datos frescos si corresponde — pendiente, requiere corrida en vivo --- # M5 (parte 2) — Migración y corte Ver contexto general en [00-INDEX.md](00-INDEX.md). ## Objetivo Pasar de "el sistema viejo en producción" a "el sistema nuevo en producción" sin downtime ni riesgo para `data_bursatil`, que es la restricción más importante de todo el proyecto. > **Corrección de plan (post-M1, ver ADR-5 en [ARCHITECTURE.md](ARCHITECTURE.md) y [02-prisma.md](02-prisma.md))**: este checklist asumía que el sistema nuevo escribiría a la MISMA `scrapper_db` (`147.79.86.181:5498`) que ya lee `data_bursatil`, haciendo el corte "sin fricción" (tablas append-only, solapamiento seguro). En la práctica, M1 se ejecutó contra una base nueva y separada — `db-arg-bursatil` (`147.79.86.181:5437`) — que arrancó vacía. Esto cambia la naturaleza del corte: ya no es "dejar correr dos escritores en la misma base y apagar uno", sino que en algún momento `data_bursatil` tiene que **cambiar su `DATABASE_URL`/connection string** de `scrapper_db` a `db-arg-bursatil`. Eso sí implica tocar la configuración de ese repo (no su código/schema — las 5 tablas son columna-por-columna idénticas), lo cual es una desviación de la restricción original "sin tocar ese repo". El checklist de abajo está actualizado para reflejar esto. ## Checklist (orden secuencial) - [x] **Paso 1** — Desplegar el sistema nuevo en producción (Dokploy), escribiendo a `db-arg-bursatil` (no a `scrapper_db`), con `WRITE_ENABLED=false` al principio: corre todo el pipeline (auth, ticks/polling, mapeo, failover) y no escribe filas reales — cero riesgo mientras se valida comportamiento. Confirmado en vivo: dominio `arg-bursatil-scrapper.sitemaster.com.ar` responde, `/health` y `/status` operativos, `SourceStatus` reportando fallback a IOL en las 5 clases (sidecar de HomeBroker aún sin credenciales/conexión real) - [ ] **Paso 2** — Revisar logs durante unos días de mercado reales, confirmar que los conteos/mapeos se ven sanos - [ ] **Paso 3** — Pasar `WRITE_ENABLED=true` (`db-arg-bursatil` sigue vacía y sin lectores todavía — cero riesgo, no hay nadie más escribiendo ni leyendo esa base) - [ ] **Paso 4** — Validar con `WRITE_ENABLED=true` corriendo sola contra `db-arg-bursatil` (sin overlap posible con el sistema viejo, que escribe a una base distinta): - [ ] Conteo de filas/día razonable para un ciclo de mercado completo - [ ] `/status` refleja correctamente la fuente activa y la frescura para las 5 clases - [ ] Sanity check manual de columnas/tipos en algunas filas (especialmente `Decimal` de precios, ver [09-testing.md](09-testing.md)) - [ ] **Paso 5** — Decidir y ejecutar el corte real de `data_bursatil`: cambiar su `DATABASE_URL` de `scrapper_db` a `db-arg-bursatil` (coordinar con quien gestione ese repo/Dokploy; ventana de bajo tráfico recomendada; rollback = revertir la env var) - [ ] **Paso 6** — Validar `data_bursatil` ya apuntando a `db-arg-bursatil`: todas sus rutas/páginas responden con datos frescos, sin errores 500 ni columnas faltantes - [ ] **Paso 7** — Apagar los scrapers Python viejos (HomeBroker + IOL de `Scrapper-Online`) solo después de validar al menos un ciclo completo de mercado (lunes a viernes) sin problemas en el sistema nuevo, incluyendo idealmente un evento de failover real u forzado a propósito (ver [05-scheduler-failover.md](05-scheduler-failover.md)) - [ ] **Paso 8** — Archivar el repo viejo `Scrapper-Online` (NO borrar) por un tiempo de retención razonable, por si hace falta volver atrás - [ ] **Paso 9** (recomendación, no bloqueante) — Dejar de manejar credenciales en `.env` con contraseñas en texto plano: usar las variables de entorno de Dokploy de forma consistente para ambos servicios nuevos (`backend` y `homebroker-sidecar`), y considerar a futuro un gestor de secretos (Doppler, Infisical, o `.env` rotados y nunca commiteados) ## Verificación final - [ ] Sistema nuevo es la única fuente de escritura a `db-arg-bursatil` para HomeBroker e IOL - [ ] `data_bursatil` apunta a `db-arg-bursatil` y no tuvo ningún cambio de comportamiento observable durante ni después del corte - [ ] Repo viejo archivado, accesible para rollback si hiciera falta ## Hallazgos durante Paso 1/2 (primer deploy real) ### Sheets: `GOOGLE_SPREADSHEET` debe ser el ID, no el nombre Primer deploy en Dokploy mostró cada ~60s en los logs: ``` ERROR (1): Error conectando a Google Sheets HTTPError: Google API error - [404] Requested entity was not found. "prefix": "https://sheets.googleapis.com/v4/spreadsheets/EPBG" ``` **Causa:** `GOOGLE_SPREADSHEET` está seteado a `EPBG`, que es el nombre/título de la hoja, no su ID. El scraper Python viejo (`gspread`, `gc.open(nombre)`) podía abrir por nombre vía la API de Drive; la librería Node `google-spreadsheet` (`new GoogleSpreadsheet(id, auth)`) solo abre por ID — pasarle un nombre pega contra una URL inválida y devuelve 404 en cada intento de conexión. **Impacto:** ninguno sobre `scrapper_db`/`data_bursatil` — el sync a Sheets es de mejor esfuerzo y no bloquea el resto del pipeline (ver [07-google-sheets.md](07-google-sheets.md)). Es solo ruido en logs y el tablero de Sheets no se actualiza. **Fix:** - [x] Obtener el ID real del spreadsheet desde su URL (`https://docs.google.com/spreadsheets/d/ESTE_ID/edit`) → `13tcOFnGOHe-cQTieGL9JdCUchUxbu3ljQDo6NgA68Co` - [x] Actualizar `.env` local (`GOOGLE_SPREADSHEET`) - [ ] Actualizar `GOOGLE_SPREADSHEET` en las variables de entorno de Dokploy (servicio `backend`) con ese mismo ID y redesplegar - [ ] Confirmar en logs que aparece `Conectado a Google Sheets` y luego `Sheets 'Data' actualizada` cada ciclo, sin el error 404 --- # M5 (parte 3) — Testing Ver contexto general en [00-INDEX.md](00-INDEX.md). ## Objetivo Cubrir con tests lo que realmente importa: la lógica nueva (frescura/failover) que no tiene equivalente previo para comparar, y los mapeos de datos (IOL, HomeBroker) donde un error silencioso significaría precios mal cargados en producción. ## Checklist ### `iolMapper.ts` - [x] Tests contra fixtures JSON reales (capturados de respuestas de IOL, sin datos sensibles, guardados en `test/iol/fixtures/`) - [x] Mapeo correcto de moneda, plazo T0/T1, extracción de `puntas` - [x] Caso borde confirmado en el Python actual: `puntas` a veces viene como dict en vez de lista - [x] `iolAuthClient.ts` con fake timers para el ciclo de expiración/refresh (sin red real) ### `homebrokerMapper.ts` - [x] Tests contra payloads de ejemplo del contrato del sidecar (hand-construidos, siguiendo el shape documentado en [04-homebroker-sidecar.md](04-homebroker-sidecar.md)) - [x] Verificar filtrado correcto a símbolos trackeados, mapeo de `settlement`, manejo de `putOrCall`/`strike` en opciones ### `freshnessMonitor.ts` / `failoverStateMachine.ts` - [x] **Prioridad más alta de todo el proyecto** — comportamiento nuevo sin equivalente previo, la corrección tiene que venir de tests deliberados, no de portar código viejo - [x] Reloj falso + `priceRepository.getLastWriteTimestamp` mockeado - [x] Escenarios: fuente fresca, se pone stale, se recupera, flapea cerca del umbral — por cada una de las 5 clases de activo de forma independiente ### `priceRepository.ts` - [ ] Tests de integración contra Postgres descartable (testcontainers) con schema creado desde el mismo `prisma/schema.prisma` — NUNCA contra producción. **Pendiente**: requiere Docker Desktop corriendo en el entorno de desarrollo (no estaba disponible al revisar este punto — `docker ps` falla, daemon apagado); no bloqueante para deploy, pero sí antes de confiar ciegamente en `insertAccionesBatch`/etc. en volumen alto - [ ] Verificar tipos correctos al insertar (especialmente precisión de `Decimal` en precios — no perder dígitos) - [ ] `getLastWriteTimestamp` devuelve el valor correcto - [ ] Inserción batch con una fila mala en el medio no debe perder el resto silenciosamente — verificar que el error se superficie ### Rutas API - [x] `/health` y `/status` con `inject()` de Fastify, shape verificado contra lo documentado en [06-status-api.md](06-status-api.md). Confirmado además en vivo contra `https://arg-bursatil-scrapper.sitemaster.com.ar` (ver hallazgo en [00-INDEX.md](00-INDEX.md)) - [x] Bug encontrado y arreglado: el primer test de `/status` pegaba contra la `DATABASE_URL` real de `.env` (no mockeada) en lugar de mockear `statusStore` como el resto de los tests del archivo — pasaba mientras `db-arg-bursatil` estaba vacía, pero rompió en cuanto el deploy real empezó a escribir filas en `SourceStatus`. Corregido mockeando `getAssetClassesStatus` igual que los demás casos (`test/api/status.test.ts`) ### Smoke test end-to-end (manual, no automatizado) - [ ] Correr sistema nuevo en producción un día completo de mercado, diferenciar conteos de filas y chequear algunas filas a mano (parte del proceso de [08-migracion-cutover.md](08-migracion-cutover.md) — ya no es "viejo vs nuevo en paralelo en la misma base", ver corrección de plan ahí) --- # Arquitectura Documento de referencia, no checklist. Ver [00-INDEX.md](00-INDEX.md) para el contexto y los hitos. ## Diagrama de flujo de datos ``` ┌─────────────────────────┐ Veta Capital <--->│ homebroker-sidecar (Py) │--push ticks (HTTP)-->┐ (SignalR 2.x) │ solo: auth+subscribe+buf │ │ └─────────────────────────┘ │ v IOL REST API <----------------------------------- iolSource.ts ---> backend_scrapper (Node/TS) │ ┌────────────┼─────────────┐ v v v priceRepository snapshotCache statusStore (Prisma, INSERT) (en memoria, (Prisma, │ carry-forward SourceStatus) v solo Sheets) │ scrapper_db v (5 tablas legacy, GET /health sin tocar schema) GET /status ^ │ data_bursatil (Next.js) lee directo, sin cambios ``` Dos procesos en Docker Compose: `backend` (Node) y `homebroker-sidecar` (Python), modelados en el patrón de deploy actual en Dokploy. ## Decisiones de diseño no obvias (ADRs) ### ADR-1: Sin carry-forward en la DB, sí en el cache de Sheets El sistema viejo reescribía cada símbolo cada 30s aunque no hubiera tick nuevo, para que "no desaparezca" de la vista. Esto rompe la detección de frescura: un timestamp que avanza sin que el precio cambie de verdad enmascara una fuente caída. **Decisión**: la escritura a Postgres (`priceRepository.ts`) solo recibe INSERT cuando hay un tick genuino. El "no desaparece de la pantalla" se resuelve aparte, con `snapshotCache.ts` (capa de presentación, solo para Google Sheets) — ver [07-google-sheets.md](07-google-sheets.md). ### ADR-2: Atribución de fuente vive en proceso/`SourceStatus`, nunca en las tablas de precio Las 5 tablas legacy (`acciones`, `bonos_ars`, `bonos_usd`, `cedears`, `opciones`) no tienen columna de "fuente" y no se les puede agregar (restricción dura: no tocar el schema que lee `data_bursatil`). **Decisión**: el estado de "qué fuente está activa para esta clase de activo" vive solo en la tabla `SourceStatus` (nueva, propia de este proyecto) y en memoria del proceso Node. La detección de frescura en sí (`MAX(timestamp)` reciente o no) es agnóstica a quién escribió — funciona igual en modo primario o fallback, que es justo el punto: en fallback, las filas de IOL satisfacen el mismo chequeo de frescura, manteniendo la tabla "viva" sin necesitar saber quién la alimentó. ### ADR-3: Democión por timestamp de DB, promoción por salud del sidecar Son señales distintas y complementarias, no intercambiables: - **Democión** (primario → fallback): se basa en `MAX(timestamp)` de la tabla — la señal más verídica de "¿hay algo fresco llegando a la base que lee `data_bursatil`?". - **Promoción** (fallback → primario): se basa en el `/health` del sidecar (`hbConnected`, `lastTickAt`) — la señal de "¿HomeBroker específicamente volvió?". Un sidecar puede reportarse conectado mientras Veta Capital deja de mandar ticks; por eso la democión nunca se basa en el self-report del sidecar, solo la promoción lo usa, y solo como candidato a re-probar. ### ADR-4: Sidecar Python como Fase 1, no como solución permanente El protocolo de pyhomebroker (ASP.NET SignalR 2.x) es reimplementable en Node, pero es la pieza más riesgosa del proyecto (feed de precios en vivo, protocolo no oficial). Se optó por aislar el riesgo en un sidecar Python mínimo y reemplazable, documentando el protocolo completo en [HOMEBROKER_PROTOCOL.md](HOMEBROKER_PROTOCOL.md) para que una Fase 2 futura (cliente Node puro) no tenga que reverse-engineer de nuevo. Esta Fase 2 está fuera de alcance de este plan — se marca como TODO explícito, no se diseña en detalle. ### ADR-5: Prisma solo introspecta las tablas legacy, nunca las migra `data_bursatil` lee las 5 tablas con SQL crudo en producción. Cualquier `prisma migrate`/`db push` contra ellas es un riesgo innecesario. Se usa `prisma db pull` (de solo lectura, nunca DDL/DML) para generar el schema, y la única tabla con migración real es `SourceStatus`, aislada y namespaced para este proyecto. **Excepción puntual (M1)**: este plan asumía que `scrapper_db` (la base legacy en `147.79.86.181:5498`) seguiría siendo la base de datos en uso. En la práctica, M1 se ejecutó contra una base nueva (`db-arg-bursatil`, `147.79.86.181:5437`) que arrancó completamente vacía — sin las 5 tablas legacy. Como no había datos que arriesgar, las 5 tablas se crearon vía `prisma migrate dev` (migración `init_legacy_and_source_status`), replicando exactamente las columnas/tipos de `Scrapper-Online/utils/iol_models.py` (SQLAlchemy) para que `data_bursatil` pueda repuntarse a esta base sin cambios de esquema. A partir de este punto, la regla de oro de ADR-5 vuelve a aplicar tal cual: ninguna migración futura debe tocar estas 5 tablas con cambios destructivos. ## Notas operativas ### Build de Docker local bloqueado por Avast (no es un problema de código) En la máquina de desarrollo usada para M1, Avast Antivirus tiene activa la inspección TLS de "Web/Mail Shield" (root CA propia inyectada en el almacén de Windows). Eso rompe la verificación de certificados dentro de los contenedores Docker al bajar paquetes (`npm ci`, `pip install`) — los contenedores no tienen esa root CA, a diferencia del host. `docker compose build` falla por esto, no por el `Dockerfile`/`docker-compose.yml`, que están completos y se verificaron estructuralmente. La build real debería funcionar sin cambios en un servidor sin ese proxy (Dokploy) o desactivando temporalmente el Web Shield de Avast en el host. El mismo root CA de Avast rompe descargas HTTPS hechas directo por Node en el host (no solo dentro de contenedores) — por ejemplo, `prisma generate` bajando los binarios de sus engines (ver actualización a Prisma 7 en [02-prisma.md](02-prisma.md)). El workaround usado fue `NODE_OPTIONS=--use-system-ca` (flag nativo de Node 22 para confiar también en el almacén de certificados del sistema operativo, donde Avast ya inyectó su CA) — evita tener que desactivar la verificación de certificados (`NODE_TLS_REJECT_UNAUTHORIZED=0`), que sí sería un downgrade de seguridad real. ## Ver también - [HOMEBROKER_PROTOCOL.md](HOMEBROKER_PROTOCOL.md) — protocolo completo de pyhomebroker - [IOL_API.md](IOL_API.md) — endpoints y mapeo de campos de IOL - [RUNBOOK.md](RUNBOOK.md) — operación día a día --- # Protocolo HomeBroker (pyhomebroker) — referencia técnica Documento de referencia, no checklist. Verificado leyendo el código fuente instalado de `pyhomebroker` (no solo su documentación) en `Scrapper-Online/env/Lib/site-packages/pyhomebroker/`. Usado por [04-homebroker-sidecar.md](04-homebroker-sidecar.md) para la Fase 1 (sidecar Python) y dejado acá para que una futura Fase 2 (cliente Node puro) no tenga que reverse-engineer de nuevo. ## Resumen ejecutivo `pyhomebroker` es, debajo de la API de Python, un cliente HTTP + **ASP.NET SignalR 2.x** (vía el paquete `signalr-client-threads`, protocolo `1.5`). **No es SignalR Core** — el paquete npm moderno `@microsoft/signalr` apunta a un protocolo distinto y NO es compatible. Una reimplementación en Node necesitaría un cliente SignalR 1.x/2.x compatible o un negotiate/connect hecho a mano. ## Broker - Broker ID 284 = Veta Capital - `page = http://cuentas.vetacapital.com.ar` (HTTP plano, no HTTPS) ## Login (HTTP, antes de SignalR) 1. GET a la página del broker primero, para sembrar cookies. 2. `POST {page}/Login/Ingresar`, form-urlencoded: ``` IpAddress: Dni: Usuario: Password: ``` 3. Si la respuesta es HTTP 500, fallback a `POST {page}/Login/IngresarModal` con el mismo payload pero como JSON (`Content-Type: application/json`). 4. Éxito = el HTML de respuesta contiene el elemento `#usuarioLogueado`. Si no, se busca `.callout-danger` para el mensaje de error. 5. Las cookies de esta sesión HTTP se reusan en la fase SignalR siguiente. ## Conexión SignalR - Endpoint: `{page}/signalr/hubs` - Hub: `stockpriceshub` - Secuencia estándar SignalR 2.x: `negotiate` (devuelve `ConnectionToken`, `ConnectionId`, `TryWebSockets`) → `connect` (`transport=webSockets`) → `start`. - Métodos invocados por el cliente sobre el hub: `JoinGroup(groupName)` / `QuitGroup(groupName)`. - Eventos escuchados del lado cliente: - `broadcast` — feed combinado de acciones/bonos/cedears/opciones/cauciones (el que importa) - `sendStartStockFavoritos` / `sendStockFavoritos` — portfolio personal (no usado en este proyecto) - `sendStartStockPuntas` / `sendStockPuntas` — libro de órdenes nivel 2 (no usado en este proyecto) ## Nombres de grupo (subscripciones) Para títulos: `{boardInterno}-{settlementCode}` | Board pyhomebroker | Board interno (en el nombre de grupo) | |---|---| | `bluechips` | `accionesLideres` | | `general_board` | `panelGeneral` | | `cedears` | `cedears` | | `government_bonds` | `rentaFija` | | `short_term_government_bonds` | `letes` | | `corporate_bonds` | `obligaciones` | | Settlement pyhomebroker | Código en el grupo | |---|---| | `spot` | `1` | | `24hs` | `2` | | `48hs` | `3` | Ejemplo: `bluechips` + `spot` → grupo `accionesLideres-1`. Casos especiales (grupo es un string literal fijo, no se parametriza): - Opciones: `'opciones-'` - Cauciones/repos: `'cauciones-'` - Libro de órdenes (no usado): `{symbol}*{settlement}*cj` (ej. `GGAL*1*cj`) - Portfolio personal (no usado): `{symbol}*{settlement}*fv` ## Payload del evento `broadcast` Campos crudos (antes del remapeo a DataFrame de pandas que hace pyhomebroker): ``` Symbol, Term, BuyQuantity, BuyPrice, SellPrice, SellQuantity, LastPrice, VariationRate, StartPrice, MaxPrice, MinPrice, PreviousClose, TotalAmountTraded, TotalQuantityTraded, Trades, TradeDate, Hour, Panel, Group, ClosePrice ``` Adicionales para opciones: ``` MaturityDate, StrikePrice, PutOrCall, Issuer ``` Mapeos de valores: - `Term`: `'1'` → spot, `'2'` → 24hs, `'3'` → 48hs - `PutOrCall`: `0` → `''`, `1` → `CALL`, `2` → `PUT` - `Group` discrimina el tipo de fila: `'cauciones-'` / `'opciones-'` / cualquier otra cosa = título normal (acción/bono/cedear) Clave de dedup para items entrantes: `Symbol + '-' + Term`. ## Horario de conexión Mercado: lunes a viernes, 10:30 a 17:00 hora ART (`America/Argentina/Buenos_Aires`). Fuera de ese horario no tiene sentido mantener la conexión SignalR abierta — el sidecar (y cualquier futuro cliente Node) debe desconectar y esperar a la próxima apertura en vez de intentar reconectar indefinidamente. ## Nota para la Fase 2 (cliente Node puro, futura, fuera de alcance de este plan) Si se decide reemplazar el sidecar Python por un cliente SignalR 2.x nativo en Node: - Buscar un paquete npm compatible con SignalR 1.x/2.x (NO `@microsoft/signalr`), o implementar el handshake `negotiate`/`connect`/`start` a mano sobre `ws` + `fetch`. - Todo lo documentado arriba (login, grupos, payload) es independiente del lenguaje — es protocolo HTTP/WebSocket estándar, no algo específico de Python. --- # API InvertirOnline (IOL) — referencia técnica Documento de referencia, no checklist. Verificado contra `Scrapper-Online/utils/iol_client.py` y `Scrapper-Online/src/iol/scraper.py`. Usado por [03-iol-source.md](03-iol-source.md). ## Autenticación (OAuth2) - Endpoint: `POST https://api.invertironline.com/token` - Login inicial: form-encoded, `{ username, password, grant_type: "password" }` - Refresh: form-encoded, `{ grant_type: "refresh_token", refresh_token }` - Vida del token: típicamente `expires_in: 1200` (20 min). Refrescar con un buffer de seguridad de 120s (es decir, refrescar cuando falten menos de 2 min para que expire, no esperar a que expire de verdad). - Si el refresh falla, recurrir a un login completo (`grant_type: password`) de nuevo. ## Endpoints bulk (snapshot de todo un universo) `GET /api/v2/Cotizaciones/{tipo}/argentina/Todos` `tipo` es uno de: - `acciones` - `cedears` - `titulospublicos` (cubre tanto bonos ARS como USD — la distinción `bonos_ars`/`bonos_usd` se hace del lado cliente, según el símbolo: ej. `AL30` es ARS, `AL30D` es USD) - `opciones` Respuesta: `{ titulos: [...] }`. ## Endpoint individual (enriquecimiento por símbolo/plazo) `GET /api/v2/{mercado}/Titulos/{simbolo}/Cotizacion?plazo={t0|t1}` - `mercado` default: `bCBA` - `plazo`: `t0` (contado inmediato / spot) o `t1` (24hs) - 404 significa "ese símbolo no tiene cotización a ese plazo" — no es un error de la app, simplemente se salta. ## Mapeo de campos (respuesta IOL → columnas internas) | Campo IOL | Columna interna | |---|---| | `simbolo` | `simbolo` | | `descripcion` | `descripcion` | | `apertura` | `apertura` | | `maximo` | `maximo` | | `minimo` | `minimo` | | `ultimoCierre` | `cierre_anterior` | | `variacionPorcentual` | `variacion_pct` | | `volumen` | `volumen` | | `moneda` (1=peso_argentino, 2=dolar_estadounidense) | `currency` (ARS / USD) | | `fecha` | `fecha` | | `plazo` (T0→sufijo `ci`, T1→sufijo `24hs`) | determina qué columnas con sufijo se llenan | | `ultimoPrecio` | `ultimo_{sufijo}` | | `puntas[0].precioCompra` | `bid_{sufijo}` | | `puntas[0].precioVenta` | `ask_{sufijo}` | | `puntas[0].cantidadCompra` | `cant_bid_{sufijo}` | | `puntas[0].cantidadVenta` | `cant_ask_{sufijo}` | Solo para opciones, además: | Campo IOL | Columna interna | |---|---| | `tipoOpcion` | `tipo_opcion` | | `precioEjercicio` | `precio_ejercicio` | | `fechaVencimiento` | `fecha_vencimiento` | **Quirk conocido** (confirmado en el código Python actual): `puntas` a veces llega como `dict` en lugar de `list`. El parseo debe ser defensivo ante ambos casos — no asumir siempre array. ## Cadencia y presupuesto de requests - Universo trackeado hoy: 12 acciones, 14 cedears, 4 bonos (2 ARS + 2 USD) → 30 símbolos con enriquecimiento individual. - Ciclo: 4 bulk + 30 individuales = 34 requests/ciclo. - Cadencia: ~12 minutos. - Cupo: 25.000 requests/mes. Cálculo aproximado: 34 req/ciclo × ~33 ciclos/día × ~22 días hábiles ≈ 24.700/mes — el presupuesto está ajustado contra el cupo en el uso normal. - **Implicancia para el failover** (ver [03-iol-source.md](03-iol-source.md) y [05-scheduler-failover.md](05-scheduler-failover.md)): si IOL pasa a cubrir clases de activo en modo fallback durante horas, correr a la MISMA cadencia que su uso normal puede agotar el cupo mensual mucho antes de fin de mes. La cadencia de fallback debe ser deliberadamente más lenta y configurable por separado. --- # Runbook (placeholder) Este documento se completa recién en M5, una vez que el sistema esté corriendo en producción y haya operación real de la cual documentar. Mientras tanto, queda como placeholder con su propio mini-checklist de qué documentar cuando llegue el momento. Ver contexto general en [00-INDEX.md](00-INDEX.md). ## Pendiente de documentar en M5 - [ ] Cómo reiniciar solo el sidecar de HomeBroker sin reiniciar el backend Node (comando exacto de `docker compose restart homebroker-sidecar`, qué esperar en logs/`/status` durante el reinicio) - [ ] Cómo leer `/status` para diagnosticar "por qué los precios están viejos" — guía paso a paso interpretando cada campo del JSON (ver [06-status-api.md](06-status-api.md)) - [ ] Cómo rotar credenciales `HB_*` / `IOL_*` / `GOOGLE_CREDENTIALS_JSON` — dónde se inyectan en Dokploy, qué reiniciar después de rotarlas - [ ] Qué hacer si `iol.requestsThisMonth` se acerca al `monthlyLimit` (25.000) antes de fin de mes - [ ] Cómo forzar un failover manualmente para testear (apagar el sidecar a propósito) y cómo confirmar la recuperación - [ ] Dónde están los logs de cada servicio (Node y sidecar) y qué buscar en ellos ante una incidencia