|
| 1 | +import type { Context } from "@finos/fdc3"; |
| 2 | +import { useEffect, useMemo, useState } from "react"; |
| 3 | + |
| 4 | +// Simple mock quote generator |
| 5 | +function hash(str: string): number { |
| 6 | + let h = 2166136261; |
| 7 | + for (let i = 0; i < str.length; i++) { |
| 8 | + h ^= str.charCodeAt(i); |
| 9 | + h += (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24); |
| 10 | + } |
| 11 | + return Math.abs(h >>> 0); |
| 12 | +} |
| 13 | + |
| 14 | +function getMockQuote(ticker: string, horizonValue?: string) { |
| 15 | + const base = hash(ticker) % 500 + 20; // $20 - $520 |
| 16 | + // Map horizon to a simple volatility multiplier so the UI "feels" different per selection |
| 17 | + let volMultiplier = 1; |
| 18 | + if (horizonValue) { |
| 19 | + if (horizonValue.includes("1d")) volMultiplier = 0.2; |
| 20 | + else if (horizonValue.includes("1w")) volMultiplier = 0.5; |
| 21 | + else if (horizonValue.includes("1mo")) volMultiplier = 1.0; |
| 22 | + else if (horizonValue.includes("3mo")) volMultiplier = 1.05; |
| 23 | + else if (horizonValue.includes("6mo")) volMultiplier = 1.1; |
| 24 | + else if (horizonValue.includes("1y")) volMultiplier = 1.15; |
| 25 | + else if (horizonValue.includes("5y")) volMultiplier = 1.2; |
| 26 | + else if (horizonValue.includes("10y")) volMultiplier = 1.25; |
| 27 | + else if (horizonValue.includes("20y")) volMultiplier = 1.3; |
| 28 | + } |
| 29 | + const change = (((hash(ticker + "chg") % 2000) - 1000) / 100) * volMultiplier; // -10%..+10% scaled |
| 30 | + const price = Math.max(1, base * (1 + change / 100)); |
| 31 | + const prevClose = price / (1 + change / 100); |
| 32 | + return { |
| 33 | + price: Number(price.toFixed(2)), |
| 34 | + changePct: Number(change.toFixed(2)), |
| 35 | + changeAbs: Number((price - prevClose).toFixed(2)), |
| 36 | + volume: (hash(ticker + "vol") % 9_000_000) + 1_000_000, |
| 37 | + dayHigh: Number((price * 1.02).toFixed(2)), |
| 38 | + dayLow: Number((price * 0.98).toFixed(2)), |
| 39 | + }; |
| 40 | +} |
| 41 | + |
| 42 | +export function Monitor() { |
| 43 | + |
| 44 | + // Demo state populated from interop contexts |
| 45 | + const [tickers, setTickers] = useState<string[]>([]); |
| 46 | + const [lastContextAt, setLastContextAt] = useState<string | null>(null); |
| 47 | + const [horizonLabel, setHorizonLabel] = useState<string>("—"); |
| 48 | + const [horizonValue, setHorizonValue] = useState<string | undefined>(undefined); |
| 49 | + const [horizonTs, setHorizonTs] = useState<string | null>(null); |
| 50 | + const [sessionContextGroup, setSessionContextGroup] = useState<any | null>(null); |
| 51 | + |
| 52 | + const quotes = useMemo(() => { |
| 53 | + return tickers.map((t) => ({ |
| 54 | + ticker: t, |
| 55 | + ...getMockQuote(t, horizonValue), |
| 56 | + })); |
| 57 | + }, [tickers, horizonValue]); |
| 58 | + |
| 59 | + // Outbound: broadcast contexts back to the session group |
| 60 | + const handleBroadcastInstruments = async () => { |
| 61 | + if (!sessionContextGroup) return; |
| 62 | + const list = (tickers.length > 0 ? tickers : ["AAPL", "AMZN", "META"]).map((t) => ({ |
| 63 | + type: "instrument", |
| 64 | + id: { ticker: t.toUpperCase() }, |
| 65 | + })); |
| 66 | + console.log('Setting instrument context:', list); |
| 67 | + await sessionContextGroup.setContext({ |
| 68 | + type: "instrumentList", |
| 69 | + instruments: list, |
| 70 | + }); |
| 71 | + }; |
| 72 | + |
| 73 | + const handleSetHorizon = async (label: string, value: string) => { |
| 74 | + if (!sessionContextGroup) return; |
| 75 | + console.log('Setting horizon context:', label, value); |
| 76 | + await sessionContextGroup.setContext({ |
| 77 | + type: "streamlit.timeHorizon", |
| 78 | + horizon: label, |
| 79 | + horizonValue: value, |
| 80 | + timestamp: new Date().toISOString(), |
| 81 | + }); |
| 82 | + }; |
| 83 | + |
| 84 | + useEffect(() => { |
| 85 | + if (!fin) { |
| 86 | + return; |
| 87 | + } |
| 88 | + |
| 89 | + (async () => { |
| 90 | + console.log("Listening to session context group"); |
| 91 | + |
| 92 | + const interop = fin.Interop.connectSync(fin.me.uuid, {}); |
| 93 | + const contextGroup = await interop.joinSessionContextGroup("stockpeers"); |
| 94 | + setSessionContextGroup(contextGroup); |
| 95 | + await contextGroup.addContextHandler((context: Context) => { |
| 96 | + console.log("Received context", context); |
| 97 | + |
| 98 | + // Handle instrument list |
| 99 | + if (context?.type === "instrumentList") { |
| 100 | + const list = context?.instruments as any[] | undefined; |
| 101 | + const next = (list ?? []) |
| 102 | + .map((i) => i?.id?.ticker as string) |
| 103 | + .filter((t): t is string => Boolean(t)) |
| 104 | + .map((t) => t.toUpperCase()); |
| 105 | + // de-dup and keep order |
| 106 | + const unique: string[] = []; |
| 107 | + next.forEach((t) => { |
| 108 | + if (!unique.includes(t)) unique.push(t); |
| 109 | + }); |
| 110 | + setTickers(unique); |
| 111 | + setLastContextAt(new Date().toISOString()); |
| 112 | + return; |
| 113 | + } |
| 114 | + |
| 115 | + // Handle time horizon |
| 116 | + if (context.type === "streamlit.timeHorizon") { |
| 117 | + const c: Context = context; |
| 118 | + setHorizonLabel(c.horizon ?? "—"); |
| 119 | + setHorizonValue(c.horizonValue); |
| 120 | + setHorizonTs(c.timestamp ?? new Date().toISOString()); |
| 121 | + return; |
| 122 | + } |
| 123 | + }); |
| 124 | + })(); |
| 125 | + }, []); |
| 126 | + |
| 127 | + return ( |
| 128 | + <div style={{ display: "flex", flexDirection: "column", gap: 16, padding: 16 }}> |
| 129 | + <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}> |
| 130 | + <h2 style={{ margin: 0 }}>Market Monitor</h2> |
| 131 | + <div style={{ display: "flex", alignItems: "center", gap: 12 }}> |
| 132 | + <div |
| 133 | + style={{ |
| 134 | + border: "1px solid #e0e0e0", |
| 135 | + borderRadius: 8, |
| 136 | + padding: "6px 10px", |
| 137 | + background: "#fafafa", |
| 138 | + }} |
| 139 | + title={horizonTs ? `Updated ${new Date(horizonTs).toLocaleString()}` : undefined} |
| 140 | + > |
| 141 | + <span style={{ color: "#666", marginRight: 6 }}>Horizon: |
| 142 | + <strong>{horizonLabel}</strong></span> |
| 143 | + </div> |
| 144 | + </div> |
| 145 | + </div> |
| 146 | + |
| 147 | + <div style={{ fontSize: 12, color: "#666" }}> |
| 148 | + {lastContextAt ? `Last context @ ${new Date(lastContextAt).toLocaleTimeString()}` : "Awaiting context…"} |
| 149 | + </div> |
| 150 | + |
| 151 | + {/* Outbound controls: send contexts to the session group */} |
| 152 | + <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}> |
| 153 | + <button type="button" onClick={handleBroadcastInstruments} disabled={!sessionContextGroup} title={!sessionContextGroup ? "Joining context group…" : undefined}> |
| 154 | + Broadcast instrumentList |
| 155 | + </button> |
| 156 | + <span style={{ color: "#888" }}>|</span> |
| 157 | + <span style={{ color: "#666" }}>Set Horizon:</span> |
| 158 | + <button type="button" onClick={() => handleSetHorizon("1 Month", "1mo")} disabled={!sessionContextGroup}>1M</button> |
| 159 | + <button type="button" onClick={() => handleSetHorizon("3 Months", "3mo")} disabled={!sessionContextGroup}>3M</button> |
| 160 | + <button type="button" onClick={() => handleSetHorizon("6 Months", "6mo")} disabled={!sessionContextGroup}>6M</button> |
| 161 | + <button type="button" onClick={() => handleSetHorizon("1 Year", "1y")} disabled={!sessionContextGroup}>1Y</button> |
| 162 | + <button type="button" onClick={() => handleSetHorizon("5 Years", "5y")} disabled={!sessionContextGroup}>5Y</button> |
| 163 | + <button type="button" onClick={() => handleSetHorizon("10 Years", "10y")} disabled={!sessionContextGroup}>10Y</button> |
| 164 | + <button type="button" onClick={() => handleSetHorizon("20 Years", "20y")} disabled={!sessionContextGroup}>20Y</button> |
| 165 | + </div> |
| 166 | + |
| 167 | + <div style={{ overflow: "auto", border: "1px solid #eee", borderRadius: 8 }}> |
| 168 | + <table style={{ width: "100%", borderCollapse: "separate", borderSpacing: 0 }}> |
| 169 | + <thead> |
| 170 | + <tr style={{ background: "#f5f7fa" }}> |
| 171 | + <th style={{ textAlign: "left", padding: "8px 12px", borderBottom: "1px solid #e5e7eb" }}>Ticker</th> |
| 172 | + <th style={{ textAlign: "right", padding: "8px 12px", borderBottom: "1px solid #e5e7eb" }}>Price</th> |
| 173 | + <th style={{ textAlign: "right", padding: "8px 12px", borderBottom: "1px solid #e5e7eb" }}>Change</th> |
| 174 | + <th style={{ textAlign: "right", padding: "8px 12px", borderBottom: "1px solid #e5e7eb" }}>%</th> |
| 175 | + <th style={{ textAlign: "right", padding: "8px 12px", borderBottom: "1px solid #e5e7eb" }}>Volume</th> |
| 176 | + <th style={{ textAlign: "right", padding: "8px 12px", borderBottom: "1px solid #e5e7eb" }}>Day Range</th> |
| 177 | + </tr> |
| 178 | + </thead> |
| 179 | + <tbody> |
| 180 | + {quotes.length === 0 ? ( |
| 181 | + <tr> |
| 182 | + <td colSpan={6} style={{ padding: 16, textAlign: "center", color: "#888" }}> |
| 183 | + Send an `instrumentList` context to populate the blotter |
| 184 | + </td> |
| 185 | + </tr> |
| 186 | + ) : ( |
| 187 | + quotes.map((q) => { |
| 188 | + const up = q.changePct >= 0; |
| 189 | + const color = up ? "#059669" : "#dc2626"; |
| 190 | + return ( |
| 191 | + <tr key={q.ticker}> |
| 192 | + <td style={{ padding: "8px 12px", borderBottom: "1px solid #f0f0f0", fontWeight: 600 }}>{q.ticker}</td> |
| 193 | + <td style={{ padding: "8px 12px", borderBottom: "1px solid #f0f0f0", textAlign: "right" }}>${q.price.toFixed(2)}</td> |
| 194 | + <td style={{ padding: "8px 12px", borderBottom: "1px solid #f0f0f0", textAlign: "right", color }}>{up ? "+" : ""}{q.changeAbs.toFixed(2)}</td> |
| 195 | + <td style={{ padding: "8px 12px", borderBottom: "1px solid #f0f0f0", textAlign: "right", color }}>{up ? "+" : ""}{q.changePct.toFixed(2)}%</td> |
| 196 | + <td style={{ padding: "8px 12px", borderBottom: "1px solid #f0f0f0", textAlign: "right" }}>{q.volume.toLocaleString()}</td> |
| 197 | + <td style={{ padding: "8px 12px", borderBottom: "1px solid #f0f0f0", textAlign: "right" }}> |
| 198 | + ${q.dayLow.toFixed(2)} – ${q.dayHigh.toFixed(2)} |
| 199 | + </td> |
| 200 | + </tr> |
| 201 | + ); |
| 202 | + }) |
| 203 | + )} |
| 204 | + </tbody> |
| 205 | + </table> |
| 206 | + </div> |
| 207 | + </div> |
| 208 | + ); |
| 209 | +} |
0 commit comments