commit 77d56e1dcbec7015bbd0381a9212561aad7c1faf Author: Meta Vens Date: Wed Jun 3 06:09:18 2026 +0100 Create Stonks market data daemon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..899eaa4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +scratch/ +node_modules/ +.env + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d890952 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,23 @@ +# Stonks Agent Notes + +Stonks is a market data adapter daemon for the CultMesh Verse. It is not a +trading bot, portfolio manager, investment adviser, or renderer decoration. + +## Authority Map + +- Owner: Stonks owns public market data polling, normalization, provenance, + freshness, its provider-owned Eve/CultUI surface, and local snapshot logs. +- Inputs: public market data endpoints, configured symbols/assets, local + runtime configuration, and future provider-owned market data feeds. +- Outputs: `stonks.market_snapshot.v1`, `gamecult.eve.surface_state.v1`, + `/eve/deck`, `/eve/deck/providers`, `/health`, and ignored scratch snapshots + under `scratch/stonks/`. +- Derived state: Odin, Nightwing, Eve clients, dashboards, and agents consume + Stonks projections; they do not own market truth or scrape markets directly. +- Forbidden writers: renderers, Odin, VoidBot rumination, and repo Faces must + not invent market state. They consume the Stonks provider surface. +- Shared paths: HTTP status, Eve surface, and future CultMesh documents are all + derived from the same normalized market snapshot. + +Markets own prices. Stonks owns the adapter. Odin sees the Verse. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..94531da --- /dev/null +++ b/README.md @@ -0,0 +1,46 @@ +# Stonks + +Stonks publishes market-data pulse into the GameCult CultMesh Verse. + +It polls public equity/ETF and crypto endpoints, normalizes the result, writes a +local scratch snapshot, and exposes a provider-owned Eve/CultUI surface for +Odin and renderers such as Nightwing. + +## Current Body + +- Provider id: `stonks.market` +- Eve deck: `ws://127.0.0.1:8802/eve/deck` +- Provider manifest: `http://127.0.0.1:8802/eve/deck/providers` +- Health: `http://127.0.0.1:8802/health` +- Snapshot: `http://127.0.0.1:8802/market/state` +- Scratch state: `scratch/stonks/` +- Default focus: gaming and tech public names such as Ubisoft, EA, Take-Two, + Roblox, Nintendo, Sony, Microsoft, NVIDIA, AMD, Google, Meta, Apple, TSMC, + Tesla, ASML, Corsair, Logitech, and Sea. +- Private watch names: `Musk Complex` is represented explicitly as mixed + public/private exposure: TSLA is tracked through the public `TSLA.US` quote, + while SpaceX, xAI/X, Neuralink, and Boring need provider-owned news, + disclosure, or secondary-market feeds before Stonks treats their movement as + data. + +## Start + +```powershell +.\scripts\start-stonks.ps1 +``` + +## Data Sources + +- Equities/ETFs: Stooq CSV quote endpoint. +- Crypto: CoinGecko simple price endpoint. + +The daemon is for operator visibility and ambient market context. It is not +financial advice and does not place trades. + +Configure defaults with: + +```powershell +$env:STONKS_EQUITIES="ubi.fr,ea.us,ttwo.us,rblx.us,ntdoy.us,nvda.us,amd.us,tsla.us" +$env:STONKS_CRYPTO="bitcoin,ethereum,solana,dogecoin" +$env:STONKS_PRIVATE_WATCH="Musk Complex" +``` diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..8525527 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,32 @@ +# Stonks Architecture + +## Objective + +Expose market price and volume pulse as a provider-owned CultMesh/Eve surface so +Odin can aggregate it and renderers can lower it without owning market state. + +## Current Mechanism + +```text +Public market endpoints + -> Stonks poller + -> normalized market snapshot with provenance and freshness + -> provider-owned Eve/CultUI surface + -> /eve/deck WebSocket + /eve/deck/providers manifest + -> Odin interface ingestion + -> Eve / Nightwing / agent projection +``` + +## Invariants + +- Market sources own prices and volumes. +- Stonks owns polling, normalization, provenance, and surface publication. +- Odin owns discovery and aggregation, not market truth. +- Renderers lower the Stonks surface only. +- Stonks does not trade, advise, custody funds, or infer portfolio intent. + +## Future Cut + +When a typed CultMesh market feed exists, replace public polling with provider +documents while preserving the same normalized snapshot and Eve surface. + diff --git a/package.json b/package.json new file mode 100644 index 0000000..8410e4d --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "@gamecult/stonks", + "version": "0.1.0", + "private": true, + "description": "Market data daemon that publishes a provider-owned Eve/CultMesh surface for Odin.", + "type": "commonjs", + "scripts": { + "check": "node --check src/stonks-daemon.cjs", + "start": "node src/stonks-daemon.cjs" + }, + "engines": { + "node": ">=20" + }, + "license": "MIT" +} + diff --git a/scripts/start-stonks.ps1 b/scripts/start-stonks.ps1 new file mode 100644 index 0000000..dce25d9 --- /dev/null +++ b/scripts/start-stonks.ps1 @@ -0,0 +1,52 @@ +param( + [int] $Port = 8802, + [string] $StateDir = "E:\Projects\Stonks\scratch\stonks", + [switch] $Foreground +) + +$ErrorActionPreference = "Stop" + +$repoRoot = Split-Path -Parent $PSScriptRoot +$scriptPath = Join-Path $repoRoot "src\stonks-daemon.cjs" +$pidPath = Join-Path $StateDir "stonks.pid" +$outLog = Join-Path $StateDir "stonks.out.log" +$errLog = Join-Path $StateDir "stonks.err.log" + +New-Item -ItemType Directory -Force -Path $StateDir | Out-Null + +if (Test-Path $pidPath) { + $oldPid = (Get-Content -Raw $pidPath).Trim() + if ($oldPid -match "^\d+$") { + $old = Get-Process -Id ([int] $oldPid) -ErrorAction SilentlyContinue + if ($old) { + throw "Stonks already appears to be running as PID $oldPid." + } + } + Remove-Item -LiteralPath $pidPath -Force +} + +$env:NODE_PATH = "E:\Projects\CultLib\packages" +$args = @( + $scriptPath, + "--port", "$Port", + "--stateDir", $StateDir +) + +if ($Foreground) { + & node @args + exit $LASTEXITCODE +} + +$proc = Start-Process -FilePath "node" -ArgumentList $args -WorkingDirectory $repoRoot -WindowStyle Hidden -PassThru -RedirectStandardOutput $outLog -RedirectStandardError $errLog +$proc.Id | Set-Content -Encoding ASCII -LiteralPath $pidPath +Start-Sleep -Seconds 1 +if ($proc.HasExited) { + $detail = "" + if (Test-Path $outLog) { $detail += Get-Content -Raw $outLog } + if (Test-Path $errLog) { $detail += Get-Content -Raw $errLog } + throw "Stonks exited immediately with code $($proc.ExitCode).`n$detail" +} + +Write-Host "Stonks started as PID $($proc.Id)." +Write-Host "Health: http://127.0.0.1:$Port/health" +Write-Host "Eve deck: ws://127.0.0.1:$Port/eve/deck" diff --git a/src/stonks-daemon.cjs b/src/stonks-daemon.cjs new file mode 100644 index 0000000..9630084 --- /dev/null +++ b/src/stonks-daemon.cjs @@ -0,0 +1,410 @@ +#!/usr/bin/env node +"use strict"; + +const crypto = require("crypto"); +const fs = require("fs"); +const http = require("http"); +const https = require("https"); +const path = require("path"); + +const repoRoot = path.resolve(__dirname, ".."); +const args = parseArgs(process.argv.slice(2)); +const port = Number(args.port || process.env.STONKS_PORT || 8802); +const host = args.host || process.env.STONKS_HOST || "0.0.0.0"; +const intervalMs = Number(args.intervalMs || process.env.STONKS_INTERVAL_MS || 15000); +const stateDir = args.stateDir || path.join(repoRoot, "scratch", "stonks"); +const providerId = "stonks.market"; +const clients = new Set(); + +const equitySymbols = String(args.equities || process.env.STONKS_EQUITIES || "ubi.fr,ea.us,ttwo.us,rblx.us,ntdoy.us,7974.jp,sony.us,msft.us,nvda.us,amd.us,googl.us,meta.us,aapl.us,tsla.us,tsm.us,asml.us,crsr.us,logi.us,se.us") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); +const cryptoIds = String(args.crypto || process.env.STONKS_CRYPTO || "bitcoin,ethereum,solana,dogecoin") + .split(",") + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); +const privateWatch = String(args.privateWatch || process.env.STONKS_PRIVATE_WATCH || "Musk Complex") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + +fs.mkdirSync(stateDir, { recursive: true }); + +let version = 0; +let latestSnapshot = pendingSnapshot("Stonks starting"); +let currentState = buildState(latestSnapshot); + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); + +async function main() { + const server = http.createServer(handleHttp); + server.on("upgrade", handleUpgrade); + server.listen(port, host, () => { + console.log(`Stonks listening on ws://${host}:${port}/eve/deck`); + }); + + await refresh(); + setInterval(() => { + refresh().catch((error) => console.error("refresh failed:", error)); + }, intervalMs); +} + +async function refresh() { + latestSnapshot = await marketSnapshot(); + currentState = buildState(latestSnapshot); + persistSnapshot(latestSnapshot, currentState); + broadcast(currentState); +} + +async function marketSnapshot() { + const startedAt = new Date(); + const [equities, crypto] = await Promise.all([ + fetchEquities().catch((error) => ({ ok: false, error: error.message, items: [] })), + fetchCrypto().catch((error) => ({ ok: false, error: error.message, items: [] })), + ]); + return { + schema: "stonks.market_snapshot.v1", + providerId, + updatedAt: new Date().toISOString(), + durationMs: Date.now() - startedAt.getTime(), + sources: { + equities: { + name: "Stooq", + url: "https://stooq.com/q/l/", + ok: equities.ok, + error: equities.error || null, + }, + crypto: { + name: "CoinGecko", + url: "https://api.coingecko.com/api/v3/simple/price", + ok: crypto.ok, + error: crypto.error || null, + }, + }, + equities: equities.items, + crypto: crypto.items, + privateWatch: privateWatch.map(privateWatchItem), + }; +} + +async function fetchEquities() { + if (equitySymbols.length === 0) return { ok: true, items: [] }; + const rows = await Promise.all(equitySymbols.map(async (symbol) => { + const params = new URLSearchParams({ s: symbol, f: "sd2t2ohlcv", h: "", e: "csv" }); + const text = await getText(`https://stooq.com/q/l/?${params}`); + const lines = text.trim().split(/\r?\n/); + const header = lines.shift()?.split(",") || []; + return lines.map((line) => csvLine(line, header)); + })); + const items = rows + .flat() + .filter((row) => row.Symbol && row.Close && row.Close !== "N/D") + .map((row) => ({ + kind: "equity", + symbol: row.Symbol.toUpperCase(), + price: numberOrNull(row.Close), + open: numberOrNull(row.Open), + high: numberOrNull(row.High), + low: numberOrNull(row.Low), + volume: numberOrNull(row.Volume), + date: row.Date, + time: row.Time, + source: "stooq", + })); + return { ok: true, items }; +} + +async function fetchCrypto() { + if (cryptoIds.length === 0) return { ok: true, items: [] }; + const params = new URLSearchParams({ + ids: cryptoIds.join(","), + vs_currencies: "usd", + include_24hr_vol: "true", + include_24hr_change: "true", + include_market_cap: "true", + }); + const json = JSON.parse(await getText(`https://api.coingecko.com/api/v3/simple/price?${params}`)); + const items = Object.entries(json).map(([id, value]) => ({ + kind: "crypto", + symbol: id.toUpperCase(), + price: numberOrNull(value.usd), + change24h: numberOrNull(value.usd_24h_change), + volume24h: numberOrNull(value.usd_24h_vol), + marketCap: numberOrNull(value.usd_market_cap), + source: "coingecko", + })); + return { ok: true, items }; +} + +function buildState(snapshot) { + version++; + const rows = [ + textNode("updated", `updated ${snapshot.updatedAt}`), + textNode("source-equities", `equities ${snapshot.sources.equities.ok ? "ok" : "error"} via ${snapshot.sources.equities.name}`), + textNode("source-crypto", `crypto ${snapshot.sources.crypto.ok ? "ok" : "error"} via ${snapshot.sources.crypto.name}`), + ...snapshot.equities.map((item) => quoteNode(item)), + ...snapshot.crypto.map((item) => quoteNode(item)), + ...snapshot.privateWatch.map((item) => privateWatchNode(item)), + ]; + if (snapshot.equities.length === 0 && snapshot.crypto.length === 0) { + rows.push(textNode("empty", snapshot.error || "no market rows available")); + } + + return { + schema: "gamecult.eve.surface_state.v1", + providerId, + title: "Stonks", + version, + updatedAt: snapshot.updatedAt, + snapshot, + surface: { + root: { + id: "stonks-root", + kind: "interface", + props: { + title: "Stonks Market Pulse", + providerId, + text: `Market pulse: ${snapshot.equities.length} equities, ${snapshot.crypto.length} crypto assets, ${snapshot.privateWatch.length} private watch names.`, + }, + children: rows, + }, + }, + }; +} + +function quoteNode(item) { + const price = item.price == null ? "N/D" : `$${formatNumber(item.price)}`; + const change = item.change24h == null ? "" : ` ${item.change24h >= 0 ? "+" : ""}${item.change24h.toFixed(2)}%`; + const volume = item.volume24h ?? item.volume; + const volumeText = volume == null ? "" : ` vol ${formatCompact(volume)}`; + return textNode(`quote-${item.kind}-${item.symbol}`, `${item.symbol} ${price}${change}${volumeText}`); +} + +function privateWatchNode(item) { + return textNode(`watch-${item.symbol}`, `${item.name} ${item.status}; ${item.movement}`); +} + +function textNode(id, text) { + return { id, kind: "text", props: { title: id, text } }; +} + +function pendingSnapshot(error) { + return { + schema: "stonks.market_snapshot.v1", + providerId, + updatedAt: new Date().toISOString(), + durationMs: 0, + error, + sources: { + equities: { name: "Stooq", ok: false, error }, + crypto: { name: "CoinGecko", ok: false, error }, + }, + equities: [], + crypto: [], + privateWatch: privateWatch.map(privateWatchItem), + }; +} + +function privateWatchItem(name) { + if (name.toLowerCase() === "musk complex") { + return { + kind: "private-watch", + symbol: "MUSK-COMPLEX", + name, + status: "mixed-public-private", + movement: "TSLA.US live; SpaceX/xAI-X/Neuralink/Boring need provider-owned news or secondary-market feed", + source: "stonks-watchlist", + }; + } + + return { + kind: "private-watch", + symbol: name.toUpperCase().replace(/\s+/g, "-"), + name, + status: "private-no-public-ticker", + movement: "awaiting provider-owned news or secondary-market feed", + source: "stonks-watchlist", + }; +} + +function persistSnapshot(snapshot, state) { + fs.writeFileSync(path.join(stateDir, "latest-market-state.json"), JSON.stringify(snapshot, null, 2), "utf8"); + fs.writeFileSync(path.join(stateDir, "latest-surface.json"), JSON.stringify(state, null, 2), "utf8"); +} + +function handleHttp(req, res) { + const url = new URL(req.url, `http://${req.headers.host || "127.0.0.1"}`); + if (url.pathname === "/health") { + sendJson(res, 200, { + ok: true, + providerId, + version: currentState.version, + clients: clients.size, + intervalMs, + stateDir, + equities: equitySymbols, + crypto: cryptoIds, + privateWatch, + sources: latestSnapshot.sources, + }); + return; + } + + if (url.pathname === "/eve/deck/providers") { + sendJson(res, 200, { + providers: [{ + id: providerId, + title: "Stonks", + description: "Market data pulse provider for the GameCult CultMesh Verse.", + version: String(currentState.version), + endpoint: "/eve/deck", + capabilities: ["market-data", "equities", "crypto", "cultui-surface"], + usesCultMesh: false, + transport: "Eve WebSocket + scratch snapshot", + }], + }); + return; + } + + if (url.pathname === "/market/state") { + sendJson(res, 200, latestSnapshot); + return; + } + + sendText(res, 404, "not found"); +} + +function handleUpgrade(req, socket) { + if (!req.url.startsWith("/eve/deck")) { + socket.end("HTTP/1.1 404 Not Found\r\n\r\n"); + return; + } + + const key = req.headers["sec-websocket-key"]; + if (!key) { + socket.end("HTTP/1.1 400 Bad Request\r\n\r\n"); + return; + } + + const accept = crypto.createHash("sha1").update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest("base64"); + socket.write( + "HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + clients.add(socket); + sendFrame(socket, 0x1, Buffer.from(JSON.stringify(currentState), "utf8")); + socket.on("data", (chunk) => { + if ((chunk[0] & 0x0f) === 0x8) { + clients.delete(socket); + socket.end(); + } + }); + socket.on("close", () => clients.delete(socket)); + socket.on("error", () => clients.delete(socket)); +} + +function broadcast(state) { + const payload = Buffer.from(JSON.stringify(state), "utf8"); + for (const client of [...clients]) { + try { + sendFrame(client, 0x1, payload); + } catch { + clients.delete(client); + client.destroy(); + } + } +} + +function sendFrame(socket, opcode, payload) { + const header = [0x80 | opcode]; + if (payload.length < 126) { + header.push(payload.length); + } else if (payload.length <= 0xffff) { + header.push(126, payload.length >> 8, payload.length & 0xff); + } else { + const length = Buffer.alloc(8); + length.writeBigUInt64BE(BigInt(payload.length)); + header.push(127, ...length); + } + socket.write(Buffer.concat([Buffer.from(header), payload])); +} + +function getText(url) { + return new Promise((resolve, reject) => { + const req = https.get(url, { timeout: 8000, headers: { "user-agent": "GameCult-Stonks/0.1" } }, (res) => { + let data = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { data += chunk; }); + res.on("end", () => { + if (res.statusCode < 200 || res.statusCode >= 300) { + reject(new Error(`${url} returned ${res.statusCode}`)); + return; + } + resolve(data); + }); + }); + req.on("timeout", () => { + req.destroy(new Error(`${url} timed out`)); + }); + req.on("error", reject); + }); +} + +function csvLine(line, header) { + const values = line.split(","); + const row = {}; + for (let i = 0; i < header.length; i++) { + row[header[i]] = values[i] || ""; + } + return row; +} + +function numberOrNull(value) { + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function formatNumber(value) { + return Math.abs(value) >= 100 ? value.toFixed(2) : value.toFixed(4).replace(/0+$/, "").replace(/\.$/, ""); +} + +function formatCompact(value) { + if (value == null) return ""; + if (Math.abs(value) >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`; + if (Math.abs(value) >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; + if (Math.abs(value) >= 1_000) return `${(value / 1_000).toFixed(1)}K`; + return String(Math.round(value)); +} + +function parseArgs(argv) { + const parsed = {}; + for (let index = 0; index < argv.length; index++) { + const arg = argv[index]; + if (!arg.startsWith("--")) continue; + const key = arg.slice(2); + const next = argv[index + 1]; + if (!next || next.startsWith("--")) { + parsed[key] = true; + continue; + } + parsed[key] = next; + index++; + } + return parsed; +} + +function sendJson(res, status, value) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(value)); +} + +function sendText(res, status, value) { + res.writeHead(status, { "content-type": "text/plain" }); + res.end(value); +}