diff --git a/generate.py b/generate.py
index 447faa7..ac1871a 100644
--- a/generate.py
+++ b/generate.py
@@ -292,6 +292,117 @@ def fetch_finnhub_calendar():
return []
+def fetch_finviz_calendar():
+ """Scrape economic calendar from Finviz for richer data with beat/miss info."""
+ try:
+ from html.parser import HTMLParser
+
+ url = "https://finviz.com/calendar.ashx"
+ headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
+ r = requests.get(url, timeout=15, headers=headers)
+ if r.status_code != 200:
+ print(f" Finviz calendar HTTP {r.status_code}")
+ return []
+
+ class CalParser(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.in_table = False
+ self.in_row = False
+ self.in_cell = False
+ self.cells = []
+ self.current_cell = ""
+ self.rows = []
+ self.table_depth = 0
+ self.target_table = False
+
+ def handle_starttag(self, tag, attrs):
+ attrs_d = dict(attrs)
+ if tag == "table" and attrs_d.get("class", "") == "calendar_table":
+ self.target_table = True
+ self.table_depth = 0
+ if self.target_table and tag == "table":
+ self.table_depth += 1
+ if self.target_table and tag == "tr":
+ self.in_row = True
+ self.cells = []
+ if self.target_table and self.in_row and tag == "td":
+ self.in_cell = True
+ self.current_cell = ""
+
+ def handle_endtag(self, tag):
+ if self.target_table and tag == "td" and self.in_cell:
+ self.in_cell = False
+ self.cells.append(self.current_cell.strip())
+ if self.target_table and tag == "tr" and self.in_row:
+ self.in_row = False
+ if len(self.cells) >= 6:
+ self.rows.append(self.cells[:])
+ if self.target_table and tag == "table":
+ self.table_depth -= 1
+ if self.table_depth <= 0:
+ self.target_table = False
+
+ def handle_data(self, data):
+ if self.in_cell:
+ self.current_cell += data
+
+ parser = CalParser()
+ parser.feed(r.text)
+
+ events = []
+ current_date = ""
+ today = datetime.now().strftime("%Y-%m-%d")
+ for row in parser.rows:
+ # Finviz columns: Date, Time, Release, For, Actual, Expected, Prior
+ date_str = row[0].strip() if row[0].strip() else current_date
+ if date_str:
+ current_date = date_str
+ time_str = row[1].strip() if len(row) > 1 else ""
+ release = row[2].strip() if len(row) > 2 else ""
+ period = row[3].strip() if len(row) > 3 else ""
+ actual = row[4].strip() if len(row) > 4 else ""
+ expected = row[5].strip() if len(row) > 5 else ""
+ prior = row[6].strip() if len(row) > 6 else ""
+
+ if not release:
+ continue
+
+ # Determine beat/miss
+ beat = None
+ if actual and expected and actual != "" and expected != "":
+ try:
+ a_val = float(actual.replace("%", "").replace(",", ""))
+ e_val = float(expected.replace("%", "").replace(",", ""))
+ if a_val > e_val:
+ beat = "beat"
+ elif a_val < e_val:
+ beat = "miss"
+ else:
+ beat = "inline"
+ except ValueError:
+ pass
+
+ events.append({
+ "date": current_date,
+ "time": time_str,
+ "ctry": "US",
+ "ev": f"{release}" + (f" ({period})" if period else ""),
+ "prev": prior if prior else "\u2014",
+ "fcast": expected if expected else "\u2014",
+ "act": actual if actual else "\u2014",
+ "imp": "red",
+ "beat": beat,
+ "source": "finviz",
+ })
+
+ print(f" Finviz: {len(events)} calendar events scraped")
+ return events
+ except Exception as e:
+ print(f" Finviz calendar error: {e}")
+ return []
+
+
def fetch_rss_news():
"""Fetch news from free RSS feeds (no API key needed)."""
import xml.etree.ElementTree as ET
@@ -493,10 +604,18 @@ def main():
output["news"] = unique_news[:40]
print(f" {len(unique_news)} unique articles")
- # 10. Economic Calendar
+ # 10. Economic Calendar (Finnhub + Finviz)
print("\n[10] Fetching calendar...")
- output["calendar"] = fetch_finnhub_calendar()
- print(f" {len(output['calendar'])} events")
+ finnhub_cal = fetch_finnhub_calendar()
+ finviz_cal = fetch_finviz_calendar()
+ # Merge: prefer Finviz for US events (has beat/miss), keep Finnhub for non-US
+ if finviz_cal:
+ # Use Finviz as primary, add non-US Finnhub events
+ non_us = [e for e in finnhub_cal if e.get("ctry", "US") != "US"]
+ output["calendar"] = finviz_cal + non_us
+ else:
+ output["calendar"] = finnhub_cal
+ print(f" {len(output['calendar'])} total events")
# Write output
output_path = Path(OUTPUT_FILE)
diff --git a/index.html b/index.html
index 2728096..e16c04f 100644
--- a/index.html
+++ b/index.html
@@ -56,6 +56,168 @@ function EtfTbl({etfs}){const rows=Object.entries(etfs||{}).map(([name,v])=>({na
function VolTbl({vol}){const rows=Object.entries(vol||{}).map(([name,v])=>({name,price:v?.price??null,prev:v?.prev_close??null,chg:v?.change_pct??null}));if(!rows.length)return
No volatility data available
;return C.b},{k:"price",l:"Level",rn:r=>r.price!=null?Number(r.price).toFixed(2):"—"},{k:"prev",l:"Prev",rn:r=>r.prev!=null?Number(r.prev).toFixed(2):"—"},{k:"chg",l:"Change",rn:r=>r.chg!=null?fc(r.chg,""):"—",cl:r=>cc(r.chg)}]} rows={rows}/>}
function BondEtfTbl({bondEtfs}){const rows=Object.entries(bondEtfs||{}).map(([name,v])=>({name,country:v?.country??"",duration:v?.duration??"",price:v?.price??null,d1:v?.d1_pct??null,w1:v?.w1_pct??null,m1:v?.m1_pct??null}));if(!rows.length)return No bond ETF data available
;return C.b},{k:"country",l:"Country",a:"left"},{k:"duration",l:"Duration",a:"left"},{k:"price",l:"Price",rn:r=>r.price!=null?Number(r.price).toFixed(2):"—"},{k:"d1",l:"1D %",rn:r=>fc(r.d1),cl:r=>cc(r.d1)},{k:"w1",l:"1W %",rn:r=>fc(r.w1),cl:r=>cc(r.w1)},{k:"m1",l:"1M %",rn:r=>fc(r.m1),cl:r=>cc(r.m1)}]} rows={rows} sm/>}
function Markets({g10,em,fxL,yld,yL,etfs,vol,bondEtfs}){const [t,sT]=useState("G10 FX");return {t==="G10 FX"&&}{t==="EM FX"&&}{t==="Rates"&&}{t==="Carry"&&}{t==="ETFs"&&}{t==="Volatility"&&}{t==="Bond ETFs"&&}
}
+
+/* ═══ SENTIMENT GAUGE ═════════════════════════════════════════════ */
+function calcSentiment(risk,g10,em,cmd){
+ // Sub-score: Volatility (VIX level + VIX change + MOVE)
+ let vol=50;
+ const vix=risk.VIX?.level,vChg=risk.VIX?.chg||0;
+ if(vix!=null){if(vix<15)vol+=20;else if(vix<20)vol+=10;else if(vix<25)vol-=5;else if(vix<30)vol-=15;else vol-=25}
+ if(vChg<-3)vol+=10;else if(vChg<-1)vol+=5;else if(vChg>3)vol-=10;else if(vChg>1)vol-=5;
+ const mv=risk.MOVE;if(mv?.level!=null){if(mv.level<90)vol+=8;else if(mv.level>130)vol-=10;else if(mv.level>115)vol-=5}
+ vol=Math.max(0,Math.min(100,Math.round(vol)));
+ // Sub-score: Currency Flow (DXY direction + G10 breadth)
+ let fx=50;const dChg=risk.DXY?.chg||0;
+ if(dChg<-0.5)fx+=15;else if(dChg<-0.2)fx+=8;else if(dChg>0.5)fx-=15;else if(dChg>0.2)fx-=8;
+ const g10M=[...g10].filter(x=>x.d1!=null&&x.pair!=="DXY").map(x=>x.d1);
+ if(g10M.length){const pos=g10M.filter(x=>x>0).length;fx+=Math.round((pos/g10M.length-0.5)*30)}
+ fx=Math.max(0,Math.min(100,Math.round(fx)));
+ // Sub-score: Safe Haven (Gold + JPY + CHF)
+ let hv=50;const gChg=cmd.gold?.chg||0;
+ if(gChg>2)hv-=18;else if(gChg>0.8)hv-=10;else if(gChg<-1)hv+=12;else if(gChg<-0.3)hv+=5;
+ const jpy=g10.find(x=>x.pair==="USDJPY");if(jpy?.d1!=null){if(jpy.d1<-0.5)hv-=10;else if(jpy.d1>0.5)hv+=10}
+ const chf=g10.find(x=>x.pair==="USDCHF");if(chf?.d1!=null){if(chf.d1<-0.3)hv-=6;else if(chf.d1>0.3)hv+=6}
+ hv=Math.max(0,Math.min(100,Math.round(hv)));
+ // Sub-score: EM Risk
+ let emR=50;const emM=[...em].filter(x=>x.d1!=null).map(x=>x.d1);
+ if(emM.length){const avg=emM.reduce((a,b)=>a+b,0)/emM.length;
+ if(avg<-0.8)emR+=18;else if(avg<-0.3)emR+=10;else if(avg>0.8)emR-=18;else if(avg>0.3)emR-=10;
+ const stressed=emM.filter(x=>x>1).length;if(stressed>=3)emR-=10}
+ emR=Math.max(0,Math.min(100,Math.round(emR)));
+ // Weighted composite
+ const score=Math.max(0,Math.min(100,Math.round(vol*0.30+fx*0.25+hv*0.20+emR*0.25)));
+ return {score,subs:{vol,fx,haven:hv,em:emR}};
+}
+function sentLabel(s){if(s>=75)return{t:"Extreme Greed",c:C.g};if(s>=60)return{t:"Greed",c:"#4ade80"};if(s>=45)return{t:"Neutral",c:C.am};if(s>=30)return{t:"Fear",c:"#f97316"};return{t:"Extreme Fear",c:C.r}}
+function sentNarrative(score,subs,risk,cmd,g10,em){
+ const p=[];const{vol,fx,haven,em:emS}=subs;const vix=risk.VIX?.level;
+ if(vol>=60)p.push("Vol regime is calm"+(vix?" with VIX at "+vix.toFixed(1):"")+" — supportive for carry and risk.");
+ else if(vol<=35)p.push("Elevated vol"+(vix?" (VIX "+vix.toFixed(1)+")":"")+" signals caution — favour defensives and short gamma.");
+ else p.push("Vol in neutral range"+(vix?" at VIX "+vix.toFixed(1):"")+".");
+ if(fx>=60)p.push("USD weakening is a tailwind for risk FX.");
+ else if(fx<=35)p.push("Strong USD is pressuring risk FX crosses.");
+ if(haven<=35){const jpyD=g10.find(x=>x.pair==="USDJPY");p.push("Safe haven bid in gold"+(jpyD?.d1<-0.3?" and JPY":"")+" confirms defensive positioning.")}
+ else if(haven>=60)p.push("Safe havens seeing outflows — risk appetite intact.");
+ if(emS<=35){const worst=[...em].filter(x=>x.d1!=null).sort((a,b)=>b.d1-a.d1).slice(0,2);
+ p.push("EM stress"+(worst.length?" led by "+worst.map(x=>x.pair.replace("USD","")+" "+fc(x.d1)).join(", "):"")+". Watch for contagion.")}
+ else if(emS>=60)p.push("EM FX resilient — constructive for EM carry.");
+ return p.join(" ");
+}
+function sentDrivers(risk,cmd,g10,em){
+ const d=[];const vix=risk.VIX,dxy=risk.DXY;
+ if(vix?.level!=null)d.push({label:"VIX",val:vix.level.toFixed(1),chg:vix.chg,weight:vix.level<20?"Bullish":vix.level<25?"Neutral":"Bearish",cl:vix.level<20?C.g:vix.level<25?C.am:C.r});
+ if(dxy?.chg!=null)d.push({label:"DXY Direction",val:fc(dxy.chg),chg:dxy.chg,weight:dxy.chg<0?"USD Weak (Risk On)":"USD Bid (Defensive)",cl:dxy.chg<0?C.g:C.r});
+ const gChg=cmd.gold?.chg;if(gChg!=null)d.push({label:"Gold",val:fc(gChg),chg:gChg,weight:gChg>1?"Safe Haven Bid":gChg<-1?"Risk Appetite":"Flat",cl:gChg>1?C.r:gChg<-1?C.g:C.am});
+ const jpyP=g10.find(x=>x.pair==="USDJPY");if(jpyP?.d1!=null)d.push({label:"USDJPY",val:fc(jpyP.d1),chg:jpyP.d1,weight:jpyP.d1<-0.3?"JPY Bid (Risk Off)":jpyP.d1>0.3?"JPY Weak (Risk On)":"Sideways",cl:jpyP.d1<-0.3?C.r:jpyP.d1>0.3?C.g:C.am});
+ const emMv=[...em].filter(x=>x.d1!=null).map(x=>x.d1);if(emMv.length){const a=+(emMv.reduce((s,v)=>s+v,0)/emMv.length).toFixed(2);d.push({label:"EM FX Avg",val:fc(a),chg:a,weight:a<-0.3?"EM under pressure":a>0.3?"EM strength":"Mixed",cl:a<-0.3?C.r:a>0.3?C.g:C.am})}
+ const mv=risk.MOVE;if(mv?.level!=null)d.push({label:"MOVE (Bond Vol)",val:mv.level.toFixed(1),chg:mv.chg,weight:mv.level<100?"Calm":mv.level<120?"Elevated":"Stressed",cl:mv.level<100?C.g:mv.level<120?C.am:C.r});
+ const wChg=cmd.wti?.chg;if(wChg!=null)d.push({label:"WTI Crude",val:fc(wChg),chg:wChg,weight:wChg>2?"Supply Concern":wChg<-2?"Demand Worry":"Stable",cl:wChg>2?C.am:wChg<-2?C.r:C.g});
+ return d;
+}
+function SubBar({label,icon,value}){
+ const{t:lb,c:lc}=sentLabel(value);
+ return
+
{icon}
+
{label}
+
+
{value}
+
{lb}
+
;
+}
+function Sentiment({risk,g10,em,cmd}){
+ const{score,subs}=calcSentiment(risk,g10,em,cmd);const{t:label,c:lc}=sentLabel(score);
+ const drivers=sentDrivers(risk,cmd,g10,em);
+ const narrative=sentNarrative(score,subs,risk,cmd,g10,em);
+ const ang=Math.PI-(score/100)*Math.PI;
+ return
+
🧭FX Sentiment Gauge
Composite score from volatility, currency flows, safe havens & EM risk
+
+
+
+
+
+
+
{label}
+
0 = Extreme Fear · 100 = Extreme Greed
+
+
+ {narrative&&
}
+
+
Composition Breakdown
+
+
+
+
+
+
+
+
Signal Drivers
+
{drivers.map((d,i)=>
+
{d.label}
+
+ {d.val}
+ {d.weight}
+
+
)}
+
;
+}
+
+/* ═══ SCENARIO ANALYSIS ══════════════════════════════════════════ */
+function buildScenarios(risk,yld,g10,em,cmd){
+ const sc=[];const vix=risk.VIX?.level||20,dxy=risk.DXY?.level,dChg=risk.DXY?.chg||0,us10=yld.US?.y10;
+ const eur=g10.find(x=>x.pair==="EURUSD"),gbp=g10.find(x=>x.pair==="GBPUSD"),jpy=g10.find(x=>x.pair==="USDJPY");
+ sc.push({pair:"DXY",spot:dxy,bull:{target:dxy?+(dxy*1.03).toFixed(1):null,label:"USD Strength",drivers:["Hot CPI → Fed repricing hawkish","Risk-off episode drives safe-haven USD bid","US growth outperformance vs rest of G10"],prob:vix>25||dChg>0.3?"40%":"25%"},base:{target:dxy?+dxy.toFixed(1):null,label:"Range-bound",drivers:["Gradual disinflation keeps Fed on hold","Growth differential narrows modestly","Cross-currents offset — no dominant theme"],prob:"45%"},bear:{target:dxy?+(dxy*0.97).toFixed(1):null,label:"USD Weakness",drivers:["Dovish Fed pivot / rate cut acceleration","Global growth recovery reduces USD haven demand","Twin deficit concerns resurface"],prob:vix<18&&dChg<-0.3?"40%":"30%"}});
+ if(eur?.spot)sc.push({pair:"EURUSD",spot:eur.spot,bull:{target:+(eur.spot*1.04).toFixed(4),label:"EUR Rally",drivers:["ECB holds while Fed cuts → rate differential narrows","Eurozone PMIs inflect higher","Ukraine ceasefire catalyst"],prob:"30%"},base:{target:+eur.spot.toFixed(4),label:"Consolidation",drivers:["ECB & Fed both on hold — carry neutral","EZ growth stagnant but not contracting","Range trading 1.05–1.10"],prob:"45%"},bear:{target:+(eur.spot*0.96).toFixed(4),label:"EUR Sell-off",drivers:["Eurozone recession deepens","US exceptionalism trade resumes","Energy shock / geopolitical premium"],prob:"25%"}});
+ if(jpy?.spot)sc.push({pair:"USDJPY",spot:jpy.spot,bull:{target:+(jpy.spot*1.04).toFixed(2),label:"JPY Weakness",drivers:["BoJ stays ultra-dovish, US yields stay elevated","Carry trade revival on low vol","Japan CPI rolls over"],prob:"30%"},base:{target:+jpy.spot.toFixed(2),label:"Sideways",drivers:["BoJ gradual normalization priced in","US-JP yield gap stable","MoF intervention threat caps upside"],prob:"40%"},bear:{target:+(jpy.spot*0.96).toFixed(2),label:"JPY Strength",drivers:["BoJ rate hike surprise / YCC exit","Global risk-off → JPY safe haven","US recession fears → Treasury rally"],prob:"30%"}});
+ if(gbp?.spot)sc.push({pair:"GBPUSD",spot:gbp.spot,bull:{target:+(gbp.spot*1.03).toFixed(4),label:"GBP Rally",drivers:["BoE holds while Fed cuts","UK services inflation stays sticky","Fiscal outlook stabilises"],prob:"30%"},base:{target:+gbp.spot.toFixed(4),label:"Range-bound",drivers:["BoE cautious easing — 25bp per quarter","UK growth muddles through","Brexit frictions fade from focus"],prob:"45%"},bear:{target:+(gbp.spot*0.97).toFixed(4),label:"GBP Sell-off",drivers:["UK recession + rapid BoE cuts","Gilt market stress returns","Broad USD strength"],prob:"25%"}});
+ return sc;
+}
+function ScenPanel({s}){
+ const cols=[{k:"bull",label:"BULL",emoji:"🟢",c:C.g},{k:"base",label:"BASE",emoji:"🟡",c:C.am},{k:"bear",label:"BEAR",emoji:"🔴",c:C.r}];
+ return
+
+ {s.pair}
+ Spot: {s.spot!=null?s.spot:"—"}
+
+
+ {cols.map(cl=>{
+ const sc=s[cl.k];if(!sc)return null;
+ return
+
+ {cl.emoji} {cl.label}
+ {sc.prob}
+
+
{sc.label}
+ {sc.target!=null&&
Target: {sc.target}
}
+
{sc.drivers.map((d,i)=>
→ {d}
)}
+
;
+ })}
+
+
;
+}
+function Scenarios({risk,yld,g10,em,cmd}){
+ const scenarios=buildScenarios(risk,yld,g10,em,cmd);
+ return
+
🎯Scenario Analysis
Bull / Base / Bear for key FX pairs — computed from live data
+ {scenarios.map((s,i)=>
)}
+ ;
+}
+
function CorrMx(){return 30-Day Correlation Matrix
—
Correlation data not available — requires a live feed
}
/* ═══ DEEP DIVE ═══════════════════════════════════════════════════ */
@@ -331,7 +493,7 @@ function Deep({ctry,g10,em,yld,news,nL,risk,macro,policyRates}){
/* ═══ BRIEF ═══════════════════════════════════════════════════════ */
function genBr(g10,em,risk,yld,cal,cmd){const L=[];const vix=risk.VIX,dxy=risk.DXY;const vD=(vix?.chg||0)<0,dD=(dxy?.chg||0)<0;if(vD&&dD)L.push("Risk on — VIX down "+Math.abs(vix?.chg||0).toFixed(1)+" at "+(vix?.level?.toFixed(1)||"—")+", USD softer at "+(dxy?.level?.toFixed(1)||"—")+" ("+fc(dxy?.chg)+"). Supportive for carry.");else if(!vD&&!dD)L.push("Defensive tone — VIX up at "+(vix?.level?.toFixed(1)||"—")+", USD bid at "+(dxy?.level?.toFixed(1)||"—")+" ("+fc(dxy?.chg)+"). JPY/CHF finding support.");else L.push("Mixed — VIX "+(vix?.level?.toFixed(1)||"—")+" ("+fc(vix?.chg,"")+"), DXY "+(dxy?.level?.toFixed(1)||"—")+" ("+fc(dxy?.chg)+"). Positioning ahead of events.");const mv=[...g10,...em].filter(x=>x.d1!=null).sort((a,b)=>Math.abs(b.d1)-Math.abs(a.d1)).slice(0,4);if(mv.length)L.push("Movers: "+mv.map(x=>x.pair+" "+(x.d1>0?"▲":"▼")+Math.abs(x.d1).toFixed(2)+"%").join(", ")+".");if(cmd.gold?.level)L.push("Gold $"+cmd.gold.level.toFixed(0)+(cmd.gold.chg!=null?" ("+fc(cmd.gold.chg)+")":"")+"."+(cmd.wti?.level?" WTI $"+cmd.wti.level.toFixed(2)+(cmd.wti.chg!=null?" ("+fc(cmd.wti.chg)+")":"")+".":" "));const u2=yld.US?.y2;if(u2!=null){const tc=RT.filter(r=>r.c!=="US"&&yld[r.c]?.y2!=null).map(r=>({ccy:r.ccy,c:+((yld[r.c].y2)-u2).toFixed(2)})).filter(x=>x.c>1).sort((a,b)=>b.c-a.c).slice(0,3);L.push("US 2Y "+u2.toFixed(2)+"%."+(tc.length?" Top carry: "+tc.map(x=>x.ccy+" +"+x.c+"%").join(", ")+".":" "))}const hi=cal.filter(e=>e.imp==="red").slice(0,4);if(hi.length)L.push("Watch: "+hi.map(e=>e.ctry+" "+e.ev+" ("+e.date+")").join(", ")+".");return L.join(" ")}
function Brief({g10,em,yld,risk,cal,cmd}){const [br,sBr]=useState(null);const [ts,sTs]=useState(null);const [ld,sLd]=useState(false);const gen=()=>{sLd(true);setTimeout(()=>{sBr(genBr(g10,em,risk,yld,cal,cmd));sTs(new Date().toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit"}));sLd(false)},600)};return 🧠AI Morning Brief
Live FX, yields, carry, commodities & calendar
{ts&&Generated {ts}}
{br?
:
Click "Generate Brief"
}
}
-function Cal({evts,live}){const [fl,sF]=useState("All");const gF=["🇺🇸","🇪🇺","🇬🇧","🇯🇵","🇨🇭","🇦🇺","🇳🇿","🇨🇦","🇸🇪","🇳🇴"];return {["All","G10","EM","CB"].map(f=>)}
r.imp==="red"?C.r:C.am},{k:"prev",l:"Prev"},{k:"fcast",l:"Fcst"},{k:"act",l:"Act",cl:r=>(r.act&&r.act!=="—")?C.g:C.txM}]} rows={evts.filter(e=>{if(fl==="G10")return gF.includes(e.ctry);if(fl==="EM")return!gF.includes(e.ctry);if(fl==="CB")return(e.ev||"").toLowerCase().match(/rate|fomc|boj|ecb|rba|rbnz|boc/);return true})}/> }
+function Cal({evts,live}){const [fl,sF]=useState("All");const gF=["🇺🇸","🇪🇺","🇬🇧","🇯🇵","🇨🇭","🇦🇺","🇳🇿","🇨🇦","🇸🇪","🇳🇴"];return {["All","G10","EM","CB"].map(f=>)}
r.imp==="red"?C.r:C.am},{k:"prev",l:"Prev"},{k:"fcast",l:"Fcst"},{k:"act",l:"Act",cl:r=>r.beat==="beat"?C.g:r.beat==="miss"?C.r:r.beat==="inline"?C.am:(r.act&&r.act!=="—")?C.g:C.txM},{k:"beat",l:"B/M",cl:r=>r.beat==="beat"?C.g:r.beat==="miss"?C.r:C.am,rn:r=>r.beat==="beat"?"▲ Beat":r.beat==="miss"?"▼ Miss":r.beat==="inline"?"● Inline":"—"}]} rows={evts.filter(e=>{if(fl==="G10")return gF.includes(e.ctry);if(fl==="EM")return!gF.includes(e.ctry);if(fl==="CB")return(e.ev||"").toLowerCase().match(/rate|fomc|boj|ecb|rba|rbnz|boc/);return true})}/> }
function Sett({open,close,keys,save}){const [lk,sL]=useState(keys);useEffect(()=>sL(keys),[keys,open]);if(!open)return null;return e.stopPropagation()}>
⚙ API Keys
{[{k:"finnhub",l:"Finnhub — VIX, commodities, news, calendar",u:"https://finnhub.io/register"}].map(a=>
)}
}
function Clock(){const [clk,setClk]=useState(new Date());useEffect(()=>{const t=setInterval(()=>setClk(new Date()),1000);return()=>clearInterval(t)},[]);const lt=clk.toLocaleString("en-GB",{timeZone:"Europe/London",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:false});const nt=clk.toLocaleString("en-US",{timeZone:"America/New_York",hour:"2-digit",minute:"2-digit",hour12:false});const tt=clk.toLocaleString("en-US",{timeZone:"Asia/Tokyo",hour:"2-digit",minute:"2-digit",hour12:false});return LON {lt} NY {nt} TKY {tt}}
@@ -364,7 +526,7 @@ function App(){
sRL(true);
sCmd({gold:{level:data.commodities?.gold?.price??null,chg:data.commodities?.gold?.d1_pct??null},wti:{level:data.commodities?.wti?.price??null,chg:data.commodities?.wti?.d1_pct??null},brent:{level:data.commodities?.brent?.price??null,chg:data.commodities?.brent?.d1_pct??null}});
const parsedNews=(data.news||[]).map(n=>{const dt=typeof n.datetime==="number"?n.datetime:Math.floor(Date.parse(n.datetime)/1000)||0;return{...n,datetime:dt}});sNews(parsedNews);sNL(true);
- const parsedCal=(data.calendar||[]).map(e=>({date:e.date||"",time:e.time||"",ctry:e.ctry||"",ev:e.ev||"",prev:e.prev??"—",fcast:e.fcast??"—",act:e.act??"—",imp:e.imp||"orange"}));sCal(parsedCal);sCL(true);
+ const parsedCal=(data.calendar||[]).map(e=>({date:e.date||"",time:e.time||"",ctry:e.ctry||"",ev:e.ev||"",prev:e.prev??"—",fcast:e.fcast??"—",act:e.act??"—",imp:e.imp||"orange",beat:e.beat||null}));sCal(parsedCal);sCL(true);
sLR(new Date());
}catch(err){console.error("Failed to load dashboard data",err)}finally{sLd(false)}
},[]);
@@ -372,13 +534,15 @@ function App(){
return
-
FXRADAR{[{id:"brief",l:"🧠 Brief"},{id:"markets",l:"📊 Markets"},{id:"commod",l:"🛢 Commodities"},{id:"corr",l:"🔗 Correlations"},{id:"deep",l:"🌍 Deep Dive"},{id:"cal",l:"📅 Calendar"}].map(s=>
)}
+
FXRADAR{[{id:"brief",l:"🧠 Brief"},{id:"sentiment",l:"🧭 Sentiment"},{id:"markets",l:"📊 Markets"},{id:"commod",l:"🛢 Commodities"},{id:"scenarios",l:"🎯 Scenarios"},{id:"corr",l:"🔗 Correlations"},{id:"deep",l:"🌍 Deep Dive"},{id:"cal",l:"📅 Calendar"}].map(s=>
)}
{lr&&⏱{lr.toLocaleTimeString("en-GB",{hour:"2-digit",minute:"2-digit",second:"2-digit"})}}
{sec==="brief"&&
}
+ {sec==="sentiment"&&}
{sec==="markets"&&
}
{sec==="commod"&&}
+ {sec==="scenarios"&&}
{sec==="corr"&&
}
{sec==="deep"&&{RT.map(c=>)}
}
{sec==="cal"&&}