-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbuild_segunda_vuelta.js
More file actions
1204 lines (1114 loc) · 52 KB
/
Copy pathbuild_segunda_vuelta.js
File metadata and controls
1204 lines (1114 loc) · 52 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* MODELO 2DA VUELTA 2026 — Keiko vs Sánchez
*
* GRANULARIDAD: distrito (1,892). Cada distrito calcula su voto 2V individualmente.
*
* ESCENARIOS:
* 1. ENCUESTAS → anclado a IPSOS (23-24 abr) + IEP (21-25 abr). PRIMARIO.
* Calibra transferencias para que el agregado nacional reproduzca lo medido y
* respeta los crossbreaks regionales IEP (Lima/Norte/Centro/Sur/Oriente).
* Intra-región: distribuye usando el patrón Castillo 1V 2021 distrital
* (mejor predictor que 2V 2021 porque mide afinidad izquierda "pura").
*
* 2. OPTIMISTA_K → Keiko captura mejor el voto centro-derecha (rechazo K baja a ~40%).
* 3. BASE → Continuación de encuestas con leve corrección.
* 4. PESIMISTA_K → Antifujimorismo se reactiva (rechazo K sube a ~55% como 2021).
*
* FUENTES INTEGRADAS:
* - IPSOS Perú21 23-24 abr 2026: K 38% / S 38% (empate técnico) / blanco-nulo 17%
* - IEP/La República 21-25 abr 2026: K 31% / S 32% / blanco-nulo 24% / NS 13%
* Sobre válido: K 49.2% / S 50.8%
* Cruces regionales IEP (% sobre total muestra):
* Lima Metropolitana: K 41% / S 22% → val K 65% / S 35%
* Costa Norte: K 29% / S 34% → val K 46% / S 54%
* Sierra Centro: K 26% / S 40% → val K 39% / S 61%
* Sierra Sur: K 18% / S 39% → val K 32% / S 68%
* Oriente (Selva): K 29% / S 37% → val K 44% / S 56%
* Cruces NSE: K (A/B 51%, C 36%, D/E 22%); S (A/B 20%, C 28%, D/E 38%).
* - Roberto Sánchez se autodefine "candidato castillista", ex-ministro Castillo.
* - Forense 2021 2da vuelta y 1ra vuelta por distrito (CSVs ONPE-PCM).
*/
const fs = require("fs");
const { execSync } = require("child_process");
const { analizarPolls, calcularTargetRegional } = require("./polls_engine");
// Regenera el modelo predictivo primero
try { execSync("node build_predictive.js", { stdio: "pipe" }); } catch (e) {}
// Pull Polymarket en vivo. Si falla, sigue con el snapshot previo.
try { execSync("node fetch_polymarket.js", { stdio: "inherit" }); }
catch (e) { console.log("⚠️ Polymarket fetch falló — usando snapshot previo si existe."); }
const data = require("./data.json");
const forense2v = require("./forensic_2021_provincias.json"); // 2V 2021
const forense1v = require("./forensic_2021_1v_distritos.json"); // 1V 2021
const modelPred = require("./modelo_predictivo_2026.json"); // modelo agnóstico propio
// Timeseries de encuestas + momentum + tripwires (calculado del polls.json)
const pollsAnalysis = analizarPolls("./polls.json");
// Baseline 2021 2V por distrito (calibrado/oficial) para el comparativo swing
const baseline2021 = fs.existsSync("./baseline_2021_2v.json") ? require("./baseline_2021_2v.json") : null;
const K_NAME = "KEIKO SOFIA FUJIMORI HIGUCHI";
const S_NAME = "ROBERTO HELBERT SANCHEZ PALOMINO";
// ====== ENCUESTAS — anclaje regional IEP (% sobre voto válido) ======
//
// El IEP da % sobre total respondientes. Para sacar % válido (sin blanco/nulo):
// val_K = K / (K + S); val_S = S / (K + S)
//
// Mapeo de cada departamento a una "zona IEP".
const ZONA_IEP_DEPTO = {
"LIMA": "LIMA_METRO", // Lima depto = Lima Metro (~95% del peso)
"CALLAO": "LIMA_METRO", // Callao en bloque con Lima Metro
"TUMBES": "COSTA_NORTE",
"PIURA": "COSTA_NORTE",
"LAMBAYEQUE": "COSTA_NORTE",
"LA LIBERTAD": "COSTA_NORTE",
"ÁNCASH": "COSTA_NORTE",
"ANCASH": "COSTA_NORTE",
"ICA": "COSTA_SUR", // costa sur, partido en encuestas → asumimos Centro
"AREQUIPA": "COSTA_SUR", // costa sur — más centro/sur
"MOQUEGUA": "SIERRA_SUR",
"TACNA": "SIERRA_SUR",
"PUNO": "SIERRA_SUR",
"CUSCO": "SIERRA_SUR",
"APURÍMAC": "SIERRA_SUR",
"APURIMAC": "SIERRA_SUR",
"AYACUCHO": "SIERRA_SUR",
"HUANCAVELICA": "SIERRA_SUR",
"JUNÍN": "SIERRA_CENTRO",
"JUNIN": "SIERRA_CENTRO",
"HUÁNUCO": "SIERRA_CENTRO",
"HUANUCO": "SIERRA_CENTRO",
"PASCO": "SIERRA_CENTRO",
"CAJAMARCA": "SIERRA_CENTRO", // sierra norte → IEP "centro" más representativo
"AMAZONAS": "SIERRA_CENTRO",
"LORETO": "ORIENTE",
"UCAYALI": "ORIENTE",
"MADRE DE DIOS": "ORIENTE",
"SAN MARTÍN": "ORIENTE",
"SAN MARTIN": "ORIENTE",
"EXTRANJERO": "LIMA_METRO", // peruanos en el exterior — perfil urbano educado, más cerca de Lima
};
// Target IEP sobre voto válido (K_pct + S_pct = 100). Para SIERRA_NORTE: aproximamos como Sierra Centro.
// El bloque IEP "Centro" sólo cubre sierra; el "Norte" cubre costa norte.
const TARGET_VAL_IEP = {
LIMA_METRO: { k: 65, s: 35 },
COSTA_NORTE: { k: 46, s: 54 },
COSTA_SUR: { k: 50, s: 50 }, // costa sur híbrida — interpolación
SIERRA_CENTRO: { k: 39, s: 61 },
SIERRA_SUR: { k: 32, s: 68 },
ORIENTE: { k: 44, s: 56 },
};
// B4 — Target regional DINÁMICO: recalibra la forma con crossbreaks de mayo (recency-weighted,
// suavizado 75/25 vs abril). Cae al fallback de abril por zona sin data limpia.
const pollsRaw = require("./polls.json").polls || [];
const TARGET_REGIONAL = calcularTargetRegional(pollsRaw, TARGET_VAL_IEP);
console.log("Target regional recalibrado (válido K, vs abril):");
for (const z of Object.keys(TARGET_VAL_IEP)) {
const r = TARGET_REGIONAL[z], delta = (r.k - TARGET_VAL_IEP[z].k);
console.log(` ${z.padEnd(15)} ${String(r.k).padStart(5)}% K (abr ${TARGET_VAL_IEP[z].k}%, Δ${delta >= 0 ? '+' : ''}${delta.toFixed(1)}pp)`);
}
// Lookup forense por ubigeo
const forense1vMap = new Map();
forense1v.distritos.forEach(d => forense1vMap.set(d.ubigeo, d));
const forense2vDistMap = new Map(); // 2V por distrito (de forense2v.distritos)
forense2v.distritos.forEach(d => forense2vDistMap.set(d.ubigeo, d));
function getForense(ubigeo6) {
return {
"1v": forense1vMap.get(ubigeo6),
"2v": forense2vDistMap.get(ubigeo6),
};
}
// ============================================================
// ESCENARIO ENCUESTAS — calibración regional IEP
// ============================================================
//
// Mecánica:
// Para cada distrito, asignamos voto K/S 2026 mediante 3 pasos:
// 1. Voto base = K_1V_2026 + S_1V_2026 (mantener — ya votaron por ellos)
// 2. Voto "otros" se reparte usando target regional + ajuste distrital por castillo_1V_2021
// 3. Validamos: el agregado por zona IEP debe ≈ target.
//
// Específicamente, para cada zona, ajustamos un parámetro `lambda_zona` tal que
// la suma por zona reproduzca el target. lambda es el % de "otros" que va a K en esa zona.
function distritoZona(dep) {
return ZONA_IEP_DEPTO[dep] || "COSTA_SUR";
}
function correrEncuestas() {
// Paso A: armar todos los distritos con su data básica
const flatDist = [];
for (const dep of data.departamentos) {
for (const prov of (dep.provincias || [])) {
for (const dist of (prov.distritos || [])) {
const ubigeo6 = String(dist.ubigeo || "").padStart(6, "0");
const fb = getForense(ubigeo6);
let kBase = 0, sBase = 0, otrosTotal = 0;
for (const c of (dist.candidatos || [])) {
const v = c.totalVotosValidos || 0;
if (c.nombreCandidato === K_NAME) kBase += v;
else if (c.nombreCandidato === S_NAME) sBase += v;
else otrosTotal += v;
}
// Indicador izquierdista 2021 distrital (% Castillo 1V): clave para asignar transferencia
const pctCastillo1V = fb["1v"]?.pct_castillo ?? null;
const pctIzqTotal1V = fb["1v"]?.pct_izq_total ?? null;
const pctCastillo2V = fb["2v"]?.pct_C ?? null;
flatDist.push({
ubigeo: ubigeo6,
departamento: dep.nombre,
provincia: prov.nombre,
distrito: dist.nombre,
zona: distritoZona(dep.nombre),
k_base: kBase,
s_base: sBase,
otros_total: otrosTotal,
validos_1v: kBase + sBase + otrosTotal,
pct_castillo_1v_2021: pctCastillo1V,
pct_izq_total_1v_2021: pctIzqTotal1V,
pct_castillo_2v_2021: pctCastillo2V,
});
}
}
}
// Paso B: para cada zona, calibrar parámetros para alcanzar el target IEP
// Por simpleza: dentro de cada zona, asumimos que el voto "otros" se reparte
// linearmente con el % Castillo 1V 2021 distrital. El distrito más castillista
// manda 0% del "otros" a Keiko; el menos castillista manda γ% a Keiko.
// Resolvemos γ de modo que el agregado de zona iguale el target.
//
// Modelo intra-distrito:
// pct_otros_a_K_d = γ_zona * (1 - pct_castillo_1v_d / pct_castillo_1v_max_zona)
// pct_otros_a_S_d = δ_zona * (pct_castillo_1v_d / pct_castillo_1v_max_zona)
// pct_otros_a_abst_d = 1 - pct_otros_a_K_d - pct_otros_a_S_d
//
// Pero más simple: calibramos un SHARE de "otros" que va a K por zona, ajustado por castillo_1V:
// share_K(d) = baseK + (1 - 2*norm_castillo) * spread
// share_S(d) = baseS + (2*norm_castillo - 1) * spread
// donde norm_castillo = (pct_castillo_d - min_zona) / (max - min), en [0, 1]
//
// Calibramos baseK por zona para que el agregado dé el target. spread fijo = 0.25.
const zonas = {};
for (const d of flatDist) {
if (!zonas[d.zona]) zonas[d.zona] = { distritos: [], min: Infinity, max: -Infinity };
zonas[d.zona].distritos.push(d);
const c = d.pct_castillo_1v_2021 ?? 19; // si no hay data, asume promedio nacional
zonas[d.zona].min = Math.min(zonas[d.zona].min, c);
zonas[d.zona].max = Math.max(zonas[d.zona].max, c);
}
// Para cada zona, busca baseK que iguala el target
const SPREAD = 0.25;
const ABST_PROMEDIO = 0.30; // ~30% de "otros" se abstiene/blanco (de los encuestados 17-24% blanco/nulo + 7-13% NS)
for (const zonaKey of Object.keys(zonas)) {
const z = zonas[zonaKey];
const tgt = TARGET_REGIONAL[zonaKey] || TARGET_VAL_IEP[zonaKey];
if (!tgt) continue;
const targetRatio = tgt.k / 100; // K / (K+S) sobre valido
// función que dado baseK calcula ratio agregado
function agregado(baseK) {
let totK = 0, totS = 0;
for (const d of z.distritos) {
const c = d.pct_castillo_1v_2021 ?? 19;
const norm = z.max > z.min ? (c - z.min) / (z.max - z.min) : 0.5;
const shareK = Math.max(0, baseK + (1 - 2 * norm) * SPREAD);
// baseS implícito tal que share_abst ≈ ABST_PROMEDIO:
const shareS = Math.max(0, (1 - ABST_PROMEDIO) - shareK);
const fromOtros_K = d.otros_total * shareK;
const fromOtros_S = d.otros_total * shareS;
totK += d.k_base + fromOtros_K;
totS += d.s_base + fromOtros_S;
}
return { totK, totS, ratio: totK / (totK + totS) };
}
// bisección para encontrar baseK
let lo = 0.01, hi = 0.99;
for (let i = 0; i < 60; i++) {
const mid = (lo + hi) / 2;
const r = agregado(mid).ratio;
if (r < targetRatio) lo = mid;
else hi = mid;
}
z.baseK = (lo + hi) / 2;
z.baseS = (1 - ABST_PROMEDIO) - z.baseK;
z._achieved = agregado(z.baseK);
}
// Paso C: aplicar y armar output con todos los distritos calculados
const distritos = [];
for (const zonaKey of Object.keys(zonas)) {
const z = zonas[zonaKey];
for (const d of z.distritos) {
const c = d.pct_castillo_1v_2021 ?? 19;
const norm = z.max > z.min ? (c - z.min) / (z.max - z.min) : 0.5;
const shareK = Math.max(0, z.baseK + (1 - 2 * norm) * SPREAD);
const shareS = Math.max(0, (1 - ABST_PROMEDIO) - shareK);
const shareAbst = Math.max(0, 1 - shareK - shareS);
const fromOtros_K = d.otros_total * shareK;
const fromOtros_S = d.otros_total * shareS;
const abst = d.otros_total * shareAbst;
const kProy = d.k_base + fromOtros_K;
const sProy = d.s_base + fromOtros_S;
const validos = kProy + sProy;
distritos.push({
ubigeo: d.ubigeo,
departamento: d.departamento,
provincia: d.provincia,
distrito: d.distrito,
zona: d.zona,
validos_1v: d.validos_1v,
keiko_base: Math.round(d.k_base),
sanchez_base: Math.round(d.s_base),
otros_1v: Math.round(d.otros_total),
share_otros_a_K: +shareK.toFixed(4),
share_otros_a_S: +shareS.toFixed(4),
share_otros_abst: +shareAbst.toFixed(4),
keiko_proy: Math.round(kProy),
sanchez_proy: Math.round(sProy),
abstencion_inducida: Math.round(abst),
validos_proy: Math.round(validos),
pct_keiko: validos > 0 ? +(kProy / validos * 100).toFixed(2) : 0,
pct_sanchez: validos > 0 ? +(sProy / validos * 100).toFixed(2) : 0,
margen: Math.round(kProy - sProy),
ganador: kProy > sProy ? "K" : "S",
pct_castillo_1v_2021: d.pct_castillo_1v_2021,
pct_castillo_2v_2021: d.pct_castillo_2v_2021,
});
}
}
return { distritos, zonas };
}
// ============================================================
// ESCENARIOS ALTERNATIVOS (sensitividad: ¿qué pasa si cambian fundamentales?)
// ============================================================
//
// Ahora son perturbaciones del escenario ENCUESTAS:
// - OPTIMISTA_K: rechazo K cae 8pp más → reasigna 8% del voto "abst" hacia K
// - PESIMISTA_K: rechazo K sube 8pp → reasigna 8% del voto K-de-transferencia hacia "abst" o S
//
function aplicarPerturbacion(encuestas, perturbacion) {
return encuestas.distritos.map(d => {
let shareK = d.share_otros_a_K;
let shareS = d.share_otros_a_S;
let shareAbst = d.share_otros_abst;
if (perturbacion === "OPTIMISTA_K") {
// 60% del shareAbst y 30% del shareS pasa a K
const moveFromAbst = shareAbst * 0.60;
const moveFromS = shareS * 0.20;
shareK += moveFromAbst + moveFromS;
shareAbst -= moveFromAbst;
shareS -= moveFromS;
} else if (perturbacion === "PESIMISTA_K") {
// 25% del shareK pasa a abst, 15% a S
const moveToAbst = shareK * 0.25;
const moveToS = shareK * 0.15;
shareK -= moveToAbst + moveToS;
shareAbst += moveToAbst;
shareS += moveToS;
}
const fromOtros_K = d.otros_1v * shareK;
const fromOtros_S = d.otros_1v * shareS;
const abst = d.otros_1v * shareAbst;
const kProy = d.keiko_base + fromOtros_K;
const sProy = d.sanchez_base + fromOtros_S;
const validos = kProy + sProy;
return {
...d,
share_otros_a_K: +shareK.toFixed(4),
share_otros_a_S: +shareS.toFixed(4),
share_otros_abst: +shareAbst.toFixed(4),
keiko_proy: Math.round(kProy),
sanchez_proy: Math.round(sProy),
abstencion_inducida: Math.round(abst),
validos_proy: Math.round(validos),
pct_keiko: +(kProy / validos * 100).toFixed(2),
pct_sanchez: +(sProy / validos * 100).toFixed(2),
margen: Math.round(kProy - sProy),
ganador: kProy > sProy ? "K" : "S",
};
});
}
function agregarProvinciaYDepartamento(distritos) {
const provs = new Map();
const deps = new Map();
let nacK = 0, nacS = 0;
for (const d of distritos) {
const provKey = d.ubigeo.slice(0, 4);
if (!provs.has(provKey)) {
provs.set(provKey, { ubigeo: provKey, departamento: d.departamento, provincia: d.provincia, zona: d.zona, k: 0, s: 0, kBase: 0, sBase: 0, validos_1v: 0 });
}
const p = provs.get(provKey);
p.k += d.keiko_proy; p.s += d.sanchez_proy;
p.kBase += d.keiko_base; p.sBase += d.sanchez_base;
p.validos_1v += d.validos_1v;
if (!deps.has(d.departamento)) {
deps.set(d.departamento, { departamento: d.departamento, k: 0, s: 0, validos_1v: 0 });
}
const x = deps.get(d.departamento);
x.k += d.keiko_proy; x.s += d.sanchez_proy;
x.validos_1v += d.validos_1v;
nacK += d.keiko_proy;
nacS += d.sanchez_proy;
}
const provincias = [];
for (const p of provs.values()) {
const val = p.k + p.s;
provincias.push({
ubigeo: p.ubigeo,
departamento: p.departamento,
provincia: p.provincia,
zona: p.zona,
validos_1v: p.validos_1v,
keiko_base: Math.round(p.kBase),
sanchez_base: Math.round(p.sBase),
keiko_proy: Math.round(p.k),
sanchez_proy: Math.round(p.s),
validos_proy: Math.round(val),
pct_keiko: +(p.k / val * 100).toFixed(2),
pct_sanchez: +(p.s / val * 100).toFixed(2),
margen: Math.round(p.k - p.s),
ganador: p.k > p.s ? "K" : "S",
});
}
const departamentos = [];
for (const d of deps.values()) {
const val = d.k + d.s;
departamentos.push({
departamento: d.departamento,
validos_1v: d.validos_1v,
keiko_proy: Math.round(d.k),
sanchez_proy: Math.round(d.s),
pct_keiko: +(d.k / val * 100).toFixed(2),
pct_sanchez: +(d.s / val * 100).toFixed(2),
margen: Math.round(d.k - d.s),
ganador: d.k > d.s ? "K" : "S",
});
}
const validosNac = nacK + nacS;
return {
nacional: {
keiko: Math.round(nacK),
sanchez: Math.round(nacS),
validos: Math.round(validosNac),
pct_keiko: +(nacK / validosNac * 100).toFixed(3),
pct_sanchez: +(nacS / validosNac * 100).toFixed(3),
margen: Math.round(nacK - nacS),
ganador: nacK > nacS ? "Keiko" : "Sánchez",
},
provincias,
departamentos,
};
}
// ============================================================
// EJECUCIÓN
// ============================================================
const out = {
meta: {
generated_at: new Date().toISOString(),
granularidad: "distrito (1,892 unidades)",
fuentes_encuestas: {
ipsos_abr_23_24: { k: 38, s: 38, blanco_nulo: 17, ns: 7, val_k: 50, val_s: 50, rechazo_k: 48, rechazo_s: 43 },
iep_abr_21_25: { k: 31, s: 32, blanco_nulo: 24, ns: 13, val_k: 49.2, val_s: 50.8, muestra: 1207, error_pp: 2.8 },
iep_regionales: TARGET_VAL_IEP,
iep_nse: {
keiko: { ab: 51, c: 36, de: 22 },
sanchez: { ab: 20, c: 28, de: 38 },
},
},
transferencias_calibradas: {},
notas: [
"Modelo calibrado contra encuestas IPSOS (23-24 abril 2026) e IEP (21-25 abril 2026).",
"Sánchez se autodefine como 'candidato castillista' (ex-ministro Castillo) → captura el voto Castillo casi 1:1 en zonas donde Castillo arrasó.",
"Mapeo dep → zona IEP. Lima Metro / Costa Norte / Costa Sur / Sierra Centro / Sierra Sur / Oriente.",
"Dentro de cada zona, voto 'otros' se distribuye por % Castillo 1V 2021 distrital (spread 0.25).",
"Tasa de abstención inducida (~30% del voto 'otros') refleja 17-24% blanco/nulo medido en encuestas.",
],
},
escenarios: {},
};
// 1V nacional
const totV1 = data.nacional.candidatos.reduce((a, c) => a + (c.totalVotosValidos || 0), 0);
const kV1 = data.nacional.candidatos.find(c => c.nombreCandidato === K_NAME)?.totalVotosValidos || 0;
const sV1 = data.nacional.candidatos.find(c => c.nombreCandidato === S_NAME)?.totalVotosValidos || 0;
out.meta.nacional_1v = {
keiko: kV1, sanchez: sV1, otros: totV1 - kV1 - sV1, validos: totV1,
pct_keiko: +(kV1 / totV1 * 100).toFixed(2),
pct_sanchez: +(sV1 / totV1 * 100).toFixed(2),
};
// Escenario ENCUESTAS (primario)
const enc = correrEncuestas();
// ====== RE-ANCLAJE: swing nacional al anclaje doble (recency-weighted) ======
// La FORMA regional viene de IEP-abr (granular y estable distrito a distrito);
// el NIVEL nacional se desplaza uniformemente para igualar el anclaje doble del
// polls_engine, que pondera todas las encuestas por recencia (al 27-may dominan
// IPSOS 16-17may + DATUM 17-20may, ~85% del peso). Mantiene la geografía, refresca
// el titular. Se recalcula solo en cada build conforme entran encuestas nuevas.
// Blend 60/40: anclaje doble (intención sobre total, renormalizado a válido) +
// anclaje simulacros (medición DIRECTA sobre válido de las cédulas Datum/Ipsos).
// Los simulacros excluyen el limbo 22-25% → mejor estimador del balotaje real; pesan 40%.
const ANCLA_DOBLE_K = pollsAnalysis.anclaje_doble?.promedio_ponderado?.val_k;
const ANCLA_SIM_K = pollsAnalysis.anclaje_simulacros?.promedio_ponderado?.val_k;
const TARGET_ENC_K = (ANCLA_SIM_K != null && ANCLA_DOBLE_K != null)
? +(0.6 * ANCLA_DOBLE_K + 0.4 * ANCLA_SIM_K).toFixed(2)
: ANCLA_DOBLE_K;
if (ANCLA_SIM_K != null) {
console.log(`Anclaje blend: doble ${ANCLA_DOBLE_K}% (60%) + simulacros ${ANCLA_SIM_K}% (40%) → TARGET_ENC_K ${TARGET_ENC_K}%`);
}
if (TARGET_ENC_K) {
// Filtra distritos sin votos (ONPE puede traer distritos vacíos durante el conteo)
const isValidDist = d => Number.isFinite(d.validos_proy) && d.validos_proy > 0 && Number.isFinite(d.pct_keiko);
const totVal = enc.distritos.reduce((a, d) => isValidDist(d) ? a + d.validos_proy : a, 0);
const curK = totVal > 0
? enc.distritos.reduce((a, d) => isValidDist(d) ? a + d.validos_proy * d.pct_keiko / 100 : a, 0) / totVal * 100
: 0;
const SWING_ENC_PP = TARGET_ENC_K - curK;
enc.distritos = enc.distritos.map(d => {
if (!isValidDist(d)) return d; // distritos vacíos: dejarlos como están (no propagar NaN)
const pct_K = Math.max(3, Math.min(97, d.pct_keiko + SWING_ENC_PP));
const pct_S = 100 - pct_K;
const k = Math.round(d.validos_proy * pct_K / 100);
const s = Math.round(d.validos_proy * pct_S / 100);
return { ...d, keiko_proy: k, sanchez_proy: s, validos_proy: k + s,
pct_keiko: +pct_K.toFixed(2), pct_sanchez: +pct_S.toFixed(2),
margen: k - s, ganador: k > s ? "K" : "S" };
});
enc.reanclaje = { target_k: TARGET_ENC_K, base_k: +curK.toFixed(2), swing_pp: +SWING_ENC_PP.toFixed(2) };
console.log(`Re-anclaje ENCUESTAS: ${curK.toFixed(2)}% → ${TARGET_ENC_K}% K (swing ${SWING_ENC_PP > 0 ? '+' : ''}${SWING_ENC_PP.toFixed(2)}pp · forma regional recalibrada con crossbreaks de mayo)`);
}
out.escenarios.ENCUESTAS = {
label: "Encuestas (forma regional recalibrada con crossbreaks de mayo · nivel re-anclado a blend anclaje doble + simulacros)",
target_regional: TARGET_REGIONAL,
...agregarProvinciaYDepartamento(enc.distritos),
distritos: enc.distritos,
calibracion: Object.fromEntries(Object.entries(enc.zonas).map(([k, v]) => [k, {
base_share_K: +v.baseK.toFixed(4),
base_share_S: +v.baseS.toFixed(4),
achieved_pctK: +(v._achieved.totK / (v._achieved.totK + v._achieved.totS) * 100).toFixed(2),
target_pctK: TARGET_REGIONAL[k]?.k ?? TARGET_VAL_IEP[k]?.k ?? null,
target_abril_pctK: TARGET_VAL_IEP[k]?.k ?? null,
n_distritos: v.distritos.length,
}])),
reanclaje: enc.reanclaje ?? null,
};
// ============================================================
// ESCENARIO MAS_PROBABLE — integra encuestas + todas las señales
// ============================================================
//
// Ajustes sobre ENCUESTAS para reflejar lo que sabemos del 16-may (3 semanas a la votación):
//
// 1. Momentum antivoto K: cayó 11pp en 3 semanas (59% → 48%).
// Si sigue a la mitad de esa velocidad: -3pp más en 3 semanas → favorece K ~+1.5pp
// Pero los modelos de antivoto suelen estabilizarse al acercarse el día D.
// Net: +1.0pp K
//
// 2. Anti-momentum recta final (efecto 2016): el antifujimorismo histórico se moviliza
// en la última semana. En 2016 Keiko cayó 10pp en 2 semanas (de Keiko+10 a PPK+0.25).
// No será tan brutal porque rechazo S también subió, pero hay riesgo.
// Net: -1.5pp K
//
// 3. Voto extranjero: ~500K votos exterior, en 2V 2026 entre K vs S, perfil urbano
// educado fuera del país → ~72% K (vs 50/50 asumido en ENCUESTAS).
// Diferencia: 500K * (0.72 - 0.50) = +110K margen a K = +0.7pp nacional
//
// 4. Voto del miedo a Castillo 2.0: Sánchez se autodefine castillista. Una parte
// del centro/centro-izq que en encuestas dice "blanco/nulo" termina votando K
// por miedo a régimen castillista en el día D.
// Net: +0.5pp K
//
// Total ajuste neto: +0.7pp K sobre ENCUESTAS (50.80% → ~51.5%)
//
// Estos números están redondeados y son mi mejor estimación. La incertidumbre real
// se mide via Monte Carlo más abajo.
function aplicarMasProbable(encDistritos) {
// Ajustes globales (sobre share_otros, no shift directo del %K)
const SHIFT_MOMENTUM_ANTIVOTO_K = +0.010; // +1pp K
const SHIFT_RECTA_FINAL_2016 = -0.015; // -1.5pp K (efecto histórico)
const SHIFT_MIEDO_CASTILLO_20 = +0.005; // +0.5pp K (voto del miedo)
// Voto extranjero se aplica como overlay a distritos con dep=EXTRANJERO
const NET_SHIFT_NACIONAL = SHIFT_MOMENTUM_ANTIVOTO_K + SHIFT_RECTA_FINAL_2016 + SHIFT_MIEDO_CASTILLO_20;
// = 0.000 (se cancelan en promedio). Pero NO se cancelan por distrito por la dirección.
// Implementación: el net shift se reparte como un % de "otros" desde abst+S hacia K.
// Por simpleza, lo aplicamos como una transferencia del share_S al share_K en cada distrito.
return encDistritos.map(d => {
let shareK = d.share_otros_a_K;
let shareS = d.share_otros_a_S;
let shareAbst = d.share_otros_abst;
// Aplicar net shift: si net positivo → muevo de abst hacia K. Si negativo → de K hacia abst.
if (NET_SHIFT_NACIONAL > 0) {
const mv = Math.min(shareAbst, NET_SHIFT_NACIONAL * 2.5); // factor 2.5 porque shift es nacional sobre 1.0 ≈ 70% del electorado
shareK += mv;
shareAbst -= mv;
} else if (NET_SHIFT_NACIONAL < 0) {
const mv = Math.min(shareK, -NET_SHIFT_NACIONAL * 2.5);
shareK -= mv;
shareAbst += mv;
}
// Voto extranjero: si dep = EXTRANJERO, override con 72% K / 28% S
const isExtranjero = d.departamento === "EXTRANJERO";
const fromOtros_K = d.otros_1v * shareK;
const fromOtros_S = d.otros_1v * shareS;
const abst = d.otros_1v * shareAbst;
let kProy = d.keiko_base + fromOtros_K;
let sProy = d.sanchez_base + fromOtros_S;
if (isExtranjero) {
// Override: total válido proyectado se asigna 72/28
const validosExt = kProy + sProy;
kProy = validosExt * 0.72;
sProy = validosExt * 0.28;
}
const validos = kProy + sProy;
return {
...d,
share_otros_a_K: +shareK.toFixed(4),
share_otros_a_S: +shareS.toFixed(4),
share_otros_abst: +shareAbst.toFixed(4),
keiko_proy: Math.round(kProy),
sanchez_proy: Math.round(sProy),
abstencion_inducida: Math.round(abst),
validos_proy: Math.round(validos),
pct_keiko: +(kProy / validos * 100).toFixed(2),
pct_sanchez: +(sProy / validos * 100).toFixed(2),
margen: Math.round(kProy - sProy),
ganador: kProy > sProy ? "K" : "S",
};
});
}
// ============================================================
// MONTE CARLO — distribución de probabilidad
// ============================================================
//
// Modelo: cada simulación perturba 4 parámetros con ruido normal y calcula resultado nacional.
// ε1 = shift_antivoto_3sem ~ Normal(0, σ=0.015) incertidumbre tendencia antivoto
// ε2 = shock_late_camp ~ Normal(-0.005, σ=0.012) efecto recta final (sesgo histórico)
// ε3 = voto_ext_K ~ Normal(0.72, σ=0.05) voto extranjero
// ε4 = correlacion_zonal ~ Normal(0, σ=0.020) ruido por zona IEP
//
// 5000 corridas. Output: P(K gana), mediana K%, CI80, CI95.
function gaussian(mu = 0, sigma = 1) {
// Box-Muller
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return mu + sigma * Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
// ===== Soporte Monte Carlo con colas gordas + correlación zonal (B3) =====
//
// Problema del MC anterior: shocks zonales gaussianos e INDEPENDIENTES → al promediar
// 6 zonas la varianza nacional colapsa (ley de grandes números) y subestima la cola.
// Eso inflaba P(K) a ~82% pese a un margen de 2-5pp. Dos correcciones:
// 1. Correlación zonal vía Cholesky: los errores de encuesta no son independientes
// entre regiones (un sesgo metodológico mueve varias zonas a la vez).
// 2. Colas gordas vía Student-t (ν=4): los "polling misses" históricos (2016, 2021,
// Brexit, Trump) son leptocúrticos — sorpresas grandes más frecuentes que en una normal.
function chi2(nu) {
let s = 0;
for (let i = 0; i < nu; i++) { const z = gaussian(0, 1); s += z * z; }
return s;
}
function choleskyDecomp(A) {
const n = A.length;
const L = Array.from({ length: n }, () => new Array(n).fill(0));
for (let i = 0; i < n; i++) {
for (let j = 0; j <= i; j++) {
let sum = 0;
for (let k = 0; k < j; k++) sum += L[i][k] * L[j][k];
if (i === j) {
const d = A[i][i] - sum;
if (d <= 0) return null; // no positiva-definida → caller usa fallback independiente
L[i][j] = Math.sqrt(d);
} else {
L[i][j] = (A[i][j] - sum) / L[j][j];
}
}
}
return L;
}
// Orden y matriz de correlación zonal. Base 0.2 (un sesgo nacional mueve todas las zonas
// un poco); pares con afinidad estructural sobre-ponderados.
const ZONA_ORDEN_MC = ["LIMA_METRO", "COSTA_NORTE", "COSTA_SUR", "SIERRA_CENTRO", "SIERRA_SUR", "ORIENTE"];
function matrizCorrelacionZonal() {
const n = 6, base = 0.2;
const M = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : base)));
const set = (a, b, r) => { M[a][b] = r; M[b][a] = r; };
set(0, 1, 0.65); // Lima ↔ Costa Norte (costa urbana, dinámica pro-K compartida)
set(3, 4, 0.75); // Sierra Centro ↔ Sierra Sur (bloque andino pro-S)
return M;
}
function correrMonteCarlo(encDistritos, nSims = 5000) {
const results = [];
for (let i = 0; i < nSims; i++) {
const e1 = gaussian(0, 0.015);
const e2 = gaussian(-0.005, 0.012);
const e3 = gaussian(0.72, 0.05);
const e4_byZone = {
LIMA_METRO: gaussian(0, 0.020),
COSTA_NORTE: gaussian(0, 0.020),
COSTA_SUR: gaussian(0, 0.020),
SIERRA_CENTRO: gaussian(0, 0.020),
SIERRA_SUR: gaussian(0, 0.020),
ORIENTE: gaussian(0, 0.020),
};
const shiftGlobal = e1 + e2 + 0.005; // +0.5pp miedo Castillo fijo
let totK = 0, totS = 0;
for (const d of encDistritos) {
let sK = d.share_otros_a_K;
let sS = d.share_otros_a_S;
let sA = d.share_otros_abst;
// Aplicar shift global (desde abst a K si positivo)
const totalShift = shiftGlobal * 2.5 + (e4_byZone[d.zona] || 0) * 2.0;
if (totalShift > 0) {
const mv = Math.min(sA, totalShift);
sK += mv; sA -= mv;
} else if (totalShift < 0) {
const mv = Math.min(sK, -totalShift);
sK -= mv; sA += mv;
}
sK = Math.max(0, Math.min(1, sK));
sS = Math.max(0, Math.min(1, sS));
let kProy = d.keiko_base + d.otros_1v * sK;
let sProy = d.sanchez_base + d.otros_1v * sS;
if (d.departamento === "EXTRANJERO") {
const v = kProy + sProy;
kProy = v * e3;
sProy = v * (1 - e3);
}
totK += kProy;
totS += sProy;
}
const pctK = totK / (totK + totS) * 100;
results.push(pctK);
}
results.sort((a, b) => a - b);
const median = results[Math.floor(nSims * 0.5)];
const p10 = results[Math.floor(nSims * 0.10)];
const p90 = results[Math.floor(nSims * 0.90)];
const p025 = results[Math.floor(nSims * 0.025)];
const p975 = results[Math.floor(nSims * 0.975)];
const pKwins = results.filter(r => r > 50).length / nSims * 100;
return {
n_sims: nSims,
mediana_pct_keiko: +median.toFixed(2),
mediana_pct_sanchez: +(100 - median).toFixed(2),
prob_keiko_gana: +pKwins.toFixed(1),
prob_sanchez_gana: +(100 - pKwins).toFixed(1),
ci80_keiko: [+p10.toFixed(2), +p90.toFixed(2)],
ci95_keiko: [+p025.toFixed(2), +p975.toFixed(2)],
parametros: {
momentum_antivoto_k: { mu: 0, sigma: 0.015 },
late_campaign_2016_effect: { mu: -0.005, sigma: 0.012 },
voto_extranjero_k: { mu: 0.72, sigma: 0.05 },
ruido_zonal: { mu: 0, sigma: 0.020 },
shift_miedo_castillo: 0.005,
},
};
}
// Sensibilidades
const opt = aplicarPerturbacion({ distritos: enc.distritos }, "OPTIMISTA_K");
out.escenarios.OPTIMISTA_K = {
label: "Optimista K (rechazo K cae 8pp más: 60% abst + 20% S → K)",
...agregarProvinciaYDepartamento(opt),
distritos: opt,
};
const pes = aplicarPerturbacion({ distritos: enc.distritos }, "PESIMISTA_K");
out.escenarios.PESIMISTA_K = {
label: "Pesimista K (rechazo K rebrota: 25% de K → abst, 15% K → S)",
...agregarProvinciaYDepartamento(pes),
distritos: pes,
};
// ============================================================
// MODELO_PROPIO — predicción agnóstica de encuestas
// ============================================================
//
// Resultado del modelo predictivo distrital (build_predictive.js):
// continuity_factor × ajuste_castillo_personal × ajuste_urbano + antivoto K + efecto 2016
// Da: K ~52% / S ~48% (5pp diferente a IEP)
//
// Lo integro como escenario MODELO_PROPIO con todas sus métricas.
const modelPredMap = new Map();
modelPred.distritos.forEach(d => modelPredMap.set(d.ubigeo, d));
function distritosFromModelPred() {
// Adaptar formato del modelo predictivo al formato esperado
return modelPred.distritos.map(d => ({
ubigeo: d.ubigeo,
departamento: d.departamento,
provincia: d.provincia,
distrito: d.distrito,
zona: distritoZona(d.departamento),
validos_1v: 0,
keiko_base: 0,
sanchez_base: 0,
otros_1v: 0,
share_otros_a_K: 0,
share_otros_a_S: 0,
share_otros_abst: 0,
keiko_proy: d.votos_keiko_pred,
sanchez_proy: d.votos_sanchez_pred,
abstencion_inducida: 0,
validos_proy: d.votos_keiko_pred + d.votos_sanchez_pred,
pct_keiko: d.pct_keiko_pred,
pct_sanchez: d.pct_sanchez_pred,
margen: d.margen_pred,
ganador: d.ganador_pred,
pct_castillo_1v_2021: null,
pct_castillo_2v_2021: d.pct_castillo_2V_2021,
continuity_factor: d.continuity_factor,
ajuste_castillo_personal: d.ajuste_castillo_personal,
ajuste_urbano: d.ajuste_urbano,
}));
}
const modeloPropioDist = distritosFromModelPred();
out.escenarios.MODELO_PROPIO = {
label: `Modelo propio agnóstico (back-test RMSE ${modelPred.meta.back_test_rmse}pp)`,
...agregarProvinciaYDepartamento(modeloPropioDist),
distritos: modeloPropioDist,
metodologia: modelPred.meta.metodologia,
validacion: modelPred.meta.validacion_cruzada,
validacion_zonal: modelPred.validacion_zonal,
};
// ============================================================
// MAS_PROBABLE — Bayesian blend 3-way (modelo + encuestas + Polymarket)
// ============================================================
//
// Polymarket lee de polymarket_live.json (regenerado por fetch_polymarket.js).
// Si el archivo no existe (e.g. API caída en momento de build), usa fallback.
let POLYMARKET_P_K = 0.74; // fallback ≈ último conocido
let POLYMARKET_VOL_USD = 19000000;
let POLYMARKET_FETCHED_AT = null;
let POLYMARKET_VOL_24H = 0;
let POLYMARKET_RESIDUAL = 0;
try {
const pmLive = JSON.parse(fs.readFileSync("./polymarket_live.json", "utf8"));
POLYMARKET_P_K = pmLive.prob_keiko_gana;
POLYMARKET_VOL_USD = pmLive.volumen_usd_total;
POLYMARKET_VOL_24H = pmLive.volumen_24h_usd;
POLYMARKET_FETCHED_AT = pmLive.fetched_at;
POLYMARKET_RESIDUAL = pmLive.prob_residual;
console.log(`Polymarket LIVE: K ${(POLYMARKET_P_K*100).toFixed(1)}% · vol $${(POLYMARKET_VOL_USD/1e6).toFixed(1)}M · fetched ${POLYMARKET_FETCHED_AT}`);
} catch (e) {
console.log("Polymarket LIVE no disponible — usando fallback K=" + POLYMARKET_P_K);
}
// Convertir P(ganar) a "%K esperado en voto":
// En victoria de K, escenario condicional medio ≈ 52% (margen apretado).
// En victoria de S, condicional medio ≈ 48%.
const POLYMARKET_PCT_K_IMPLIED = POLYMARKET_P_K * 52 + (1 - POLYMARKET_P_K) * 48;
// Pesos del blend bayesiano (suman 1.0):
const W_MODELO = 0.30; // modelo propio (RMSE 11pp por distrito, alto)
const W_ENCUESTAS = 0.45; // encuestas IEP/IPSOS (±2.8pp error muestral, mayor metodología)
const W_POLYMARKET = 0.25; // smart money con skin in the game ($53M apostado)
const encDistMap = new Map();
enc.distritos.forEach(d => encDistMap.set(d.ubigeo, d));
// Polymarket no tiene granularidad distrital. Aplicamos un shift uniforme nacional
// para llevar el agregado del blend modelo+encuestas hacia POLYMARKET_PCT_K_IMPLIED.
// Primero blend modelo+encuestas sin Polymarket para saber su agregado nacional
let totK_modEnc = 0, totS_modEnc = 0;
const blendModEnc = [];
for (const dMP of modeloPropioDist) {
const dEnc = encDistMap.get(dMP.ubigeo);
if (!dEnc) { blendModEnc.push({ ...dMP }); continue; }
const w_mod_norm = W_MODELO / (W_MODELO + W_ENCUESTAS);
const w_enc_norm = W_ENCUESTAS / (W_MODELO + W_ENCUESTAS);
const pct_K_blend = dMP.pct_keiko * w_mod_norm + dEnc.pct_keiko * w_enc_norm;
const validos = Math.max(dMP.validos_proy, dEnc.validos_proy);
blendModEnc.push({ ...dEnc, pct_keiko: pct_K_blend, validos_proy: validos });
totK_modEnc += validos * pct_K_blend / 100;
totS_modEnc += validos * (100 - pct_K_blend) / 100;
}
const pct_K_modEnc_nac = totK_modEnc / (totK_modEnc + totS_modEnc) * 100;
// Shift necesario para alcanzar la mezcla 3-way: pct_K_final = (1-Wpoly)*pct_K_modEnc + Wpoly*POLYMARKET
const target_K_nac = (W_MODELO + W_ENCUESTAS) * pct_K_modEnc_nac + W_POLYMARKET * POLYMARKET_PCT_K_IMPLIED;
const SHIFT_POLYMARKET_PP = target_K_nac - pct_K_modEnc_nac;
console.log(`Blend mod+enc nacional: K ${pct_K_modEnc_nac.toFixed(2)}% · target final con polymarket: ${target_K_nac.toFixed(2)}% · shift: ${SHIFT_POLYMARKET_PP.toFixed(2)}pp`);
// Aplicar shift uniforme a cada distrito
const mpDist = [];
for (const d of blendModEnc) {
const pct_K_final = d.pct_keiko + SHIFT_POLYMARKET_PP;
const pct_S_final = 100 - pct_K_final;
const validos = d.validos_proy;
const votos_K = Math.round(validos * pct_K_final / 100);
const votos_S = Math.round(validos * pct_S_final / 100);
const dMP_for_log = modeloPropioDist.find(x => x.ubigeo === d.ubigeo);
const dEnc_for_log = encDistMap.get(d.ubigeo);
mpDist.push({
...d,
keiko_proy: votos_K,
sanchez_proy: votos_S,
validos_proy: votos_K + votos_S,
pct_keiko: +pct_K_final.toFixed(2),
pct_sanchez: +pct_S_final.toFixed(2),
margen: votos_K - votos_S,
ganador: votos_K > votos_S ? "K" : "S",
blend_modelo_pct_k: dMP_for_log?.pct_keiko,
blend_encuestas_pct_k: dEnc_for_log?.pct_keiko,
blend_polymarket_shift_pp: +SHIFT_POLYMARKET_PP.toFixed(2),
});
}
out.escenarios.MAS_PROBABLE = {
label: `Más probable (blend 3-way: 30% modelo + 45% encuestas + 25% Polymarket)`,
...agregarProvinciaYDepartamento(mpDist),
distritos: mpDist,
blend: {
pesos: { modelo_propio: W_MODELO, encuestas: W_ENCUESTAS, polymarket: W_POLYMARKET },
polymarket: {
prob_keiko_gana: POLYMARKET_P_K,
prob_sanchez_gana: +(1 - POLYMARKET_P_K - POLYMARKET_RESIDUAL).toFixed(4),
prob_residual: POLYMARKET_RESIDUAL,
pct_k_implied: +POLYMARKET_PCT_K_IMPLIED.toFixed(2),
volumen_usd: POLYMARKET_VOL_USD,
volumen_24h_usd: POLYMARKET_VOL_24H,
fetched_at: POLYMARKET_FETCHED_AT,
url: "https://polymarket.com/event/peru-presidential-election-winner",
},
justificacion: "Blend 3-way ponderado: 30% modelo distrital (RMSE 11pp pero captura tendencias estructurales); 45% encuestas IEP/IPSOS (±2.8pp pero metodología validada); 25% Polymarket (smart money $53M apostado, free of polling biases).",
},
};
// ============================================================
// ESCENARIO MOMENTUM — extrapola velocidades IPSOS al día D
// ============================================================
//
// Toma el escenario ENCUESTAS como base distrital y aplica un shift uniforme
// para que el % nacional iguale el momentum proyectado del polls_engine.
// El momentum_engine ya computó el % nacional válido proyectado al 7-jun
// usando velocidades observadas IPSOS (abr→may) con dampening 0.5
// y reparto del limbo con prior histórico Perú 2da vuelta.
if (pollsAnalysis.escenario_momentum) {
const targetMomentumK = pollsAnalysis.escenario_momentum.nacional_valido.k;
const baseEncNac = out.escenarios.ENCUESTAS.nacional;
const baseEncK = baseEncNac.pct_keiko;
const SHIFT_MOMENTUM_PP = targetMomentumK - baseEncK;
const momentumDist = enc.distritos.map(d => {
const pct_K = Math.max(3, Math.min(97, d.pct_keiko + SHIFT_MOMENTUM_PP));
const pct_S = 100 - pct_K;
const validos = d.validos_proy;
const k = Math.round(validos * pct_K / 100);
const s = Math.round(validos * pct_S / 100);
return {
...d,
keiko_proy: k,
sanchez_proy: s,
validos_proy: k + s,
pct_keiko: +pct_K.toFixed(2),
pct_sanchez: +pct_S.toFixed(2),
margen: k - s,
ganador: k > s ? "K" : "S",
};
});
out.escenarios.MOMENTUM = {
label: `Momentum (extrapola velocidades IPSOS abr→may al día D, dampening 0.5)`,
...agregarProvinciaYDepartamento(momentumDist),
distritos: momentumDist,
momentum: pollsAnalysis.escenario_momentum,
shift_aplicado_pp: +SHIFT_MOMENTUM_PP.toFixed(2),
justificacion: "Toma la última IPSOS (16-17 may) por estrato (Lima/Otras/Rural), extrapola con velocidad observada de 3 semanas previas (con 50% dampening porque tendencias se aplanan cerca del día D), reparte el limbo (indec+B/N) con prior histórico Perú: 55% al líder, 30% al rezagado, 15% nulo extra; +10% al líder si el rezagado es percibido radical (sesgo anti-castillista observado en 2021).",
};
console.log(`Momentum nacional: K ${targetMomentumK}% (shift sobre ENCUESTAS: ${SHIFT_MOMENTUM_PP > 0 ? '+' : ''}${SHIFT_MOMENTUM_PP.toFixed(2)}pp)`);
}
// Adjuntar análisis del timeseries (polls_engine) al output
out.polls_timeseries = pollsAnalysis;
// ============================================================
// COMPARATIVO 2021 vs 2026 — baseline real + contrafactual swing
// ============================================================
//
// Toma el resultado REAL 2021 2V por distrito (Castillo vs Keiko) y, manteniendo
// el turnout/padrón de cada distrito, aplica las preferencias PROYECTADAS 2026 por
// escenario. Responde: "si la elección de 2021 se repitiera con los % de hoy,
// ¿Keiko voltea el resultado y por cuántos votos?". El voto extranjero (no proyectado
// por encuestas) se mantiene en su patrón 2021. Se recalcula en cada build.
if (baseline2021) {
const CONTINENTES_EXT = new Set(["AFRICA", "AMERICA", "ASIA", "EUROPA", "OCEANIA"]);
const b21 = new Map(baseline2021.distritos.map(d => [String(d.ubigeo), d]));
const foreign = baseline2021.distritos.filter(d => CONTINENTES_EXT.has(d.departamento));
const fK = foreign.reduce((a, d) => a + d.votos_K, 0);
const fC = foreign.reduce((a, d) => a + d.votos_C, 0);
const contrafactual = (scen) => {
const dists = out.escenarios[scen]?.distritos || [];
let cfK = 0, cfS = 0, valMatched = 0, matched = 0, flipCaK = 0, flipKaC = 0;
for (const d of dists) {
const b = b21.get(String(d.ubigeo));
if (!b || CONTINENTES_EXT.has(b.departamento)) continue;
const projK = d.pct_keiko / 100;
cfK += b.validos * projK;
cfS += b.validos * (1 - projK);
valMatched += b.validos; matched++;
const won26 = d.pct_keiko >= 50 ? "K" : "S";
if (b.ganador === "C" && won26 === "K") flipCaK++;
if (b.ganador === "K" && won26 === "S") flipKaC++;
}