Repository navigation
Expand file tree
/
Copy pathserver.py
More file actions
1840 lines (1617 loc) · 88.4 KB
/
Copy pathserver.py
File metadata and controls
1840 lines (1617 loc) · 88.4 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
"""
server.py — FastAPI backend for the React rebuild of the ASEAN ESG Momentum Radar.
====================================================================================
The Python "brain" stays exactly as it was: core / contracts / metrics / universe / rag /
datasource / stage1 / stage2 are imported unchanged (frozen modules untouched). This file is
the NEW presentation boundary: it exposes the brain over REST (+ one SSE stream for Stage 2)
so the React app in web/ can render it. Stage 3's job (rendering the answer) now lives in
React — this server only ever returns verified contract batons, never re-words them.
What it ports from the old Streamlit shell (app.py), as pure request/response logic:
- the dashboard board state (filters -> metrics aggregation),
- the chat intent parser (_chat_act) as structured actions,
- the watchlist / snapshot registry (in-memory + data/watchlist.json persistence),
- the deep-dive relay plumbing (default NarrowedQuestion, Stage-2 progress phases).
HARD RULES honoured: LLM only via core.call_llm, fetch only via core.http_get (both inside
the imported modules), retrieval best-effort, never fabricate, key from env only.
Run:
venv/bin/python server.py # http://localhost:8000
(dev UI: cd web && npm run dev -> http://localhost:5173, proxies /api to :8000)
"""
import datetime as _dt
import json
import os
import queue
import re
import threading
import time
from fastapi import Body, FastAPI, File, HTTPException, Query, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, PlainTextResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
import anchor
import benchmarks
import brief as brief_mod
import clients as clients_mod
import company_metadata
import contracts
import core
import datasource
import engine
import engine_config
import harvest
import lseg
import metrics
import pipeline_counts
import news
import quotes
import rationale
import sensitivity
import stage1
import stage2
import universe
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
FIXTURES_DIR = os.path.join(BASE_DIR, "fixtures")
SAMPLE_FILE = os.path.join(DATA_DIR, "hero_company.json")
WATCHLIST_FILE = os.path.join(DATA_DIR, "watchlist.json")
WEB_DIST = os.path.join(BASE_DIR, "web", "dist")
# Uploads are read into memory, so they are capped. See /api/upload.
MAX_UPLOAD_BYTES = int(float(os.environ.get("ESG_MAX_UPLOAD_MB", "5")) * 1024 * 1024)
# Stage-2 progress phases (same narration as the Streamlit shell): as each Contract-C key
# appears in the streamed JSON, advance the status label against REAL progress.
_S2_PHASES = [
("what_rating_sees", "Reading the stale rating + its history…"),
("what_we_see", "Checking the live signal the rating can't see…"),
("check_before_monday", "Framing the check for your mandate…"),
("competes_summary", "Forming the disagreement…"),
("reasoning", "Writing out its chain of thought…"),
]
_ORIGIN_BADGE = {"live": "Live (fetched)", "upload": "Uploaded", "sample": "Sample",
"dataset": "Dataset"}
_ORIGIN_DISCLAIMER = {
"live": "Built live from public sources — fields not found are shown as “unknown”, never "
"invented. Not investment advice.",
"upload": "Built from your uploaded data. Not investment advice.",
"sample": "Illustrative sample — placeholder data, not real facts about any real company.",
"dataset": "Built from pre-scored local dataset values (not a live fetch). Not investment "
"advice.",
}
app = FastAPI(title="ASEAN ESG Momentum Radar API", version="2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
allow_methods=["*"], allow_headers=["*"],
)
# --------------------------------------------------------------------------- #
# SNAPSHOT REGISTRY (what Streamlit kept in st.session_state.snapshots)
# --------------------------------------------------------------------------- #
_ENTRIES = {} # ticker -> {"company", "snap", "answer", "narrowed_q", "built_at"}
_ENTRIES_LOCK = threading.Lock()
_BUILT_AT = [time.time()]
def _load_watchlist():
try:
with open(WATCHLIST_FILE, encoding="utf-8") as f:
data = json.load(f)
return [t for t in (data.get("tickers") or []) if isinstance(t, str)]
except (OSError, ValueError):
return []
def _save_watchlist(tickers):
try:
with open(WATCHLIST_FILE, "w", encoding="utf-8") as f:
json.dump({"tickers": list(dict.fromkeys(tickers))}, f, indent=2)
except OSError:
pass
_WATCHLIST = _load_watchlist()
def _pin(company):
"""Compute a snapshot for a built CompanyData and add it to the monitored board."""
snap = datasource.snapshot_from_company(company)
tk = company.get("ticker") or company.get("_constituent_ticker") or snap.get("ticker") or ""
if not _known(tk):
name = company.get("company") or ""
slug = re.sub(r"[^A-Za-z0-9]+", "", name)[:12]
tk = f"{slug or 'live'}-{time.time_ns()}"
entry = {"company": company, "snap": snap, "answer": None, "narrowed_q": None,
"built_at": time.time()}
with _ENTRIES_LOCK:
_ENTRIES[tk] = entry
_BUILT_AT[0] = entry["built_at"]
if tk not in _WATCHLIST:
_WATCHLIST.append(tk)
_save_watchlist(_WATCHLIST)
return tk
def _unpin(ticker):
with _ENTRIES_LOCK:
if ticker in _WATCHLIST:
_WATCHLIST.remove(ticker)
_ENTRIES.pop(ticker, None)
_save_watchlist(_WATCHLIST)
def _entry_json(ticker, demo=False):
"""Full deep-dive payload for one monitored company (Contract B + snap + cached batons).
Also carries the two industry benchmarks and, for a real listing, a live quote. An UPLOADED
company arrives here like any other, so an upload gets benchmarked against its ASEAN peers
and its OECD industry with no extra plumbing — which is the whole point of hanging this off
the entry rather than off the universe.
"""
entry = _ENTRIES.get(ticker)
if not entry:
return None
company = entry["company"]
bd = metrics.esg_breakdown(company)
if bd is None and isinstance(company, dict) and isinstance(company.get("_esg_breakdown"), dict):
bd = company.get("_esg_breakdown")
return {
"ticker": ticker,
"company": company,
"snap": entry["snap"],
"answer": entry["answer"],
"narrowed_q": entry["narrowed_q"],
"default_nq": _default_nq(company),
"built_at": entry["built_at"],
"financial": metrics.financial_snapshot(company),
"breakdown": bd,
"origin_badge": _ORIGIN_BADGE.get(company.get("_origin", "sample"), ""),
"origin_disclaimer": _ORIGIN_DISCLAIMER.get(company.get("_origin", "sample"), ""),
# score_higher_better is the snapshot's own reading of which way this company's static
# rating runs; the benchmark needs it before it dares subtract anything from it.
"benchmark": benchmarks.compare_company(
company, demo=demo, higher_better=entry["snap"].get("score_higher_better")),
# The name travels with the ticker: the PSE source is trusted only because the
# company it names is read back and compared to ours before a price is shown.
"quote": quotes.quote(ticker, demo=demo, company=company.get("company", "")),
}
def _default_nq(company):
"""A sensible default NarrowedQuestion so a deep dive can 'compete' without interrogation
(ported from app.py — grounded clause only for foundation-basket constituents)."""
name = company.get("company", "this company")
grounded = _known(company.get("_constituent_ticker"))
if grounded:
q = (f"Is {name}'s ESG profile as solid as its static rating and 2019–2023 improvement "
"imply, once you weigh the live AI / news / behaviour signals the rating can't see?")
else:
q = (f"Is {name}'s ESG profile as solid as its static rating implies, once you weigh the "
"live AI / news / behaviour signals the rating can't see?")
return contracts.coerce_narrowed_question({
"narrowed_question": q, "mandate": "risk",
"sector": company.get("sector", "unknown"), "horizon": "near_term", "trail": [],
})
def _known(v):
return bool(v) and str(v).strip().lower() not in ("", "unknown")
def _short_sector(s):
s = s or "All"
return "All industries" if s == "All" else s.split("—")[-1].strip()
def _band_emoji(snap):
tone = (snap.get("band_tone") or "").strip().lower()
if tone:
return {"good": "🟢", "caution": "🟡", "warn": "🟠", "bad": "🔴"}.get(tone, "⚪")
b = (snap.get("band") or "").strip().lower()
if not b:
return "⚪"
if "negli" in b or "low" in b or b in ("aaa", "aa"):
return "🟢"
if "med" in b or b in ("a", "bbb"):
return "🟡"
if "high" in b or b in ("bb", "b"):
return "🟠"
if "sever" in b or b == "ccc":
return "🔴"
return "⚪"
# --------------------------------------------------------------------------- #
# BUILD PATHS (ported _ensure_snapshot / _add_live_company)
# --------------------------------------------------------------------------- #
def _attach_engine_verdict(company, ticker, demo):
"""Ride the ENGINE's own verdict onto the Contract B as a non-contract field.
THE BUG THIS FIXES. Stage 2 is told to "take a position that DISAGREES with the Layer A
rating" — and was never told which way the evidence actually disagrees. A risk-framed
narrowed question ("is it as solid as its rating implies?") therefore pulled the model
DOWNWARD: RHB Bank produced "the 61.0 score OVERSTATES ESG solidity" while the board, on the
same run, labelled it a Hidden Winner at +0.804 — our evidence at the 98th percentile against
a rating at the 18th. Two opposite claims about one company, on one screen, and the deep dive
is where a judge would look.
The sign is arithmetic, not an opinion: `disagreement` is our momentum percentile minus the
rating's. Handing it over as a stated fact lets the model argue about WHAT the evidence means
without inverting WHICH WAY it points; the prompt forbids the inversion explicitly. Attached,
never merged — nothing here is recomputed or blended into a score.
"""
try:
run, _meta, _cfg = _engine_run(demo, engine_config.DEFAULT_HORIZON)
rec = next((r for r in run.get("records") or [] if r.get("company_id") == ticker), None)
if not rec:
return
company["_engine"] = {
"label": rec.get("label_display"),
"disagreement": rec.get("disagreement"),
"our_momentum_percentile": rec.get("momentum_percentile"),
"rating_percentile": rec.get("lseg_percentile"),
"signal_count": rec.get("signal_count"),
"confidence": rec.get("composite_confidence"),
"direction_note": (
"POSITIVE disagreement means OUR EVIDENCE RANKS THIS COMPANY ABOVE where the "
"rating ranks it — the rating is BEHIND. NEGATIVE means the rating is more "
"generous than our evidence supports."),
}
except Exception: # noqa: BLE001 - context is optional
return
def _ensure_snapshot(constituent, demo):
"""Return the ticker of a constituent's snapshot, building it first if needed. Numeric
constituents build locally (no network); evidence-only names go through the live builder."""
tk = constituent.get("ticker") or constituent.get("id")
if tk in _ENTRIES:
return tk, None
if metrics.has_numbers([constituent]):
company = datasource.company_from_numeric(constituent,
origin="sample" if demo else "dataset")
_attach_engine_verdict(company, tk, demo)
return _pin(company), None
try:
company, _meta = datasource.build_company_from_constituent(constituent, use_rag=True)
except core.LLMConfigError as e:
return None, str(e)
except Exception as e: # noqa: BLE001 — a live flop must not break the board.
return None, f"Couldn't build that snapshot ({type(e).__name__}: {e})."
return _pin(company), None
def _add_live_company(text):
"""Build a LIVE company by name (not in the universe) and pin it — ASEAN-only gate."""
try:
company, _meta = datasource.build_live_company(text, use_rag=True)
except core.LLMConfigError as e:
return None, str(e)
except Exception as e: # noqa: BLE001
return None, f"Couldn't build “{text}” live ({type(e).__name__})."
country = (company.get("_country") or "").strip()
asean = [c.lower() for c in universe.ASEAN_COUNTRIES]
if country and country.lower() != "unknown" and country.lower() not in asean:
return None, (f"{company.get('company', text)} looks {country}-listed — this radar is "
"ASEAN-only (Singapore, Malaysia, Indonesia, Thailand, Philippines).")
return _pin(company), None
# --------------------------------------------------------------------------- #
# HEALTH
# --------------------------------------------------------------------------- #
@app.get("/api/health")
def health():
return {
"ok": True,
"llm_configured": bool(os.environ.get("DEEPSEEK_API_KEY")),
"model": core.MODEL,
"entries": len(_ENTRIES),
"watchlist": len(_WATCHLIST),
}
# --------------------------------------------------------------------------- #
# BOARD (the whole dashboard state for a filter set — pure metrics, no LLM)
# --------------------------------------------------------------------------- #
def _uni_mode(cons):
if metrics.has_numbers(cons):
return "numeric"
if any(c.get("esg_basis") for c in cons):
return "evidence"
return "empty"
# --------------------------------------------------------------------------- #
# ENGINE (Build Spec v2 — score records, CGSI quadrants, tiers, N/M/K, anchors)
# The engine is a pure function of the data files, so one run per (universe,
# config) is memoised in-process; run_engine's own disk cache backs that up.
# --------------------------------------------------------------------------- #
_ENGINE_MEMO = {}
def precompute_engine():
"""Warm every (universe, horizon) pair so the first toggle flip is a memo hit.
A5 says the matrix must re-segment in under a second and to precompute if it does not. The
tiers are already instant because they are flags on a finished record; the horizon is a
re-score, so this is where that promise is actually kept. Best-effort by design — a failure
here must never stop the server booting, it just means the first flip pays for itself."""
warmed = []
for demo in (True, False):
for name in sorted(engine_config.horizons()) or [engine_config.DEFAULT_HORIZON]:
try:
run, _meta, _cfg = _engine_run(demo, name)
warmed.append(f"{'demo' if demo else 'real'}/{name}:{run['run_id']}")
except Exception as exc: # noqa: BLE001
warmed.append(f"{'demo' if demo else 'real'}/{name}:FAILED {type(exc).__name__}")
return warmed
def _engine_run(demo, horizon=engine_config.DEFAULT_HORIZON):
"""The scored run for a universe at a named decay horizon, metadata joined, tiers stamped.
The horizon is NOT a filter. The risk tiers are — they read flags already stamped on a
finished record, so the front-end can re-segment without asking us anything. Changing the
decay half-life changes the weight of every signal, so momentum, confidence, percentiles,
quadrants and N/M/K are all different numbers: it is a re-score, and it gets its own
`run_id`. Hence the memo below and the startup precompute — the spec's "<1 second" is met
by having both answers ready, not by making the flip cheap."""
cfg = engine_config.for_horizon(horizon)
path = universe.active_file(demo)
# The metadata file follows the UNIVERSE, not the preference order: the fictional demo set
# is keyed on invented tickers and joins nothing against the verified 52.
key = (path, cfg["config_hash"], os.path.getmtime(path) if os.path.exists(path) else 0,
company_metadata.load_report(demo=demo).get("rows", 0), demo,
0 if demo else sum(len(v) for v in harvest.load_overlay().values()))
hit = _ENGINE_MEMO.get(key)
if hit:
return hit
meta = company_metadata.load(demo=demo)
cons = universe.constituents(path)
if not demo:
# Harvested evidence merges in for the REAL basket only — searching live news about a
# fictional company would be nonsense, and the demo set already carries rich momentum.
# This changes the run's INPUTS, so it changes the run_id, which is correct: a run over
# more evidence is a different run. The engine itself stays pure, and the harvest files
# are committed, so a fresh clone reproduces the same id.
cons = harvest.apply_overlay(cons)
run = engine.run_engine(cons, metadata=meta, config=cfg)
if len(_ENGINE_MEMO) > 8: # keys carry mtime+config; 2 universes x 2 horizons live here
_ENGINE_MEMO.clear()
_ENGINE_MEMO[key] = (run, meta, cfg)
return _ENGINE_MEMO[key]
def _record_summary(record):
"""The compact per-company record the dashboard paints (signals stay server-side)."""
return {
"company_id": record["company_id"],
"label": record["label"],
"label_display": record["label_display"],
"composite_momentum": record["composite_momentum"],
"composite_confidence": record["composite_confidence"],
"direction_consensus": record["direction_consensus"],
"evidence_weight": record["evidence_weight"],
"shrinkage": record["shrinkage"],
"disagreement": record["disagreement"],
"lseg_percentile": record["lseg_percentile"],
"momentum_percentile": record["momentum_percentile"],
"signal_count": record["signal_count"],
"baseline_origin": record["baseline_origin"],
"baseline_basis": record["baseline_basis"],
"components": {k: v["momentum"] for k, v in record["components"].items()},
"tiers": record.get("tiers", {}),
}
def _badges(ticker, meta, cfg, record=None, nmk=None, buckets=None):
"""The per-company badges. `delisted` is CGSI's own note carried onto the record, and it is
surfaced deliberately: two of their 52 (MAHB, INTUCH) went private in 2025 while sitting in
a basket meant to be current. A static list going stale IS the product's argument, so the
row stays and wears a badge rather than disappearing (Prototype_Build_Notes.md §3)."""
row = meta.get(ticker, {})
out = {"green_bond": company_metadata.green_bond_badge(row, cfg),
"profitability": company_metadata.profitability_badge(row)}
# Which origination bucket this company is in. The counts strip has always shown the totals;
# this puts the label on the company, which is where a reader asking "is THIS one a lead?"
# actually looks. Display only — a join over the same run, no new scoring.
if buckets is not None:
out["pipeline"] = pipeline_counts.bucket_badge(buckets.get(ticker, ""), nmk)
if record and record.get("delisted"):
out["delisted"] = {
"label": "delisted",
"value": "delisted — basket membership stale",
"note": (record.get("company", ticker) + " is no longer listed. Kept in the basket "
"and excluded from investable output."),
"tone": "warn",
}
# The industry's transition bar, joined on CGSI's own industry label. Context for the
# evidence trail, never differenced against the company (see benchmarks.sector_benchmark).
bar = benchmarks.sector_benchmark((record or {}).get("industry", ""))
if bar.get("available"):
out["sector_benchmark"] = {
"label": "industry bar",
"value": "%s g CO2e/EUR GVA" % bar["intensity"],
"industry": bar["industry"],
"nace": bar["nace_code"],
"rank": bar["rank_cleanest"], "of": bar["of"],
"above_median": bar["above_median"],
"attribution": bar["attribution"],
"caveat": bar["caveat"],
"note": ("Sector bar only — we hold no per-company emissions intensity for this "
"basket, so this is never subtracted from the company."),
}
return out
def _anchor_summary(run):
"""Anchor status for a run — never computes a chain call on the board path."""
record = anchor.load_record(run["run_id"])
if not record:
return {"status": "not_built", "run_id": run["run_id"], "root": "", "leaf_count": 0,
"tx_hash": "", "explorer_url": "", "chain": anchor.CHAIN_NAME,
"note": "Run not anchored yet — `python anchor.py --run` builds and anchors it."}
cfg = anchor.chain_config()
explorer = (f"{cfg['explorer']}/tx/{record['tx_hash']}" if record.get("tx_hash")
else f"{cfg['explorer']}/address/{record['contract']}" if record.get("contract")
else "")
return {"status": record.get("status", "anchor_pending"), "run_id": record["run_id"],
"root": record["root"], "leaf_count": record["leaf_count"],
"tx_hash": record.get("tx_hash", ""), "block_number": record.get("block_number"),
"signer": record.get("signer", ""), "contract": record.get("contract", ""),
"chain": record.get("chain", anchor.CHAIN_NAME), "explorer_url": explorer,
"note": record.get("anchor_note", "")}
def _harvest_summary(demo):
"""How much live-gathered evidence is in this run, and how much of the basket it touched."""
if demo:
return {"companies": 0, "events": 0, "note": ""}
overlay = harvest.load_overlay()
events = sum(len(v) for v in overlay.values())
return {
"companies": len(overlay),
"events": events,
"note": ("%d dated, sourced facts gathered live for %d companies and merged into this "
"run. The model only read them — every direction, weight and label below is "
"still assigned by rule." % (events, len(overlay))) if events else "",
}
def _engine_block(demo, filtered, horizon=engine_config.DEFAULT_HORIZON):
"""Everything the Build-Spec front-end needs for one filtered view."""
run, meta, cfg = _engine_run(demo, horizon)
tickers = [c["ticker"] for c in filtered]
by_id = {r["company_id"]: r for r in run["records"]}
subset = {"records": [by_id[t] for t in tickers if t in by_id],
"company_count": len(tickers)}
labels = {k: engine_config.display(cfg, k) for k in engine_config.label_order(cfg)}
# Computed once and used twice: the counts strip beside the matrix, and the per-company
# bucket badge. Two calls could drift apart on the same screen.
nmk = pipeline_counts.counts(subset, meta, cfg)
buckets = pipeline_counts.bucket_of(nmk)
return {
"run_id": run["run_id"],
"as_of": run["as_of"],
"engine_version": run["engine_version"],
"config_version": run["config_version"],
"config_hash": run["config_hash"],
"theta": cfg["theta"],
"labels": labels,
"label_tooltips": {k: (cfg["labels"].get(k) or {}).get("tooltip", "")
for k in engine_config.label_order(cfg)},
"label_rules": {k: (cfg["labels"].get(k) or {}).get("rule", "")
for k in engine_config.label_order(cfg)},
"label_counts": engine.label_counts(subset),
"tiers": {k: cfg["risk_tiers"][k]["display"]
for k in ("conservative", "balanced", "aggressive")},
"tier_rules": {k: cfg["risk_tiers"][k].get("rule", "") for k in
("conservative", "balanced", "aggressive")},
"default_tier": cfg["risk_tiers"]["default"],
# The horizon names and their half-lives come from config, never from a literal here —
# the UI renders whatever `decay.horizons` holds, so adding a third is a file edit.
"horizon": engine_config.horizon_of(cfg) or engine_config.DEFAULT_HORIZON,
"horizons": engine_config.horizons(cfg),
"default_horizon": engine_config.DEFAULT_HORIZON,
"half_life_days": cfg["decay"]["half_life_days"],
"nmk": nmk,
"metadata": company_metadata.load_report(demo=demo),
# Say how much of this run's evidence was harvested rather than supplied. A board that
# silently mixes the two invites exactly the question we would have no answer to.
"harvest": _harvest_summary(demo),
"anchor": _anchor_summary(run),
"records": {t: _record_summary(by_id[t]) for t in tickers if t in by_id},
"badges": {t: _badges(t, meta, cfg, by_id.get(t), nmk, buckets) for t in tickers},
}
def _focused_engine_record(demo, horizon, ticker):
"""The FULL engine record (signals included) for one ticker, or None. `_record_summary`
deliberately strips signals for the board payload, so the rail asks the run directly."""
if not ticker:
return None
try:
run, _meta, _cfg = _engine_run(demo, horizon)
except Exception: # noqa: BLE001 - never break the board
return None
for rec in run.get("records") or []:
if rec.get("company_id") == ticker:
return rec
return None
def _live_price_90d(focused):
"""The real 90-day price move for a REAL listing, via `quotes.change_90d`. Returns None on
any failure, an unmapped exchange (PSE and HOSE are documented gaps) or a fictional name —
the strip then says it is unavailable rather than drawing a flat line, which a reader would
take to mean the price did not move. A quote is context and never an input to a score."""
ticker = (focused or {}).get("ticker")
if not ticker:
return None
try:
return quotes.change_90d(ticker)
except Exception: # noqa: BLE001 - best-effort by design
return None
def _decay_note(record, half_life_days):
"""Why a company with evidence can still read zero momentum.
The short horizon halves the weight of evidence every 45 days, so a name whose newest signal
is a year old scores 0.000 and lands in `consensus` — correct, and completely opaque on
screen, because the card just empties. Saying it turns a blank into the finding it actually
is: the evidence exists, it is simply too old to answer THIS question."""
if not record:
return ""
count = record.get("signal_count") or 0
if not count or metrics.num(record.get("composite_momentum")):
return ""
dates = [str(s.get("published_at") or "")[:10] for s in (record.get("signals") or [])]
dates = sorted([d for d in dates if d], reverse=True)
newest = dates[0] if dates else ""
return (f"{count} scored signal{'s' if count != 1 else ''} on file"
+ (f", newest {newest}" if newest else "")
+ f" — all decayed to zero weight at a {half_life_days}-day half-life. "
"The evidence exists; it is too old to move this horizon. Switch to the longer "
"horizon to see what it says.")
#: Seconds the cutoff-replay chart may spend before giving up (see below).
SERIES_BUDGET_SECONDS = 6.0
_SERIES_CACHE = {}
def _real_momentum_series(demo, horizon, run, points=8):
"""A REAL per-pillar momentum series: the engine re-run at successive historical cutoffs.
The existing `metrics.momentum_series` interpolates — it eases from an invented baseline to
each pillar's current value so the lines fan out like the mockup, and it says so. That is
honest for the FICTIONAL demo set, which holds no dated evidence to build a series from.
The real basket does hold dated evidence, and `run_engine` already takes a `cutoff`, so every
point here is a genuine engine run at its own date — what the Radar WOULD have said then, on
only the evidence that existed then. Same construction as the five backtest cases, and the
reason it can be drawn at all is Gate-1 purity: no clock, no RNG, so a replay of 2024-06-30
is the same computation today as it was then.
Returns `{pillar: [floats]}` plus the cutoff labels, or `({}, [])` when there is nothing
dated to plot — never an interpolated stand-in for a real universe.
"""
as_of = str(run.get("as_of") or "")[:10]
if demo or not as_of:
return {}, []
key = (demo, horizon, run.get("run_id"), points)
if key in _SERIES_CACHE:
return _SERIES_CACHE[key]
year, month = int(as_of[:4]), int(as_of[5:7])
cutoffs = []
for step in range(points - 1, -1, -1):
m = month - step * 3
y = year
while m <= 0:
m += 12
y -= 1
# Quarter ends, so a cutoff never lands mid-month and shift the window unevenly.
cutoffs.append(f"{y:04d}-{m:02d}-28")
cfg = engine_config.for_horizon(horizon)
metadata = company_metadata.load(demo=demo)
cons = universe.constituents(universe.active_file(demo))
if not demo:
cons = harvest.apply_overlay(cons)
series = {p: [] for p in metrics.PILLARS}
# A HARD BUDGET, because this is eight full engine runs. They are cheap once the disk cache
# is warm, but every cutoff is its own `run_id` and a harvest running in the background
# changes the inputs continuously — so during a sweep every request is a cold miss and the
# board would block on a CHART. A chart is never worth a hanging page: past the budget this
# gives up and the panel shows its honest empty state until the inputs settle.
deadline = time.monotonic() + SERIES_BUDGET_SECONDS
for cutoff in cutoffs:
if time.monotonic() > deadline:
return {}, []
try:
past = engine.run_engine(cons, metadata=metadata, config=cfg, cutoff=cutoff)
except Exception: # noqa: BLE001 - a chart must not 500
return {}, []
rows = metrics.pillar_momentum_from_records(past["records"])
for row in rows:
# `None` where NO company had evidence for that pillar at that cutoff. Substituting
# 0.0 here would be the bug this whole codebase keeps catching: "no evidence" and
# "evidence says flat" both land on zero, and only one of them is a measurement.
series[row["key"]].append(row["value"] if row["n"] else None)
# A line can only be drawn where every point is a real reading. Social and digital evidence
# exists for 3 of 52 companies, so those pillars have genuine gaps — and a chart that bridges
# a gap with a zero is asserting a measurement nobody made. Rather than draw a broken line or
# a false one, a pillar that is missing ANY point is dropped and the count is reported
# alongside, which is the same treatment the matrix gives its hollow dots.
dropped = {k: sum(1 for x in v if x is None) for k, v in series.items()
if any(x is None for x in v)}
series = {k: v for k, v in series.items()
if v and all(x is not None for x in v) and any(abs(x) > 1e-9 for x in v)}
out = (series, cutoffs if series else [])
if dropped:
out[0].setdefault("_dropped", None)
del out[0]["_dropped"] # keep the shape clean; the note travels in the log
print(f"[series] pillars with evidence gaps, not plotted: {dropped}")
_SERIES_CACHE[key] = out
return out
def _why_wrong(focused):
if not focused:
return "Add or focus a company to see where its live signal diverges from the stale rating."
s = focused.get("live_signals") or {}
d = metrics.num((focused.get("momentum") or {}).get("digital_ai"))
name = focused.get("company", "This company")
score, as_of = focused.get("esg_score"), (focused.get("esg_as_of") or "")
if d is None and focused.get("esg_basis"):
p = metrics.evidence_profile(focused)
tags = ", ".join(f'{cr["label"]} {cr["value"]}' for cr in p["credentials"][:2]) or \
"documented ESG progress"
return (f"{name} is a documented ESG improver ({tags}; leadership {p['score']}/100). A "
"stale rating may already price that leadership — the radar's edge is the LIVE "
"alt-data (AI hiring, patents, news/behaviour) that isn't wired yet. That's where "
"a 2023 score gets caught out.")
cls = metrics.classify(focused)
if cls["label"] == "HIDDEN WINNER":
return (f"{name} is hiring hard for AI governance ({s.get('ai_hiring_surge') or 'fast'}) "
f"while the static {as_of} score ({score if score is not None else '—'}) sits "
f"still — the rating can't see the live Digital/AI {metrics.fmt_pct(d)} trajectory.")
if cls["label"].startswith("WATCH"):
return (f"{name} carries {int(metrics.num(s.get('controversy_flags')) or 0)} controversy "
f"flag(s) with softening signals (Digital/AI {metrics.fmt_pct(d)}); the stale "
"score lags the behaviour.")
if d is None:
return (f"No live momentum for {name} yet — add numeric data (or run a deep dive) so the "
"radar can compete with its rating.")
return (f"{name}'s live signal (Digital/AI {metrics.fmt_pct(d)}) is roughly in line with its "
"rating — watch for divergence.")
#: A company has to carry at least this much scored evidence to be the one the board OPENS on.
#: Not a display filter — every company is still reachable, ranked and plotted. It only stops the
#: unconfigured first screen landing on a name we have almost nothing to say about.
_DEFAULT_FOCUS_MIN_SIGNALS = 5
def _default_focus_from_records(filtered, records):
"""The company the board opens on when the user has not chosen one.
THE OLD CHAIN OPENED ON THE WRONG COMPANY, and for the same reason three widgets did before
it: `metrics.hidden_winners` ranks on a Digital/AI field only the FICTIONAL demo set carries,
so on the real basket it returns nothing and the pick fell through to `evidence_leaders` —
which counts the rating agencies named in a company's `esg_basis` PROSE, not evidence this
engine ever scored. That opened the board on Bank Negara Indonesia: eight agency mentions in
its blurb, no social evidence, no digital/AI evidence, no headlines, and a Consensus label.
The first thing anyone saw was the one company we had nothing to say about.
So the pick reads the engine, like everything else now does. Ranked by
`|disagreement| x confidence`: a big disagreement we are confident in is precisely what this
product exists to show, and multiplying is what stops a wild claim on one thin signal
outranking a solid one. Deterministic, ties broken by ticker, so the board opens on the same
company every time.
"""
by_id = {r.get("company_id"): r for r in (records or [])}
best, best_key = None, None
for c in filtered:
r = by_id.get(c.get("ticker"))
if not r or r.get("delisted"):
continue
signals = r.get("signal_count") or 0
if signals < _DEFAULT_FOCUS_MIN_SIGNALS:
continue
conf = r.get("composite_confidence") or 0.0
strength = abs(r.get("disagreement") or 0.0) * conf
key = (strength, signals, c.get("ticker") or "")
if best_key is None or key > best_key:
best, best_key = c, key
return best
def _cc_focused(focus_ticker, filtered, path, records=None):
if focus_ticker:
c = universe.get(focus_ticker, path)
if c:
return c
e = _ENTRIES.get(focus_ticker)
if e:
return e["company"]
picked = _default_focus_from_records(filtered, records)
if picked:
return picked
hw, _, _ = metrics.hidden_winners(filtered, top_n=1)
if hw:
return universe.get(hw[0]["ticker"], path) or (filtered[0] if filtered else None)
lead = metrics.evidence_leaders(filtered, top_n=1)
if lead:
return universe.get(lead[0]["ticker"], path) or (filtered[0] if filtered else None)
return filtered[0] if filtered else None
def _board_average(filtered, mode, sector):
"""Build the filtered-set average card without coupling it to the endpoint response."""
if mode == "evidence":
avg, count = metrics.evidence_average(filtered)
return {"kind": "evidence", "value": avg, "n": count,
"title": f"Avg ESG-leadership · {_short_sector(sector)}",
"sub": f"evidence index 0–100 · {count} names" if avg is not None
else "awaiting data"}
avg, count = metrics.average_esg(filtered)
return {"kind": "numeric", "value": avg, "n": count,
"title": f"Avg ESG · {_short_sector(sector)}",
"sub": f"mean static score · {count} names" if avg is not None else "awaiting data"}
def _board_watchlist(path):
"""Serialize monitored entries and preserve unbuilt universe tickers."""
rows = []
for ticker in _WATCHLIST:
entry = _ENTRIES.get(ticker)
constituent = universe.get(ticker, path)
if entry:
rows.append({"ticker": ticker, "name": entry["snap"]["company"], "built": True,
"band": entry["snap"].get("band") or "",
"band_emoji": _band_emoji(entry["snap"]),
"has_answer": entry.get("answer") is not None,
"in_universe": constituent is not None})
else:
rows.append({"ticker": ticker, "name": (constituent or {}).get("company") or ticker,
"built": False, "band": "", "band_emoji": "", "has_answer": False,
"in_universe": constituent is not None})
return rows
@app.get("/api/board")
def board(demo: bool = Query(True), country: str = "All", sector: str = "All",
focus: str = "", simplified: bool = Query(True),
horizon: str = Query(engine_config.DEFAULT_HORIZON)):
path = universe.active_file(demo)
uni = universe.load_universe(path)
cons = uni["constituents"]
sectors = ["All"] + universe.sectors(path)
countries = ["All"] + universe.countries(path)
if sector not in sectors:
sector = "All"
if country not in countries:
country = "All"
filtered = universe.filter_constituents(country=country, sector=sector, path=path)
mode = _uni_mode(cons)
# The engine run is memoised, so consulting it here costs nothing — the board
# builds it a few lines below anyway.
focused = _cc_focused(focus or None, filtered, path,
_engine_run(demo, horizon)[0].get("records"))
nt = {focus} if focus else set()
avg_payload = _board_average(filtered, mode, sector)
hw_rows, peer_avg, peer_n = metrics.hidden_winners(filtered, top_n=5, new_tickers=nt)
# Same false negative again: `hidden_winners` ranks on a Digital/AI field only the demo set
# carries, so the real basket showed "nothing to rank" beside a matrix already holding four
# names in that quadrant. Fall back to the engine's own signed disagreement.
hw_basis = "digital_ai"
if not hw_rows:
hw_rows, peer_avg, peer_n = metrics.hidden_winners_from_records(
filtered, _engine_block(demo, filtered, horizon)["records"],
top_n=5, new_tickers=nt)
hw_basis = "disagreement" if hw_rows else hw_basis
leaders = metrics.evidence_leaders(filtered, top_n=5, new_tickers=nt)
# --- focused company panels ------------------------------------------------ #
focused_payload = None
if focused:
ft = focused.get("ticker")
entry = _ENTRIES.get(ft)
answer = entry.get("answer") if entry else None
ep = metrics.evidence_profile(focused) if focused.get("esg_basis") else {}
if mode == "evidence":
cls = metrics.classify_evidence(focused)
signals = [{"label": cr["label"], "value": cr["value"], "tone": cr["tone"]}
for cr in ep.get("credentials", [])]
signals_kind = "credentials"
else:
cls = metrics.classify(focused)
signals = metrics.live_signals(focused)
signals_kind = "signals"
# The engine holds dated, sourced, SCORED signals for the real basket; `live_signals`
# only ever finds the demo set's own block. Falling back means a real company with three
# scored signals stops reporting "0 signals" beside the momentum those signals produced.
erec = _focused_engine_record(demo, horizon, focused.get("ticker"))
if not signals and erec:
signals = metrics.signals_from_record(erec)
if signals:
signals_kind = "scored signals"
# The SAME false negative, one widget over. `metrics.classify` reads the demo-only
# `momentum` block, so once the real basket landed every real company read "AWAITING
# DATA" in the classification strip and in the RadarHub chip — printed directly above a
# rationale panel naming the engine's verdict for that same company. Two adjacent widgets
# disagreeing about whether we have data is worse than either answer on its own. The
# engine record is the authority whenever there is one; when there is not, the honest
# "awaiting data" stands.
if isinstance(cls, dict) and cls.get("label") == "AWAITING DATA" and erec:
cls = metrics.classify_from_record(erec) or cls
# The four pillar readings for THIS company. `board["pillars"]` is the average across
# everything in view, which is the right number when nothing is focused and the wrong one
# the moment a company's name is printed in the middle of it: the hub drew the 52-company
# average and labelled it with the focused company, so the readings never moved when you
# changed company. Same precedence as the board cards — the constituent's own numeric
# block first (the fictional demo set), the engine record otherwise. None when neither
# can answer, and the hub then falls back to the set average and says so.
own_pillars = metrics.pillar_momentum([focused])
if not any(row["value"] is not None for row in own_pillars):
own_pillars = metrics.pillar_momentum_for_record(erec) if erec else []
focus_pillars = own_pillars if any(
row["value"] is not None for row in (own_pillars or [])) else None
# A real last price for a real listing (best-effort, never a signal — see quotes.py).
# The demo universe is FICTIONAL and deliberately gets no quote.
live_px = None if demo else _live_price_90d(focused)
# The last traded price, separately from the 90-day series. The Philippine board gives a
# real PSE price but no history, so without this a reader would see "no price" for a
# company we can in fact quote.
last_px = None if demo else quotes.quote(
focused.get("ticker") or "", company=focused.get("company", ""))
pct = metrics.price_change_pct(focused) if demo else (live_px or {}).get("pct")
focused_payload = {
"constituent": focused,
"from_snapshot": bool(entry),
"classification": cls,
"pillars": focus_pillars,
"credentials": ep.get("credentials", []),
"leadership": {"score": ep.get("score"), "band": ep.get("band"),
"has": bool(ep)} if ep else None,
"signals": signals,
"signals_kind": signals_kind,
# Why a company WITH evidence can still read zero momentum on this horizon. Empty
# string when it does not apply, so the UI shows nothing rather than a caveat that
# does not fit the company in front of the reader.
"decay_note": _decay_note(erec, _engine_run(demo, horizon)[2]["decay"]["half_life_days"]),
# WHY this verdict — pros and cons on BOTH axes, derived by rule from the same run.
# A label without its reasoning is a conclusion the reader cannot argue with, and the
# cons are not optional: a case that only lists reasons to agree is marketing.
"case": (rationale.build(erec, company_metadata.load(demo=demo).get(focused.get("ticker")))
if erec else None),
"why_wrong": _why_wrong(focused),
"plain_summary": metrics.plain_summary(focused, answer),
# Real gathered headlines for a real company; the demo set keeps its own seeded,
# clearly-labelled ones. Display only — nothing here reaches the engine.
"news": metrics.news_card(
focused, None if demo else news.load(focused.get("ticker") or "")),
"analyst": metrics.analyst_coverage(focused),
"check_action": metrics.focused_answer_action(
{ft: entry} if entry else {}, ft),
# Demo draws a synthetic curve from its own illustrative percent; a real listing
# draws its ACTUAL closes, rebased to 100. `source` says which, so the strip can
# stop calling a live Yahoo series "illustrative".
"price": {"pct": pct,
"series": ((live_px or {}).get("series")
if live_px else
(metrics.price_series(pct) if pct is not None else [])),
"source": (live_px or {}).get("source") if live_px else None,
"points": (live_px or {}).get("points") if live_px else None,
# The date the series was CAPTURED. The basket runs off a dated snapshot so
# the demo needs no network; a stored price printed without its date would
# read as a live tick, which is a rule-3 breach for the sake of looking
# fresher than it is.
"captured": (live_px or {}).get("captured") if live_px else None,
"last": ({"price": last_px.get("price"),
"currency": last_px.get("currency"),
"change_pct": last_px.get("change_pct"),
"exchange": last_px.get("exchange"),
"source": last_px.get("source"),
"captured": last_px.get("captured")}
if last_px and last_px.get("available") else None)},
"foundation": ({"basis": focused.get("esg_basis"),
"source_url": focused.get("source_url"),
"confidence": focused.get("confidence")}
if focused.get("esg_basis") else None),
# Peer average and industry footprint for the focused name. Local and cheap — the
# OECD half is a parsed CSV, the ASEAN half is an average over the filtered set.
"benchmark": benchmarks.compare_company(focused, path=path),
}
# --- watchlist ---------------------------------------------------------------- #
wl = _board_watchlist(path)
followups = metrics.suggested_followups(
focused_name=(focused or {}).get("company"),
focused_sector=_short_sector((focused or {}).get("sector")),
has_focus=bool(focus) or bool(focused),
has_answer=bool(focused and (_ENTRIES.get(focused.get("ticker")) or {}).get("answer")),
simplified=simplified,
sample_sector=_short_sector(universe.sectors(path)[0]) if universe.sectors(path) else None,
sample_country=(universe.countries(path) or [None])[0])
# Built once and used TWICE below — the pillar cards fall back to the engine's own
# per-component momentum when the constituents carry no numeric block, so the board must not
# score the same filtered view twice (it is cached, but the two copies could still drift).
engine_block = _engine_block(demo, filtered, horizon)
# The pillar cards: a constituent's own `momentum` block when it has one (the fictional demo
# set), otherwise the ENGINE's per-component direction consensus. Before this the cards read
# only the first source, so the real basket showed four "awaiting data" tiles next to 44
# companies' worth of environment evidence the engine had already scored. Two different
# scales, so each row carries `basis` and the UI labels the unit rather than guessing.
pillars = metrics.pillar_momentum(filtered)
if not any(row["value"] is not None for row in pillars):
pillars = metrics.pillar_momentum_from_records(engine_block["records"])
# The momentum chart. For the real basket this is not a drawing — it is the engine re-run at
# each quarter end, which is only possible because the engine is pure.
real_series, series_labels = _real_momentum_series(demo, horizon, _engine_run(demo, horizon)[0])
return {
"universe": {"note": uni.get("note"), "selection": uni.get("selection"),
"benchmark": uni.get("benchmark"),
"benchmark_stats": uni.get("benchmark_stats"),
"as_of": uni.get("as_of"),
"banner": metrics.universe_banner(uni),
"quarter": metrics._as_quarter(uni.get("as_of", ""))},
"mode": mode,
"demo": demo,
"sectors": sectors,
"countries": countries,
"filter": {"country": country, "sector": sector},
"counts": {"total": len(cons), "showing": len(filtered)},
"industries": len({c["sector"] for c in cons if c["sector"] != "unknown"}),
"avg": avg_payload,
"pillars": pillars,
# Real basket: genuine engine replays at quarterly cutoffs. Demo: the illustrative
# interpolation, which is all a fictional set can honestly support.
"momentum_series": real_series or metrics.momentum_series(filtered),
"momentum_series_basis": (
"engine replayed at each quarter cutoff — every point is a real run"
if real_series else "illustrative interpolation"),
# The SCALE, machine-readable, using the same vocabulary as the pillar cards. The real
# basket's points are a direction consensus on -1..+1; the demo set's are momentum
# percentages. The chart hardcoded a "%" suffix on both, so a consensus of 0.998 — the
# evidence overwhelmingly agreeing — was drawn as "1%" and read as a rounding error.