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
+
+
+ + + + + + + 0 + 50 + 100 + +
+
{score}
+
+
+
+
{label}
+
0 = Extreme Fear · 100 = Extreme Greed
+
+
+ {narrative&&
+
{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?
{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=>
sL({...lk,[a.k]:e.target.value})} style={{width:"100%",padding:"8px 12px",background:C.bgC,border:"1px solid "+C.bd,borderRadius:4,color:C.tx,fontSize:12,fontFamily:Mn,boxSizing:"border-box"}}/>
)}
} 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"&&
Commodities
} + {sec==="scenarios"&&} {sec==="corr"&&
} {sec==="deep"&&
{RT.map(c=>)}
} {sec==="cal"&&
Economic Calendar
}