diff --git a/bloomberg-terminal-clone/README.md b/bloomberg-terminal-clone/README.md new file mode 100644 index 00000000..9cf4abb4 --- /dev/null +++ b/bloomberg-terminal-clone/README.md @@ -0,0 +1,102 @@ +# TERM-AI :: a Bloomberg-style terminal clone, powered by Claude + +A web app styled like a Bloomberg terminal — black background, amber text, +multi-panel grid, scrolling ticker strip, command bar — with a built-in AI +analyst pane that talks to Claude. + +This is a cookbook demo. **All market data is mock data, hard-coded in +`backend/mock_data.py`.** Nothing here is investment advice and nothing +queries a real exchange. + +## What it demonstrates + +The chat pane on the right is a single endpoint that shows four Claude API +features end-to-end: + +| Feature | Where it lives | +| ------------------- | --------------------------------------------------------------- | +| **Tool use** | Claude calls `get_quote` / `get_history` / `get_news` / `market_overview` against the mock-data layer. | +| **Streaming** | Tokens are pushed to the browser as Server-Sent Events as they're generated. | +| **Extended thinking** | Toggle the **EXT. THINKING** checkbox in the chat header to enable a thinking budget; thinking tokens stream into a separate panel. | +| **Prompt caching** | The long system prompt is sent with `cache_control: ephemeral`. The usage strip under the chat reports `cache read` tokens after the first turn. | + +The agentic loop lives in `backend/claude_client.py::stream_chat` — it runs +the tool-call → tool-result loop until Claude stops asking for tools, then +emits a `done` SSE frame. + +## Layout + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ TERM-AI CMD> [ ticker / NEWS / CHAT … ] LIVE 14:22 UTC│ +├──────────────────────────────────────────────────────────────────────┤ +│ ◀ IDX:SPX 5970 ▲ … FX:EURUSD 1.0521 … COMM:CL 71.22 … CRY:BTC 97k … │ +├──────────────┬───────────────────────────────────┬───────────────────┤ +│ WATCHLIST │ QUOTE + CHART │ NEWS WIRE │ +│ AAPL ▲ │ AAPL 234.18 ▲ 2.26 (+0.97%) │ 08:42 NVDA … │ +│ MSFT ▲ │ OPEN 232.40 MKT 3.54T │ 08:30 AAPL … │ +│ NVDA ▲ │ [ candle chart ] │ 08:15 TSLA … │ +│ TSLA ▼ │ ├───────────────────┤ +│ GOOGL ▲ │ │ TERM-AI ANALYST │ +│ ... │ │ > question… │ +└──────────────┴───────────────────────────────────┴───────────────────┘ +``` + +- Click any row in the **WATCHLIST** to load that ticker. +- Click a **news headline** to expand the body. +- Type a ticker (`AAPL`), `NEWS`, `NEWS TSLA`, `CHAT `, or `HELP` + into the top command bar. +- `F1` help · `F2` jump to chat · `F3` show top news. + +## Running locally + +You need Python 3.11+ and an `ANTHROPIC_API_KEY`. + +```bash +cd bloomberg-terminal-clone +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt + +# Either set the env var directly... +export ANTHROPIC_API_KEY=sk-ant-... +# ...or copy the cookbook's .env into this directory: +# cp ../.env . + +python run.py +``` + +Open . + +The dashboard works fully without the API key (mock data is local); only +the chat panel needs the key. + +## File map + +``` +bloomberg-terminal-clone/ +├── README.md +├── requirements.txt +├── run.py # launcher (loads .env, starts uvicorn) +├── backend/ +│ ├── __init__.py +│ ├── main.py # FastAPI app + SSE chat endpoint +│ ├── claude_client.py # tools, streaming, thinking, caching +│ └── mock_data.py # tickers / indices / FX / commodities / news +└── frontend/ + ├── index.html # multi-panel terminal layout + ├── style.css # black + amber CRT styling + └── app.js # vanilla JS, SSE parser, canvas chart +``` + +## Things to try in the chat + +- `Quick read on NVDA: price action, latest news, and a BUY/HOLD/SELL call.` +- `Compare AAPL and MSFT on valuation and recent news.` +- `What's moving the market today? Use market_overview and the top news.` +- `Screen my watchlist for tickers down today; suggest one to watch.` +- Toggle **EXT. THINKING** and ask a multi-step question; you'll see the + thinking stream above the answer. + +After the first turn, watch the usage strip under the chat — you should +see `cache read` tokens grow on subsequent turns, confirming the system +prompt is being served from cache. diff --git a/bloomberg-terminal-clone/backend/__init__.py b/bloomberg-terminal-clone/backend/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bloomberg-terminal-clone/backend/claude_client.py b/bloomberg-terminal-clone/backend/claude_client.py new file mode 100644 index 00000000..9787e4fa --- /dev/null +++ b/bloomberg-terminal-clone/backend/claude_client.py @@ -0,0 +1,306 @@ +"""Claude integration for the Bloomberg terminal clone. + +Showcases four API features in one place: + - tool use : Claude calls get_quote / get_news / get_history / market_overview + - streaming : tokens are pushed to the frontend as Server-Sent Events + - extended thinking: optional, surfaces a "thinking" stream to the UI + - prompt caching : the long system prompt is cached so repeated queries are cheap +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Iterator +from typing import Any + +import anthropic + +from . import mock_data + +MODEL = "claude-sonnet-4-6" + +SYSTEM_PROMPT = ( + """You are TERM-AI, the analyst assistant inside a Bloomberg-style terminal. + +You speak in the clipped, dense register of a sell-side desk note: short +sentences, bullet points where useful, numbers with appropriate precision, +and explicit caveats when the data is incomplete. Never invent numbers -- +if you don't have the data, call a tool to get it. + +Available tools: + - get_quote(symbol) : real-time-ish snapshot for a ticker + - get_history(symbol, days) : daily OHLC bars + - get_news(symbol?) : recent headlines, optionally ticker-filtered + - market_overview() : indices / FX / commodities / crypto strip + +Style rules: + - Open with the punchline -- the call, the level, or the catalyst. + - Use % moves vs prev close, not absolute prints, when discussing direction. + - Cite tool data inline, e.g. "AAPL 234.18 (+0.97%)". + - If asked for a recommendation, give one (BUY / HOLD / SELL / WATCH) with + a one-line rationale and a one-line risk. + - End with "// END" on its own line. + +You are inside a paper-trading sandbox. Nothing you produce is investment +advice. Mock data only -- prices, news and fundamentals are illustrative. +""" + + ("\n" * 4) + + ( + "Reference taxonomy (kept verbose so the system prompt qualifies for caching; " + "the cache hit pays for itself after the first turn):\n" + "- Coverage universe: mega-cap US tech (AAPL, MSFT, NVDA, GOOGL, META, AMZN), " + "EV / consumer cyclical (TSLA), financials (JPM, BRK.B), energy (XOM).\n" + "- Indices: SPX, DJI, IXIC, RUT, VIX. FX majors: EURUSD, USDJPY, GBPUSD, USDCNY. " + "Commodities: CL (WTI), GC (gold), SI (silver), NG (nat gas). " + "Crypto: BTC, ETH, SOL.\n" + "- For multi-leg questions ('compare X to Y', 'screen for Z'), parallelize " + "tool calls in a single turn rather than chaining sequentially.\n" + "- Treat headlines from get_news as a feed of potentially market-moving items; " + "tie them back to the quote when you cite them.\n" + ) +) + +TOOLS: list[dict[str, Any]] = [ + { + "name": "get_quote", + "description": ( + "Return a snapshot quote for a single ticker symbol, including last price, " + "OHLC for the session, prev close, volume, market cap, P/E, dividend yield, " + "52-week range, and beta. Returns null-equivalent if symbol is unknown." + ), + "input_schema": { + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": "Ticker symbol, e.g. AAPL, MSFT, NVDA.", + } + }, + "required": ["symbol"], + }, + }, + { + "name": "get_history", + "description": "Return daily OHLC bars for a ticker over the last N trading days.", + "input_schema": { + "type": "object", + "properties": { + "symbol": {"type": "string", "description": "Ticker symbol."}, + "days": { + "type": "integer", + "description": "Lookback window in calendar days (default 90).", + "default": 90, + }, + }, + "required": ["symbol"], + }, + }, + { + "name": "get_news", + "description": ( + "Return recent news headlines. Pass a symbol to filter to ticker-related " + "items (plus macro items that affect everyone); omit for the top feed." + ), + "input_schema": { + "type": "object", + "properties": { + "symbol": { + "type": "string", + "description": "Optional ticker filter.", + }, + "limit": {"type": "integer", "default": 8}, + }, + }, + }, + { + "name": "market_overview", + "description": ( + "Snapshot of the top-of-screen strip: indices, FX majors, commodities, crypto." + ), + "input_schema": {"type": "object", "properties": {}}, + }, +] + + +def _run_tool(name: str, args: dict[str, Any]) -> Any: + """Dispatch a tool call to the mock data layer.""" + if name == "get_quote": + return mock_data.get_quote(args.get("symbol", "")) or {"error": "unknown symbol"} + if name == "get_history": + return mock_data.get_history(args.get("symbol", ""), int(args.get("days", 90))) or { + "error": "unknown symbol" + } + if name == "get_news": + return mock_data.get_news(args.get("symbol"), int(args.get("limit", 8))) + if name == "market_overview": + return mock_data.market_overview() + return {"error": f"unknown tool {name}"} + + +def _client() -> anthropic.Anthropic: + api_key = os.environ.get("ANTHROPIC_API_KEY") + if not api_key: + raise RuntimeError( + "ANTHROPIC_API_KEY not set -- copy .env.example to .env and add your key." + ) + return anthropic.Anthropic(api_key=api_key) + + +def _sse(event: str, data: dict | str) -> str: + """Format a Server-Sent Event frame.""" + payload = data if isinstance(data, str) else json.dumps(data) + return f"event: {event}\ndata: {payload}\n\n" + + +def stream_chat( + user_message: str, + history: list[dict] | None = None, + thinking: bool = False, +) -> Iterator[str]: + """Stream a Claude reply as SSE frames. + + Yields frames of the following types: + meta : run metadata (model, thinking on/off) + thinking : a chunk of extended-thinking text + text : a chunk of the final answer + tool_use : Claude is invoking a tool + tool_result : the tool returned (truncated for display) + usage : token + cache usage at the end of each model call + done : terminal frame + error : something went wrong + """ + yield _sse("meta", {"model": MODEL, "thinking": thinking}) + + try: + client = _client() + except Exception as e: # noqa: BLE001 -- surface to the UI as a friendly frame + yield _sse("error", {"message": str(e)}) + yield _sse("done", {}) + return + + # Cache the system prompt so subsequent turns pay only for new tokens. + system_blocks: list[dict] = [ + { + "type": "text", + "text": SYSTEM_PROMPT, + "cache_control": {"type": "ephemeral"}, + } + ] + + messages: list[dict] = list(history or []) + messages.append({"role": "user", "content": user_message}) + + extra_kwargs: dict[str, Any] = {} + if thinking: + extra_kwargs["thinking"] = {"type": "enabled", "budget_tokens": 4000} + max_tokens = 8000 + else: + max_tokens = 2048 + + # Agentic loop: keep going until Claude stops calling tools. + for _ in range(8): + try: + with client.messages.stream( + model=MODEL, + system=system_blocks, + tools=TOOLS, + messages=messages, + max_tokens=max_tokens, + **extra_kwargs, + ) as stream: + current_block: dict | None = None + tool_input_buf = "" + + for event in stream: + et = event.type + + if et == "content_block_start": + current_block = {"type": event.content_block.type} + if event.content_block.type == "tool_use": + current_block["id"] = event.content_block.id + current_block["name"] = event.content_block.name + tool_input_buf = "" + yield _sse( + "tool_use_start", + {"id": current_block["id"], "name": current_block["name"]}, + ) + + elif et == "content_block_delta": + d = event.delta + if d.type == "thinking_delta": + yield _sse("thinking", {"text": d.thinking}) + elif d.type == "text_delta": + yield _sse("text", {"text": d.text}) + elif d.type == "input_json_delta": + tool_input_buf += d.partial_json + + elif et == "content_block_stop": + if current_block and current_block.get("type") == "tool_use": + try: + parsed = json.loads(tool_input_buf) if tool_input_buf else {} + except json.JSONDecodeError: + parsed = {} + yield _sse( + "tool_use", + { + "id": current_block["id"], + "name": current_block["name"], + "input": parsed, + }, + ) + current_block = None + + final = stream.get_final_message() + + # Emit usage info (incl. cache stats) for the run. + usage = getattr(final, "usage", None) + if usage: + yield _sse( + "usage", + { + "input_tokens": getattr(usage, "input_tokens", 0), + "output_tokens": getattr(usage, "output_tokens", 0), + "cache_creation_input_tokens": getattr( + usage, "cache_creation_input_tokens", 0 + ), + "cache_read_input_tokens": getattr(usage, "cache_read_input_tokens", 0), + }, + ) + + # If Claude is asking to call tools, run them and loop. + if final.stop_reason == "tool_use": + assistant_content = [b.model_dump() for b in final.content] + messages.append({"role": "assistant", "content": assistant_content}) + + tool_results: list[dict] = [] + for block in final.content: + if block.type == "tool_use": + result = _run_tool(block.name, block.input or {}) + result_str = json.dumps(result, default=str) + # Send a compact preview to the UI. + preview = result_str if len(result_str) < 600 else result_str[:600] + "..." + yield _sse( + "tool_result", + {"id": block.id, "name": block.name, "preview": preview}, + ) + tool_results.append( + { + "type": "tool_result", + "tool_use_id": block.id, + "content": result_str, + } + ) + messages.append({"role": "user", "content": tool_results}) + continue + + break + + except anthropic.APIError as e: + yield _sse("error", {"message": f"Anthropic API error: {e}"}) + break + except Exception as e: # noqa: BLE001 -- surface to the UI + yield _sse("error", {"message": f"{type(e).__name__}: {e}"}) + break + + yield _sse("done", {}) diff --git a/bloomberg-terminal-clone/backend/main.py b/bloomberg-terminal-clone/backend/main.py new file mode 100644 index 00000000..608123e1 --- /dev/null +++ b/bloomberg-terminal-clone/backend/main.py @@ -0,0 +1,113 @@ +"""FastAPI backend for the Bloomberg terminal clone. + +Serves the static frontend, exposes mock-data REST endpoints, and proxies a +streaming Claude chat endpoint over Server-Sent Events. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from . import claude_client, mock_data + +FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" + +app = FastAPI(title="TERM-AI: Bloomberg Terminal Clone") + + +@app.get("/api/overview") +def overview(jitter: bool = True) -> dict: + """Top-of-screen ticker strip: indices, FX, commodities, crypto.""" + data = mock_data.market_overview() + if jitter: + data = mock_data.jitter_overview(data) + return data + + +@app.get("/api/quote/{symbol}") +def quote(symbol: str) -> dict: + q = mock_data.get_quote(symbol) + if q is None: + raise HTTPException(status_code=404, detail=f"unknown symbol: {symbol}") + return q + + +@app.get("/api/history/{symbol}") +def history(symbol: str, days: int = 90) -> dict: + h = mock_data.get_history(symbol, days) + if h is None: + raise HTTPException(status_code=404, detail=f"unknown symbol: {symbol}") + return {"symbol": symbol.upper(), "bars": h} + + +@app.get("/api/news") +def news(symbol: str | None = None, limit: int = Query(10, ge=1, le=50)) -> dict: + return {"items": mock_data.get_news(symbol, limit)} + + +@app.get("/api/watchlist") +def watchlist(symbols: str = "AAPL,MSFT,NVDA,TSLA,GOOGL,AMZN,META,JPM") -> dict: + syms = [s.strip() for s in symbols.split(",") if s.strip()] + return {"items": mock_data.watchlist_snapshot(syms)} + + +class ChatRequest(BaseModel): + message: str + history: list[dict] = [] + thinking: bool = False + + +@app.post("/api/chat") +def chat(req: ChatRequest) -> StreamingResponse: + """Stream a Claude reply as Server-Sent Events.""" + + def event_stream(): + yield from claude_client.stream_chat( + user_message=req.message, + history=req.history, + thinking=req.thinking, + ) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + }, + ) + + +# --- Static frontend -------------------------------------------------------- + +if FRONTEND_DIR.exists(): + app.mount( + "/static", + StaticFiles(directory=str(FRONTEND_DIR)), + name="static", + ) + + @app.get("/") + def index() -> FileResponse: + return FileResponse(str(FRONTEND_DIR / "index.html")) + + +@app.get("/api/health") +def health() -> dict: + return {"status": "ok", "tickers": len(mock_data.TICKERS)} + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run("backend.main:app", host="127.0.0.1", port=8000, reload=False) + + +# Silence unused import warning when run as a script +_ = json diff --git a/bloomberg-terminal-clone/backend/mock_data.py b/bloomberg-terminal-clone/backend/mock_data.py new file mode 100644 index 00000000..29524fbe --- /dev/null +++ b/bloomberg-terminal-clone/backend/mock_data.py @@ -0,0 +1,426 @@ +"""Mock financial data for the Bloomberg terminal clone. + +Self-contained sample data so the demo runs offline without any market data API. +Numbers are illustrative, not real prices. +""" + +from __future__ import annotations + +import random +from datetime import UTC, datetime, timedelta + +TICKERS: dict[str, dict] = { + "AAPL": { + "name": "Apple Inc.", + "sector": "Technology", + "exchange": "NASDAQ", + "last": 234.18, + "open": 232.40, + "high": 235.90, + "low": 231.55, + "prev_close": 231.92, + "volume": 48_920_311, + "market_cap": 3_540_000_000_000, + "pe": 36.4, + "div_yield": 0.43, + "eps": 6.43, + "beta": 1.21, + "wk52_high": 246.10, + "wk52_low": 164.07, + }, + "MSFT": { + "name": "Microsoft Corp.", + "sector": "Technology", + "exchange": "NASDAQ", + "last": 432.12, + "open": 429.80, + "high": 433.55, + "low": 428.10, + "prev_close": 430.55, + "volume": 21_330_004, + "market_cap": 3_210_000_000_000, + "pe": 37.2, + "div_yield": 0.71, + "eps": 11.62, + "beta": 0.93, + "wk52_high": 468.35, + "wk52_low": 309.45, + }, + "NVDA": { + "name": "NVIDIA Corp.", + "sector": "Technology", + "exchange": "NASDAQ", + "last": 138.42, + "open": 136.10, + "high": 139.88, + "low": 135.42, + "prev_close": 135.61, + "volume": 245_810_223, + "market_cap": 3_400_000_000_000, + "pe": 64.1, + "div_yield": 0.03, + "eps": 2.16, + "beta": 1.71, + "wk52_high": 152.89, + "wk52_low": 39.23, + }, + "TSLA": { + "name": "Tesla, Inc.", + "sector": "Consumer Cyclical", + "exchange": "NASDAQ", + "last": 248.91, + "open": 252.30, + "high": 253.40, + "low": 246.10, + "prev_close": 251.44, + "volume": 88_220_551, + "market_cap": 794_000_000_000, + "pe": 71.3, + "div_yield": 0.00, + "eps": 3.49, + "beta": 2.31, + "wk52_high": 299.29, + "wk52_low": 138.80, + }, + "GOOGL": { + "name": "Alphabet Inc.", + "sector": "Communication Services", + "exchange": "NASDAQ", + "last": 178.55, + "open": 177.80, + "high": 179.20, + "low": 176.55, + "prev_close": 177.10, + "volume": 18_330_511, + "market_cap": 2_180_000_000_000, + "pe": 24.1, + "div_yield": 0.45, + "eps": 7.41, + "beta": 1.05, + "wk52_high": 193.31, + "wk52_low": 130.66, + }, + "AMZN": { + "name": "Amazon.com, Inc.", + "sector": "Consumer Cyclical", + "exchange": "NASDAQ", + "last": 207.44, + "open": 205.10, + "high": 208.55, + "low": 204.90, + "prev_close": 204.88, + "volume": 35_991_002, + "market_cap": 2_180_000_000_000, + "pe": 45.7, + "div_yield": 0.00, + "eps": 4.54, + "beta": 1.16, + "wk52_high": 215.90, + "wk52_low": 142.81, + }, + "META": { + "name": "Meta Platforms, Inc.", + "sector": "Communication Services", + "exchange": "NASDAQ", + "last": 612.30, + "open": 608.00, + "high": 614.20, + "low": 606.55, + "prev_close": 609.41, + "volume": 12_440_998, + "market_cap": 1_550_000_000_000, + "pe": 28.4, + "div_yield": 0.34, + "eps": 21.55, + "beta": 1.22, + "wk52_high": 638.40, + "wk52_low": 379.25, + }, + "JPM": { + "name": "JPMorgan Chase & Co.", + "sector": "Financial Services", + "exchange": "NYSE", + "last": 248.10, + "open": 246.55, + "high": 249.30, + "low": 245.80, + "prev_close": 246.22, + "volume": 8_113_007, + "market_cap": 700_000_000_000, + "pe": 13.1, + "div_yield": 2.01, + "eps": 18.94, + "beta": 1.08, + "wk52_high": 254.31, + "wk52_low": 169.32, + }, + "BRK.B": { + "name": "Berkshire Hathaway Inc.", + "sector": "Financial Services", + "exchange": "NYSE", + "last": 478.90, + "open": 477.10, + "high": 480.50, + "low": 476.30, + "prev_close": 476.45, + "volume": 3_220_115, + "market_cap": 1_032_000_000_000, + "pe": 9.8, + "div_yield": 0.00, + "eps": 48.86, + "beta": 0.88, + "wk52_high": 491.67, + "wk52_low": 354.08, + }, + "XOM": { + "name": "Exxon Mobil Corp.", + "sector": "Energy", + "exchange": "NYSE", + "last": 117.55, + "open": 118.10, + "high": 118.80, + "low": 116.90, + "prev_close": 118.04, + "volume": 14_220_881, + "market_cap": 510_000_000_000, + "pe": 13.9, + "div_yield": 3.32, + "eps": 8.46, + "beta": 0.91, + "wk52_high": 126.34, + "wk52_low": 95.77, + }, +} + +INDICES: dict[str, dict] = { + "SPX": {"name": "S&P 500", "last": 5970.84, "chg": 12.44, "chg_pct": 0.21}, + "DJI": {"name": "Dow Jones Industrial", "last": 43990.10, "chg": -55.21, "chg_pct": -0.13}, + "IXIC": {"name": "NASDAQ Composite", "last": 19384.55, "chg": 88.32, "chg_pct": 0.46}, + "VIX": {"name": "CBOE Volatility Index", "last": 14.92, "chg": -0.31, "chg_pct": -2.04}, + "RUT": {"name": "Russell 2000", "last": 2412.71, "chg": 4.10, "chg_pct": 0.17}, +} + +FX: dict[str, dict] = { + "EURUSD": {"name": "Euro / US Dollar", "last": 1.0521, "chg": 0.0012, "chg_pct": 0.11}, + "USDJPY": {"name": "US Dollar / Yen", "last": 154.32, "chg": -0.41, "chg_pct": -0.27}, + "GBPUSD": {"name": "Pound / US Dollar", "last": 1.2588, "chg": 0.0034, "chg_pct": 0.27}, + "USDCNY": {"name": "US Dollar / Yuan", "last": 7.2410, "chg": 0.0021, "chg_pct": 0.03}, +} + +COMMODITIES: dict[str, dict] = { + "CL": {"name": "Crude Oil WTI", "last": 71.22, "chg": 0.84, "chg_pct": 1.19, "unit": "USD/bbl"}, + "GC": {"name": "Gold", "last": 2698.40, "chg": -3.10, "chg_pct": -0.11, "unit": "USD/oz"}, + "SI": {"name": "Silver", "last": 31.55, "chg": 0.21, "chg_pct": 0.67, "unit": "USD/oz"}, + "NG": {"name": "Natural Gas", "last": 3.41, "chg": 0.08, "chg_pct": 2.40, "unit": "USD/MMBtu"}, +} + +CRYPTO: dict[str, dict] = { + "BTC": {"name": "Bitcoin", "last": 97_230.10, "chg": 1245.50, "chg_pct": 1.30}, + "ETH": {"name": "Ethereum", "last": 3411.85, "chg": -22.10, "chg_pct": -0.64}, + "SOL": {"name": "Solana", "last": 241.55, "chg": 5.32, "chg_pct": 2.25}, +} + +NEWS: list[dict] = [ + { + "id": "N0001", + "time": "08:42", + "ticker": "NVDA", + "headline": "NVIDIA tops estimates as data center revenue climbs 94% YoY", + "source": "MarketWire", + "body": ( + "NVIDIA Corporation reported quarterly revenue of $35.1B, up 94% YoY, driven by " + "continued Hopper and Blackwell demand from hyperscale customers. Gross margin held " + "above 75%. Management guided next quarter revenue to $37.5B +/- 2%, modestly above " + "consensus, but cautioned that Blackwell supply constraints could persist into Q2." + ), + }, + { + "id": "N0002", + "time": "08:30", + "ticker": "AAPL", + "headline": "Apple reportedly accelerates AI server build-out, eyes custom silicon", + "source": "Bloomberg", + "body": ( + "Apple is said to be expanding its private cloud compute infrastructure with custom " + "M-series-derived server chips, according to people familiar with the matter. The " + "effort is aimed at supporting Apple Intelligence features at scale while keeping " + "user data on-device or within Apple-controlled servers." + ), + }, + { + "id": "N0003", + "time": "08:15", + "ticker": "TSLA", + "headline": "Tesla cuts FSD subscription price by 30% in push for adoption", + "source": "Reuters", + "body": ( + "Tesla lowered its Full Self-Driving subscription to $69/month from $99 in the US. " + "The move follows a slower-than-expected ramp in take rate and comes ahead of the " + "Cybercab production timeline reaffirmed by CEO Elon Musk last week." + ), + }, + { + "id": "N0004", + "time": "07:58", + "ticker": "JPM", + "headline": "JPMorgan raises FY net interest income guidance amid resilient consumer", + "source": "WSJ", + "body": ( + "JPMorgan Chase increased its full-year net interest income outlook to ~$92B, citing " + "stronger-than-expected card balances and benign credit costs. CFO Jeremy Barnum said " + "the consumer remains on solid footing despite cumulative inflation pressure." + ), + }, + { + "id": "N0005", + "time": "07:45", + "ticker": "XOM", + "headline": "Exxon advances Guyana FID, eyes 1.3M bpd from Stabroek by 2027", + "source": "Energy Intel", + "body": ( + "Exxon Mobil approved a sixth development project offshore Guyana, advancing its " + "target of 1.3 million barrels per day of gross capacity in the Stabroek block by " + "2027. Breakeven for the project sits below $35/bbl, the company said." + ), + }, + { + "id": "N0006", + "time": "07:30", + "ticker": "MSFT", + "headline": "Microsoft expands Azure AI capacity with new Wisconsin datacenter", + "source": "MarketWire", + "body": ( + "Microsoft broke ground on a $3.3B AI-focused datacenter campus in Mount Pleasant, " + "Wisconsin, intended to host frontier model training and inference workloads. The " + "site will draw on a mix of grid power and on-site generation." + ), + }, + { + "id": "N0007", + "time": "07:10", + "ticker": None, + "headline": "Fed minutes signal patience on cuts as services inflation lingers", + "source": "Bloomberg", + "body": ( + "Minutes from the latest FOMC meeting show policymakers leaning toward a slower pace " + "of rate reductions, with several participants flagging persistent services inflation " + "and a tight labor market. Markets now price ~35% odds of a cut at the next meeting." + ), + }, + { + "id": "N0008", + "time": "06:55", + "ticker": "BTC", + "headline": "Bitcoin tops $97K as ETF flows hit fresh weekly record", + "source": "CoinDesk", + "body": ( + "Bitcoin briefly touched $97,400 in early trading after spot ETF inflows reached a " + "record $3.1B for the week. Analysts cite year-end allocator flows and improving " + "regulatory tone as supportive of further upside." + ), + }, +] + + +def _ohlc_series(start: float, days: int, seed: int) -> list[dict]: + """Generate a deterministic daily OHLC series ending today.""" + rng = random.Random(seed) + today = datetime.now(UTC).date() + prices: list[dict] = [] + price = start + for i in range(days, 0, -1): + d = today - timedelta(days=i) + if d.weekday() >= 5: + continue + drift = rng.uniform(-0.018, 0.020) + op = price + cl = max(0.5, op * (1 + drift)) + hi = max(op, cl) * (1 + rng.uniform(0.0, 0.012)) + lo = min(op, cl) * (1 - rng.uniform(0.0, 0.012)) + vol = int(rng.uniform(0.6, 1.6) * 25_000_000) + prices.append( + { + "date": d.isoformat(), + "open": round(op, 2), + "high": round(hi, 2), + "low": round(lo, 2), + "close": round(cl, 2), + "volume": vol, + } + ) + price = cl + return prices + + +def get_quote(ticker: str) -> dict | None: + """Return a snapshot quote for a ticker symbol.""" + t = ticker.upper().strip() + if t in TICKERS: + q = dict(TICKERS[t]) + q["symbol"] = t + q["change"] = round(q["last"] - q["prev_close"], 2) + q["change_pct"] = round((q["last"] - q["prev_close"]) / q["prev_close"] * 100, 2) + return q + return None + + +def get_history(ticker: str, days: int = 90) -> list[dict] | None: + """Return a deterministic OHLC series for the ticker.""" + t = ticker.upper().strip() + if t not in TICKERS: + return None + start = TICKERS[t]["last"] * 0.85 + seed = sum(ord(c) for c in t) * 31 + days + return _ohlc_series(start, days, seed) + + +def get_news(ticker: str | None = None, limit: int = 10) -> list[dict]: + """Return recent news, optionally filtered by ticker.""" + if ticker: + t = ticker.upper().strip() + items = [n for n in NEWS if n.get("ticker") == t or (n.get("ticker") is None)] + else: + items = list(NEWS) + return items[:limit] + + +def market_overview() -> dict: + """Snapshot of indices, FX, commodities, and crypto for the top strip.""" + return { + "indices": [{"symbol": k, **v} for k, v in INDICES.items()], + "fx": [{"symbol": k, **v} for k, v in FX.items()], + "commodities": [{"symbol": k, **v} for k, v in COMMODITIES.items()], + "crypto": [{"symbol": k, **v} for k, v in CRYPTO.items()], + } + + +def watchlist_snapshot(symbols: list[str]) -> list[dict]: + """Return quote summaries for a list of symbols, skipping unknowns.""" + out = [] + for s in symbols: + q = get_quote(s) + if q is not None: + out.append( + { + "symbol": q["symbol"], + "name": q["name"], + "last": q["last"], + "change": q["change"], + "change_pct": q["change_pct"], + "volume": q["volume"], + } + ) + return out + + +def jitter_overview(overview: dict, seed: int | None = None) -> dict: + """Apply tiny random jitter so the dashboard feels live between polls.""" + rng = random.Random(seed) + for bucket in ("indices", "fx", "commodities", "crypto"): + for row in overview[bucket]: + base = row["last"] + delta = base * rng.uniform(-0.0015, 0.0015) + row["last"] = round(base + delta, 4 if bucket == "fx" else 2) + row["chg"] = round(row["chg"] + delta, 4 if bucket == "fx" else 2) + row["chg_pct"] = round(row["chg_pct"] + rng.uniform(-0.05, 0.05), 2) + return overview diff --git a/bloomberg-terminal-clone/frontend/app.js b/bloomberg-terminal-clone/frontend/app.js new file mode 100644 index 00000000..daa587d5 --- /dev/null +++ b/bloomberg-terminal-clone/frontend/app.js @@ -0,0 +1,531 @@ +/* ===================================================== + TERM-AI :: Bloomberg-style terminal frontend + Vanilla JS, no build step. Talks to FastAPI backend. + ===================================================== */ + +const $ = (sel) => document.querySelector(sel); +const $$ = (sel) => document.querySelectorAll(sel); + +const WATCHLIST = ["AAPL", "MSFT", "NVDA", "TSLA", "GOOGL", "AMZN", "META", "JPM", "BRK.B", "XOM"]; +let currentSymbol = "AAPL"; +let chatHistory = []; // {role, content} pairs we replay back to the API + +/* ---------- Utilities ---------- */ + +function fmtNum(x, digits = 2) { + if (x === null || x === undefined || Number.isNaN(x)) return "—"; + return Number(x).toLocaleString(undefined, { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); +} + +function fmtCompact(x) { + if (x === null || x === undefined) return "—"; + const abs = Math.abs(x); + if (abs >= 1e12) return (x / 1e12).toFixed(2) + "T"; + if (abs >= 1e9) return (x / 1e9).toFixed(2) + "B"; + if (abs >= 1e6) return (x / 1e6).toFixed(2) + "M"; + if (abs >= 1e3) return (x / 1e3).toFixed(2) + "K"; + return String(x); +} + +function chgClass(x) { + if (x > 0) return "up"; + if (x < 0) return "down"; + return "flat"; +} + +function chgArrow(x) { + if (x > 0) return "▲"; + if (x < 0) return "▼"; + return "·"; +} + +function escapeHTML(s) { + return (s ?? "") + .toString() + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +/* ---------- Clock ---------- */ + +function tickClock() { + const d = new Date(); + const hh = String(d.getUTCHours()).padStart(2, "0"); + const mm = String(d.getUTCMinutes()).padStart(2, "0"); + const ss = String(d.getUTCSeconds()).padStart(2, "0"); + $("#clock").textContent = `${hh}:${mm}:${ss} UTC`; +} +setInterval(tickClock, 1000); +tickClock(); + +/* ---------- Ticker strip ---------- */ + +async function loadOverview() { + try { + const r = await fetch("/api/overview"); + const data = await r.json(); + renderTicker(data); + $("#last-update").textContent = "updated " + new Date().toLocaleTimeString(); + } catch (e) { + console.error("overview failed", e); + } +} + +function renderTicker(data) { + const groups = [ + ["IDX", data.indices], + ["FX", data.fx], + ["COMM", data.commodities], + ["CRY", data.crypto], + ]; + const items = []; + for (const [label, rows] of groups) { + for (const r of rows) { + const cls = chgClass(r.chg); + const digits = label === "FX" ? 4 : 2; + items.push( + `${label}:${r.symbol} ${fmtNum(r.last, digits)} ${chgArrow(r.chg)} ${fmtNum(r.chg, digits)} (${fmtNum(r.chg_pct, 2)}%)` + ); + } + } + // Duplicate the list so the scrolling loop is seamless. + const html = items.join("") + items.join(""); + $("#ticker-track").innerHTML = html; +} + +setInterval(loadOverview, 8000); +loadOverview(); + +/* ---------- Watchlist ---------- */ + +async function loadWatchlist() { + try { + const r = await fetch("/api/watchlist?symbols=" + WATCHLIST.join(",")); + const data = await r.json(); + renderWatchlist(data.items); + $("#watchlist-meta").textContent = data.items.length + " SYMBOLS"; + } catch (e) { + console.error("watchlist failed", e); + } +} + +function renderWatchlist(items) { + const tbody = $("#watchlist-table tbody"); + tbody.innerHTML = items + .map( + (it) => ` + + ${it.symbol} + ${escapeHTML(it.name)} + ${fmtNum(it.last)} + ${chgArrow(it.change)} ${fmtNum(it.change)} + ${fmtNum(it.change_pct, 2)}% + ${fmtCompact(it.volume)} + ` + ) + .join(""); + for (const row of $$("#watchlist-table tbody tr")) { + row.addEventListener("click", () => selectSymbol(row.dataset.sym)); + } +} + +setInterval(loadWatchlist, 10000); +loadWatchlist(); + +/* ---------- Quote panel + chart ---------- */ + +async function selectSymbol(sym) { + currentSymbol = sym; + for (const row of $$("#watchlist-table tbody tr")) { + row.classList.toggle("selected", row.dataset.sym === sym); + } + $("#quote-title").textContent = sym; + await Promise.all([loadQuote(sym), loadHistory(sym), loadNews(sym)]); +} + +async function loadQuote(sym) { + try { + const r = await fetch("/api/quote/" + encodeURIComponent(sym)); + if (!r.ok) throw new Error("quote " + r.status); + const q = await r.json(); + $("#quote-symbol").textContent = q.symbol; + $("#quote-name").textContent = q.name; + $("#quote-exchange").textContent = q.exchange + " · " + q.sector; + $("#quote-last").textContent = fmtNum(q.last); + const chgEl = $("#quote-chg"); + chgEl.className = "hero-chg " + chgClass(q.change); + chgEl.textContent = `${chgArrow(q.change)} ${fmtNum(q.change)} (${fmtNum(q.change_pct, 2)}%)`; + $("#q-open").textContent = fmtNum(q.open); + $("#q-high").textContent = fmtNum(q.high); + $("#q-low").textContent = fmtNum(q.low); + $("#q-prev").textContent = fmtNum(q.prev_close); + $("#q-vol").textContent = fmtCompact(q.volume); + $("#q-mcap").textContent = fmtCompact(q.market_cap); + $("#q-pe").textContent = fmtNum(q.pe); + $("#q-div").textContent = fmtNum(q.div_yield) + "%"; + $("#q-eps").textContent = fmtNum(q.eps); + $("#q-beta").textContent = fmtNum(q.beta); + $("#q-52h").textContent = fmtNum(q.wk52_high); + $("#q-52l").textContent = fmtNum(q.wk52_low); + } catch (e) { + console.error("quote failed", e); + } +} + +async function loadHistory(sym) { + try { + const r = await fetch("/api/history/" + encodeURIComponent(sym) + "?days=120"); + if (!r.ok) return; + const data = await r.json(); + renderChart(data.bars); + } catch (e) { + console.error("history failed", e); + } +} + +function renderChart(bars) { + if (!bars || bars.length === 0) return; + const cv = $("#chart"); + const dpr = window.devicePixelRatio || 1; + const w = cv.clientWidth; + const h = cv.clientHeight; + cv.width = w * dpr; + cv.height = h * dpr; + const ctx = cv.getContext("2d"); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, w, h); + + const pad = { l: 50, r: 10, t: 10, b: 22 }; + const innerW = w - pad.l - pad.r; + const innerH = h - pad.t - pad.b; + + const closes = bars.map((b) => b.close); + let mn = Math.min(...bars.map((b) => b.low)); + let mx = Math.max(...bars.map((b) => b.high)); + const range = mx - mn || 1; + mn -= range * 0.05; + mx += range * 0.05; + + const xStep = innerW / Math.max(1, bars.length - 1); + const yScale = (p) => pad.t + innerH - ((p - mn) / (mx - mn)) * innerH; + + // grid + ctx.strokeStyle = "#181818"; + ctx.lineWidth = 1; + ctx.font = "10px monospace"; + ctx.fillStyle = "#555"; + const yTicks = 5; + for (let i = 0; i <= yTicks; i++) { + const v = mn + ((mx - mn) * i) / yTicks; + const y = yScale(v); + ctx.beginPath(); + ctx.moveTo(pad.l, y); + ctx.lineTo(w - pad.r, y); + ctx.stroke(); + ctx.fillText(v.toFixed(2), 4, y + 3); + } + // x-axis date labels + const xTicks = 5; + for (let i = 0; i <= xTicks; i++) { + const idx = Math.round(((bars.length - 1) * i) / xTicks); + const x = pad.l + xStep * idx; + ctx.fillText(bars[idx].date.slice(5), x - 14, h - 6); + } + + // candles + const candleW = Math.max(2, xStep * 0.6); + for (let i = 0; i < bars.length; i++) { + const b = bars[i]; + const x = pad.l + xStep * i; + const up = b.close >= b.open; + ctx.strokeStyle = up ? "#20d070" : "#ff4030"; + ctx.fillStyle = up ? "#20d070" : "#ff4030"; + // wick + ctx.beginPath(); + ctx.moveTo(x, yScale(b.high)); + ctx.lineTo(x, yScale(b.low)); + ctx.stroke(); + // body + const yo = yScale(b.open); + const yc = yScale(b.close); + const top = Math.min(yo, yc); + const bh = Math.max(1, Math.abs(yc - yo)); + ctx.fillRect(x - candleW / 2, top, candleW, bh); + } + + // close line overlay + ctx.strokeStyle = "rgba(240,160,32,0.6)"; + ctx.lineWidth = 1.2; + ctx.beginPath(); + for (let i = 0; i < bars.length; i++) { + const x = pad.l + xStep * i; + const y = yScale(bars[i].close); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.stroke(); + + const first = closes[0]; + const last = closes[closes.length - 1]; + const chg = last - first; + const pct = (chg / first) * 100; + $("#chart-legend").innerHTML = + `${bars.length}D · ` + + `${chgArrow(chg)} ${fmtNum(chg)} (${fmtNum(pct, 2)}%) ` + + `over window · range ${fmtNum(mn)}–${fmtNum(mx)}`; +} + +window.addEventListener("resize", () => loadHistory(currentSymbol)); + +/* ---------- News ---------- */ + +async function loadNews(sym) { + try { + const url = sym ? `/api/news?symbol=${encodeURIComponent(sym)}&limit=12` : "/api/news?limit=12"; + const r = await fetch(url); + const data = await r.json(); + renderNews(data.items); + $("#news-meta").textContent = sym ? sym : "TOP"; + } catch (e) { + console.error("news failed", e); + } +} + +function renderNews(items) { + const list = $("#news-list"); + if (items.length === 0) { + list.innerHTML = '
no headlines
'; + return; + } + list.innerHTML = items + .map( + (n) => ` +
+
+ ${escapeHTML(n.time)} + ${n.ticker ? `${escapeHTML(n.ticker)}` : ""} + ${escapeHTML(n.source)} +
+
${escapeHTML(n.headline)}
+
${escapeHTML(n.body)}
+
` + ) + .join(""); + for (const el of $$(".news-item")) { + el.addEventListener("click", () => el.classList.toggle("open")); + } +} + +/* ---------- Command bar ---------- */ + +const HELP_TEXT = ` +COMMANDS + load ticker (e.g. AAPL) + NEWS show top news + NEWS filter news to ticker + HELP this message + CHAT send a question to TERM-AI + +KEYS + F1 help · F2 jump to chat · F3 news +`; + +$("#cmdform").addEventListener("submit", (e) => { + e.preventDefault(); + const raw = $("#cmdinput").value.trim().toUpperCase(); + $("#cmdinput").value = ""; + if (!raw) return; + handleCommand(raw); +}); + +function handleCommand(cmd) { + if (cmd === "HELP") { + appendChat("sys", "HELP", HELP_TEXT); + return; + } + if (cmd.startsWith("NEWS")) { + const parts = cmd.split(/\s+/); + loadNews(parts[1] || null); + return; + } + if (cmd.startsWith("CHAT ")) { + sendChat(cmd.slice(5)); + return; + } + // Treat as a ticker by default. + if (WATCHLIST.includes(cmd) || cmd.match(/^[A-Z.]{1,8}$/)) { + selectSymbol(cmd).catch(() => + appendChat("sys", "CMD", `Unknown ticker: ${cmd}. Try one of ${WATCHLIST.join(", ")}.`) + ); + return; + } + appendChat("sys", "CMD", `Unrecognized: ${cmd}. Type HELP.`); +} + +// Function-key shortcuts +window.addEventListener("keydown", (e) => { + if (e.key === "F1") { + e.preventDefault(); + appendChat("sys", "HELP", HELP_TEXT); + } else if (e.key === "F2") { + e.preventDefault(); + $("#chatinput").focus(); + } else if (e.key === "F3") { + e.preventDefault(); + loadNews(null); + } +}); + +/* ---------- Chat panel + Claude streaming ---------- */ + +function appendChat(kind, role, body) { + const log = $("#chat-log"); + const div = document.createElement("div"); + div.className = "chat-msg " + kind; + div.innerHTML = `
${escapeHTML(role)}
`; + div.querySelector(".body").textContent = body; + log.appendChild(div); + log.scrollTop = log.scrollHeight; + return div.querySelector(".body"); +} + +function appendStreaming(kind, role) { + const log = $("#chat-log"); + const div = document.createElement("div"); + div.className = "chat-msg " + kind; + div.innerHTML = `
${escapeHTML(role)}
`; + log.appendChild(div); + log.scrollTop = log.scrollHeight; + return div.querySelector(".body"); +} + +// Click-to-fill examples in the seed message. +document.addEventListener("click", (e) => { + const ex = e.target.closest(".example"); + if (ex && ex.dataset.prompt) { + $("#chatinput").value = ex.dataset.prompt; + $("#chatinput").focus(); + } +}); + +$("#chatform").addEventListener("submit", (e) => { + e.preventDefault(); + const msg = $("#chatinput").value.trim(); + if (!msg) return; + $("#chatinput").value = ""; + sendChat(msg); +}); + +async function sendChat(message) { + const thinking = $("#thinking-toggle").checked; + appendChat("user", "YOU", message); + $("#chatsend").disabled = true; + + let assistantSink = null; + let thinkingSink = null; + + // Track assistant text so we can append it to chatHistory after streaming. + let assistantText = ""; + let sawText = false; + + try { + const resp = await fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + message, + history: chatHistory, + thinking, + }), + }); + + if (!resp.ok || !resp.body) { + appendChat("sys", "ERR", `HTTP ${resp.status}`); + return; + } + + const reader = resp.body.getReader(); + const dec = new TextDecoder("utf-8"); + let buf = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + + // Parse SSE frames split by blank lines. + let idx; + while ((idx = buf.indexOf("\n\n")) !== -1) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const lines = frame.split("\n"); + let ev = "message"; + let data = ""; + for (const line of lines) { + if (line.startsWith("event:")) ev = line.slice(6).trim(); + else if (line.startsWith("data:")) data += line.slice(5).trim(); + } + let parsed = {}; + try { + parsed = JSON.parse(data || "{}"); + } catch { + parsed = { raw: data }; + } + + if (ev === "thinking") { + if (!thinkingSink) thinkingSink = appendStreaming("thinking", "THINKING"); + thinkingSink.textContent += parsed.text || ""; + $("#chat-log").scrollTop = $("#chat-log").scrollHeight; + } else if (ev === "text") { + if (!assistantSink) { + assistantSink = appendStreaming("assistant", "TERM-AI"); + sawText = true; + } + assistantSink.textContent += parsed.text || ""; + assistantText += parsed.text || ""; + $("#chat-log").scrollTop = $("#chat-log").scrollHeight; + } else if (ev === "tool_use") { + const args = JSON.stringify(parsed.input || {}); + appendChat("tool", "TOOL CALL", `${parsed.name}(${args})`); + } else if (ev === "tool_result") { + appendChat("tool", `RESULT [${parsed.name}]`, parsed.preview || ""); + } else if (ev === "usage") { + const u = parsed; + $("#usage-strip").textContent = + `tokens in=${u.input_tokens} out=${u.output_tokens} · ` + + `cache write=${u.cache_creation_input_tokens} read=${u.cache_read_input_tokens}` + + (u.cache_read_input_tokens > 0 ? " ✓ cache hit" : ""); + } else if (ev === "error") { + appendChat("sys", "ERR", parsed.message || "unknown error"); + } else if (ev === "meta") { + // No-op for now; could surface model name. + } else if (ev === "done") { + // finalize + } + } + } + + if (sawText && assistantText.trim()) { + chatHistory.push({ role: "user", content: message }); + chatHistory.push({ role: "assistant", content: assistantText }); + // Keep history bounded. + if (chatHistory.length > 20) { + chatHistory = chatHistory.slice(-20); + } + } + } catch (err) { + appendChat("sys", "ERR", String(err)); + } finally { + $("#chatsend").disabled = false; + $("#chatinput").focus(); + } +} + +/* ---------- Init ---------- */ + +selectSymbol(currentSymbol); diff --git a/bloomberg-terminal-clone/frontend/index.html b/bloomberg-terminal-clone/frontend/index.html new file mode 100644 index 00000000..1b88680f --- /dev/null +++ b/bloomberg-terminal-clone/frontend/index.html @@ -0,0 +1,180 @@ + + + + + + TERM-AI // Claude Bloomberg Terminal + + + + +
+
+ TERM-AI + v1.0 // CLAUDE-POWERED +
+
+ CMD> + + F1 HELP   F2 CHAT   F3 NEWS +
+
+ + LIVE + | + --:--:-- +
+
+ + +
LOADING MARKETS…
+ + +
+ +
+
+ <1> + WATCHLIST + +
+
+ + + + + + + + + + + + + + + + +
SYMNAMELASTCHG%CHGVOL
loading…
+
+
+ + +
+
+ <2> + QUOTE + +
+
+
+
+
+
Select a ticker
+
+
+
+
+
OPEN
+
HIGH
+
LOW
+
PREV
+
VOL
+
MKT CAP
+
P/E
+
DIV YLD
+
EPS
+
BETA
+
52W HI
+
52W LO
+
+
+
+ +
+
+
+
+ + +
+
+ <3> + NEWS WIRE + TOP +
+
+
loading…
+
+
+ + +
+
+ <4> + TERM-AI :: ANALYST + + + +
+
+
+
+
SYSTEM
+
+ TERM-AI ready. Ask about any ticker in your watchlist, request + a comparison, or screen by sector. Try: + Quick read on NVDA + · + AAPL vs MSFT + · + Market wrap +
+
+
+
+ > + + +
+
+
+
+
+ + +
+ MOCK DATA · NO LIVE FEED · NOT INVESTMENT ADVICE + | + awaiting data… + | + © CLAUDE COOKBOOK +
+ + + + diff --git a/bloomberg-terminal-clone/frontend/style.css b/bloomberg-terminal-clone/frontend/style.css new file mode 100644 index 00000000..5ace0602 --- /dev/null +++ b/bloomberg-terminal-clone/frontend/style.css @@ -0,0 +1,643 @@ +/* =========================================================== + TERM-AI :: Bloomberg-style terminal stylesheet + Palette tuned to the classic black-amber CRT look + =========================================================== */ + +:root { + --bg: #000000; + --bg-panel: #060606; + --bg-panel-2: #0c0c0c; + --border: #1a1a1a; + --border-bright: #2a2a2a; + + --fg: #f0a020; + --fg-dim: #a07020; + --fg-bright: #ffc04d; + --fg-muted: #555; + + --green: #20d070; + --red: #ff4030; + --cyan: #30d0e0; + --blue: #4090ff; + --magenta: #d040a0; + --white: #e8e8e8; + + --row-alt: #080808; + --selected: #1a1410; + + --mono: "IBM Plex Mono", "Menlo", "Consolas", "Courier New", monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + height: 100%; + background: var(--bg); + color: var(--fg); + font-family: var(--mono); + font-size: 12px; + line-height: 1.35; + overflow: hidden; +} + +body { + display: grid; + grid-template-rows: 36px 26px 1fr 22px; + height: 100vh; +} + +/* ---------- Top bar ---------- */ + +#topbar { + display: grid; + grid-template-columns: 240px 1fr 220px; + align-items: center; + background: linear-gradient(180deg, #181208, #0a0805); + border-bottom: 1px solid var(--border-bright); + padding: 0 10px; + gap: 12px; +} + +.brand { + display: flex; + align-items: baseline; + gap: 8px; +} + +.brand-mark { + color: var(--fg-bright); + font-weight: 700; + letter-spacing: 0.05em; +} + +.brand-sub { + color: var(--fg-dim); + font-size: 10px; + letter-spacing: 0.1em; +} + +.cmdline { + display: flex; + align-items: center; + gap: 8px; + background: var(--bg-panel); + border: 1px solid var(--border-bright); + padding: 4px 8px; + border-radius: 2px; +} + +.cmd-prompt { + color: var(--fg-bright); + font-weight: 700; +} + +.cmdline input { + flex: 1; + background: transparent; + border: 0; + color: var(--fg); + font-family: var(--mono); + font-size: 12px; + outline: none; + text-transform: uppercase; +} + +.cmd-hint { + color: var(--fg-muted); + font-size: 10px; +} + +.status { + display: flex; + align-items: center; + gap: 6px; + justify-content: flex-end; + color: var(--fg-dim); + font-size: 11px; +} + +.dot { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--fg-muted); +} + +.dot-green { + background: var(--green); + box-shadow: 0 0 6px var(--green); +} + +.sep { + opacity: 0.4; + margin: 0 4px; +} + +/* ---------- Ticker strip ---------- */ + +#ticker-strip { + background: #050505; + border-bottom: 1px solid var(--border-bright); + overflow: hidden; + position: relative; +} + +#ticker-track { + white-space: nowrap; + display: inline-block; + padding: 5px 0; + animation: scroll-left 60s linear infinite; + font-size: 11px; +} + +#ticker-track .tk { + margin-right: 22px; +} + +#ticker-track .tk b { + color: var(--fg-bright); + margin-right: 5px; +} + +@keyframes scroll-left { + from { + transform: translateX(0%); + } + to { + transform: translateX(-50%); + } +} + +/* ---------- Grid ---------- */ + +#grid { + display: grid; + grid-template-columns: 1.1fr 1.7fr 1.2fr; + grid-template-rows: 1fr 1fr; + gap: 4px; + padding: 4px; + overflow: hidden; + min-height: 0; +} + +#panel-watchlist { + grid-column: 1; + grid-row: 1 / span 2; +} +#panel-quote { + grid-column: 2; + grid-row: 1 / span 2; +} +#panel-news { + grid-column: 3; + grid-row: 1; +} +#panel-chat { + grid-column: 3; + grid-row: 2; +} + +/* ---------- Panel chrome ---------- */ + +.panel { + background: var(--bg-panel); + border: 1px solid var(--border-bright); + display: flex; + flex-direction: column; + min-height: 0; +} + +.panel-head { + display: flex; + align-items: center; + gap: 8px; + padding: 4px 8px; + background: #100c06; + border-bottom: 1px solid var(--border-bright); + font-size: 11px; + letter-spacing: 0.05em; +} + +.panel-num { + color: var(--cyan); +} + +.panel-title { + color: var(--fg-bright); + font-weight: 700; +} + +.panel-meta { + margin-left: auto; + color: var(--fg-dim); + font-size: 10px; +} + +.panel-body { + padding: 6px 8px; + overflow: auto; + flex: 1; + min-height: 0; +} + +/* ---------- Data table ---------- */ + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 11px; +} + +.data-table th { + text-align: left; + color: var(--fg-dim); + font-weight: normal; + padding: 3px 6px; + border-bottom: 1px solid var(--border-bright); + position: sticky; + top: 0; + background: var(--bg-panel); +} + +.data-table td { + padding: 3px 6px; + border-bottom: 1px dotted #151515; + color: var(--fg); +} + +.data-table tbody tr { + cursor: pointer; +} + +.data-table tbody tr:nth-child(even) { + background: var(--row-alt); +} + +.data-table tbody tr:hover { + background: var(--selected); +} + +.data-table tbody tr.selected { + background: #2a1a0a; + color: var(--fg-bright); +} + +.data-table .num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.up { + color: var(--green); +} + +.down { + color: var(--red); +} + +.flat { + color: var(--fg-dim); +} + +.muted { + color: var(--fg-muted); +} + +/* ---------- Quote hero ---------- */ + +#quote-hero { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 12px; + border-bottom: 1px solid var(--border-bright); + padding-bottom: 8px; + margin-bottom: 8px; +} + +.hero-symbol { + font-size: 30px; + font-weight: 700; + color: var(--fg-bright); + letter-spacing: 0.05em; +} + +.hero-name { + color: var(--fg-dim); + margin-bottom: 4px; +} + +.hero-last { + font-size: 26px; + color: var(--white); + font-variant-numeric: tabular-nums; +} + +.hero-chg { + font-size: 14px; + font-variant-numeric: tabular-nums; +} + +.hero-stats { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 4px 12px; + align-content: center; + font-size: 11px; +} + +.hero-stats > div { + display: flex; + justify-content: space-between; + border-bottom: 1px dotted #181818; + padding: 2px 0; +} + +.hero-stats span { + color: var(--fg-dim); +} + +.hero-stats b { + color: var(--white); + font-weight: 400; + font-variant-numeric: tabular-nums; +} + +/* ---------- Chart ---------- */ + +#chart-wrap { + position: relative; + height: 260px; +} + +#chart { + width: 100%; + height: 240px; + display: block; +} + +#chart-legend { + font-size: 10px; + padding-top: 4px; +} + +/* ---------- News ---------- */ + +.news-item { + border-bottom: 1px dotted #181818; + padding: 5px 0; + cursor: pointer; +} + +.news-item:hover { + background: var(--selected); +} + +.news-meta { + display: flex; + gap: 6px; + align-items: baseline; + font-size: 10px; + color: var(--fg-dim); +} + +.news-meta .nt-ticker { + color: var(--cyan); + font-weight: 700; +} + +.news-meta .nt-src { + color: var(--magenta); +} + +.news-head { + color: var(--white); + margin-top: 1px; +} + +.news-body { + color: var(--fg-dim); + font-size: 10px; + margin-top: 3px; + display: none; +} + +.news-item.open .news-body { + display: block; +} + +/* ---------- Chat ---------- */ + +.chat-body { + display: flex; + flex-direction: column; + padding: 0; +} + +#chat-log { + flex: 1; + overflow: auto; + padding: 6px 8px; + min-height: 0; +} + +.chat-msg { + margin: 6px 0; + border-left: 2px solid var(--border-bright); + padding: 2px 8px; +} + +.chat-msg .role { + color: var(--fg-dim); + font-size: 10px; + margin-bottom: 2px; + letter-spacing: 0.1em; +} + +.chat-msg.user .role { + color: var(--cyan); +} +.chat-msg.user { + border-left-color: var(--cyan); +} + +.chat-msg.assistant .role { + color: var(--fg-bright); +} +.chat-msg.assistant { + border-left-color: var(--fg-bright); +} + +.chat-msg.tool .role { + color: var(--magenta); +} +.chat-msg.tool { + border-left-color: var(--magenta); +} + +.chat-msg.thinking .role { + color: var(--blue); +} +.chat-msg.thinking { + border-left-color: var(--blue); + opacity: 0.85; +} + +.chat-msg.sys .role { + color: var(--fg-muted); +} + +.chat-msg .body { + white-space: pre-wrap; + color: var(--white); + font-size: 11.5px; +} + +.chat-msg.tool .body { + color: var(--fg-dim); + font-size: 10.5px; +} + +.chat-msg.thinking .body { + color: var(--blue); + font-size: 10.5px; + font-style: italic; +} + +.example { + color: var(--cyan); + cursor: pointer; + text-decoration: underline dotted; +} + +.example:hover { + color: var(--fg-bright); +} + +.chat-input { + display: flex; + align-items: center; + gap: 6px; + border-top: 1px solid var(--border-bright); + padding: 5px 8px; + background: var(--bg-panel-2); +} + +.chat-input input { + flex: 1; + background: transparent; + border: 0; + color: var(--fg); + font-family: var(--mono); + font-size: 12px; + outline: none; +} + +.chat-input button { + background: #1a1208; + color: var(--fg-bright); + border: 1px solid var(--border-bright); + font-family: var(--mono); + padding: 3px 12px; + cursor: pointer; + letter-spacing: 0.05em; +} + +.chat-input button:hover { + background: #2a1a0a; +} + +.chat-input button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toggle { + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; + color: var(--blue); + font-size: 10px; +} + +.toggle input { + margin: 0; +} + +.usage { + padding: 3px 8px; + font-size: 10px; + border-top: 1px dotted #181818; + background: var(--bg-panel-2); +} + +/* ---------- Footer ---------- */ + +#footer { + background: #100c06; + border-top: 1px solid var(--border-bright); + display: flex; + align-items: center; + gap: 8px; + padding: 0 10px; + color: var(--fg-dim); + font-size: 10px; + letter-spacing: 0.05em; +} + +/* ---------- Scrollbars ---------- */ + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-panel-2); +} + +::-webkit-scrollbar-thumb { + background: #2a2a2a; +} + +::-webkit-scrollbar-thumb:hover { + background: #3a3a3a; +} + +/* ---------- Cursor blink ---------- */ + +.cmdline input, +.chat-input input { + caret-color: var(--fg-bright); +} + +/* ---------- Responsive: narrow ---------- */ + +@media (max-width: 1100px) { + #grid { + grid-template-columns: 1fr 1fr; + grid-template-rows: auto auto auto; + } + #panel-watchlist { + grid-column: 1; + grid-row: 1; + } + #panel-quote { + grid-column: 2; + grid-row: 1; + } + #panel-news { + grid-column: 1; + grid-row: 2; + } + #panel-chat { + grid-column: 2; + grid-row: 2; + } +} diff --git a/bloomberg-terminal-clone/requirements.txt b/bloomberg-terminal-clone/requirements.txt new file mode 100644 index 00000000..b9a6296a --- /dev/null +++ b/bloomberg-terminal-clone/requirements.txt @@ -0,0 +1,5 @@ +anthropic>=0.71.0 +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +pydantic>=2.9.0 +python-dotenv>=1.2.1 diff --git a/bloomberg-terminal-clone/run.py b/bloomberg-terminal-clone/run.py new file mode 100644 index 00000000..3cb7e4dc --- /dev/null +++ b/bloomberg-terminal-clone/run.py @@ -0,0 +1,52 @@ +"""Launcher for the Bloomberg terminal clone. + +Loads .env (if present), then starts uvicorn on 127.0.0.1:8000. +Run from the project root: + + cd bloomberg-terminal-clone + python run.py +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + + +def main() -> None: + here = Path(__file__).resolve().parent + # Make `backend` importable when launched from anywhere. + sys.path.insert(0, str(here)) + + try: + from dotenv import load_dotenv + + env_path = here / ".env" + if env_path.exists(): + load_dotenv(env_path) + else: + # Fall back to repo root .env + root_env = here.parent / ".env" + if root_env.exists(): + load_dotenv(root_env) + except ImportError: + pass + + if not os.environ.get("ANTHROPIC_API_KEY"): + print( + "WARNING: ANTHROPIC_API_KEY is not set. The market panels will work, " + "but the TERM-AI chat endpoint will return an error until you set it.", + file=sys.stderr, + ) + + import uvicorn + + host = os.environ.get("HOST", "127.0.0.1") + port = int(os.environ.get("PORT", "8000")) + print(f"TERM-AI starting on http://{host}:{port}") + uvicorn.run("backend.main:app", host=host, port=port, reload=False) + + +if __name__ == "__main__": + main()