Create Stonks market data daemon

This commit is contained in:
Meta Vens 2026-06-03 06:09:18 +01:00
commit 77d56e1dcb
7 changed files with 583 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
scratch/
node_modules/
.env

23
AGENTS.md Normal file
View file

@ -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.

46
README.md Normal file
View file

@ -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"
```

32
docs/architecture.md Normal file
View file

@ -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.

16
package.json Normal file
View file

@ -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"
}

52
scripts/start-stonks.ps1 Normal file
View file

@ -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"

410
src/stonks-daemon.cjs Normal file
View file

@ -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);
}