📈 the trading desk — see the data, pin the ticker, hear the market think
Right-click any source: its actual number, live, with a two-minute
sparkline right in the menu — and 📌 pin it to the ticker, where your
watchlist scrolls ahead of the news with live values. The Binance tap
widens to ETH, SOL and the euro itself (EUR/USD on a 1s miniTicker,
since the pair trades thin), the ECB's daily fixes arrive as glacial
drones (GBP/USD, USD/JPY, AUD/USD), and Bitcoin speaks its own
technical analysis on 5s closes: the MACD line, Wilder's RSI(14), and
cross bells that ring once at every golden and death cross. All of it
routed through the Spirits like any money — to the solar plexus, to
Hod, to Nidavellir, to fehu.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d19618083b
commit
aa81535379
15
config.json
15
config.json
@ -41,7 +41,7 @@
|
||||
"air": {"label": "💨 air", "members": ["weather.wind", "air.pm25", "planes.count", "wiki.rate"]},
|
||||
"water": {"label": "💧 water", "members": ["weather.precip", "weather.humidity", "astro.moon", "season.south"]},
|
||||
"spirit": {"label": "✨ spirit", "members": ["astro.harmony", "almanac.fertile", "zodiac.hour", "clock.births"]},
|
||||
"wealth": {"label": "💰 wealth", "members": ["crypto.price", "market.turnover", "market.velocity", "debt.total"]},
|
||||
"wealth": {"label": "💰 wealth", "members": ["crypto.price", "crypto.eth", "fx.eurusd", "market.turnover", "market.velocity", "debt.total"]},
|
||||
"summer": {"label": "☀ summer", "members": ["season.north", "season.south", "weather.temp", "sky.day"]}
|
||||
},
|
||||
|
||||
@ -78,6 +78,19 @@
|
||||
"weather.humidity":{"norm": {"lo": 0, "hi": 100}, "label": "humidity"},
|
||||
"crypto.price": {"norm": {"mode": "zscore", "window": 400}, "filter": {"min_cutoff": 0.6, "beta": 0.02}, "label": "BTC price"},
|
||||
"crypto.vel": {"norm": {"mode": "minmax", "window": 120}, "filter": {"min_cutoff": 2.0, "beta": 0.15}, "label": "BTC volatility"},
|
||||
"crypto.eth": {"norm": {"mode": "zscore", "window": 400}, "filter": {"min_cutoff": 0.6, "beta": 0.02}, "label": "ETH price"},
|
||||
"crypto.eth_vel": {"norm": {"mode": "minmax", "window": 120}, "filter": {"min_cutoff": 2.0, "beta": 0.15}, "label": "ETH volatility"},
|
||||
"crypto.sol": {"norm": {"mode": "zscore", "window": 400}, "filter": {"min_cutoff": 0.6, "beta": 0.02}, "label": "SOL price"},
|
||||
"crypto.sol_vel": {"norm": {"mode": "minmax", "window": 120}, "filter": {"min_cutoff": 2.0, "beta": 0.15}, "label": "SOL volatility"},
|
||||
"crypto.macd": {"norm": {"mode": "zscore", "window": 400}, "filter": {"min_cutoff": 0.4, "beta": 0.01}, "label": "BTC momentum (MACD)"},
|
||||
"crypto.rsi": {"norm": {"lo": 0, "hi": 100}, "filter": {"min_cutoff": 0.4, "beta": 0.01}, "label": "BTC RSI(14)"},
|
||||
"crypto.cross_up": {"type": "event", "norm": {"lo": 0, "hi": 1}, "decay": 4.0, "label": "golden cross (BTC)"},
|
||||
"crypto.cross_down": {"type": "event", "norm": {"lo": 0, "hi": 1}, "decay": 4.0, "label": "death cross (BTC)"},
|
||||
"fx.eurusd": {"norm": {"mode": "zscore", "window": 600}, "filter": {"min_cutoff": 0.4, "beta": 0.01}, "label": "EUR/USD live"},
|
||||
"fx.eurusd_vel": {"norm": {"mode": "minmax", "window": 120}, "filter": {"min_cutoff": 2.0, "beta": 0.15}, "label": "EUR/USD velocity"},
|
||||
"fx.gbpusd": {"norm": {"mode": "zscore", "window": 200}, "filter": {"min_cutoff": 0.08, "beta": 0.001}, "label": "GBP/USD (ECB fix)"},
|
||||
"fx.usdjpy": {"norm": {"mode": "zscore", "window": 200}, "filter": {"min_cutoff": 0.08, "beta": 0.001}, "label": "USD/JPY (ECB fix)"},
|
||||
"fx.audusd": {"norm": {"mode": "zscore", "window": 200}, "filter": {"min_cutoff": 0.08, "beta": 0.001}, "label": "AUD/USD (ECB fix)"},
|
||||
"quake.event": {"type": "event", "norm": {"lo": 0, "hi": 7}, "decay": 3.5, "label": "earthquake"},
|
||||
"quake.depth": {"norm": {"lo": 0, "hi": 300}, "label": "quake depth"},
|
||||
"wiki.edit.event": {"type": "event", "norm": {"lo": 0, "hi": 3000}, "attack": 0.005, "decay": 0.25, "label": "wiki edit"},
|
||||
|
||||
1
run.py
1
run.py
@ -34,6 +34,7 @@ WORLD = [
|
||||
"workers/world_sun.py",
|
||||
"workers/world_iss.py",
|
||||
"workers/world_crypto.py",
|
||||
"workers/world_fx.py",
|
||||
"workers/world_planes.py",
|
||||
"workers/world_wiki.py",
|
||||
"workers/world_sky.py",
|
||||
|
||||
106
viz/index.html
106
viz/index.html
@ -551,6 +551,8 @@
|
||||
|
||||
<p><b>crypto.price / crypto.vel — Bitcoin.</b> Live trades off the <b>Binance websocket firehose</b>. Price is <kbd>zscore</kbd>-normalized over a 400-sample window (so it's "how unusual is this price vs the recent run," not an absolute dollar figure); volatility (<kbd>crypto.vel</kbd>) is <kbd>minmax</kbd> over 120 samples with an aggressive, snappy filter (<kbd>min_cutoff 2.0, β 0.15</kbd>) so it reacts in near-real-time. This is the fastest feed in the instrument: <i>sub-second chaos</i>, a genuine rhythm engine. <i>BTC volatility drives the hats</i> (amt 0.8, scurve) and <i>nudges the tempo</i> (amt 0.25) — when the market convulses, the percussion densifies and the track leans forward. And <i>Bitcoin plays the lead melody</i> (crypto.price → lead.note, quantized to <b>minor pentatonic</b>, root 60 / C, 2 octaves) — the price doesn't wobble formlessly, it picks notes in a key. The market's fear and greed, rendered as a melody line that's always in tune.</p>
|
||||
|
||||
<p><b>The trading desk — more markets, and the market's mood as signals.</b> The same Binance tap now carries <b>ETH</b> and <b>SOL</b> (<kbd>crypto.eth</kbd>, <kbd>crypto.sol</kbd>, each with its own velocity) and — because Binance quotes the euro itself — <b>EUR/USD live</b> (<kbd>fx.eurusd</kbd>, on a steady one-second pulse). The slower currencies arrive as <b>ECB daily reference rates</b>: <kbd>fx.gbpusd</kbd>, <kbd>fx.usdjpy</kbd>, <kbd>fx.audusd</kbd> — glacial, tectonic feeds like the national debt; drones, not melodies. And Bitcoin now speaks its own <b>technical analysis</b>, computed on five-second closes so it is stable no matter how fast the trades storm in: <kbd>crypto.macd</kbd> is the <b>MACD line</b> (fast EMA minus slow — momentum, z-scored so above-half means a bullish push), <kbd>crypto.rsi</kbd> is <b>Wilder's RSI(14)</b> (a natural mean-reverting wave: near 1 overbought, near 0 oversold — practically a market-driven LFO), and the <b>cross bells</b>: <kbd>crypto.cross_up</kbd> fires a single impulse the moment the fast average climbs over the slow (a golden cross in miniature) and <kbd>crypto.cross_down</kbd> when it falls through — patch them to a bell, a chord, the reverb throwing open, and the market's turning points become <i>events you hear happen</i>. Every one of these routes through the Spirits like any money: to the solar plexus, to Hod, to the dwarves in Nidavellir, to ᚠ fehu — <i>wealth is strife among kinsmen</i>.</p>
|
||||
|
||||
<p><b>market.velocity / market.turnover — your dealgod market.</b> Your own deal-flow, read from the basegod warehouse (<kbd>dealgod.price_events</kbd>, ~4M rows) over the tailnet — a single read-only aggregate query, no writes. Binned into an <i>activity</i> curve (velocity) and an <i>average-price</i> curve (turnover), replayed compressed and jog-scrubbable. Both are pre-normalized <kbd>0→1</kbd>. Character: a replay with a playhead, so it's scratchable like a record. <i>Your market drives the hats</i> (amt 0.7) and <i>nudges tempo</i> (amt 0.2), mirroring BTC; <i>pricier gear moving = more grit</i> (turnover → saturation, amt 0.5, exp); and <i>your market plays the lead</i> (turnover → lead.note, <b>G dorian</b>, root 55, 2 octaves). This is the one voice that is <i>yours</i> — your commerce, given a melody in a mode with a little melancholy built in.</p>
|
||||
|
||||
<div class="hr"></div>
|
||||
@ -1166,6 +1168,19 @@
|
||||
|
||||
// short "what it is" for the output voices/params (sources use their label)
|
||||
const DESC = {
|
||||
"crypto.eth": "ETH price, live trade ticks from Binance",
|
||||
"crypto.eth_vel": "ETH tick-to-tick velocity — ether's jitter",
|
||||
"crypto.sol": "SOL price, live trade ticks from Binance",
|
||||
"crypto.sol_vel": "SOL tick-to-tick velocity",
|
||||
"crypto.macd": "BTC momentum — fast EMA minus slow (the MACD line) on 5s closes; above the middle = bullish push",
|
||||
"crypto.rsi": "BTC RSI(14) on 5s closes — near 1 overbought, near 0 oversold; a natural mean-reverting wave",
|
||||
"crypto.cross_up": "golden cross — BTC's fast average just crossed above the slow. A bell rings for the bulls.",
|
||||
"crypto.cross_down": "death cross — the fast average just fell through the slow. The bears' bell.",
|
||||
"fx.eurusd": "EUR/USD, live from Binance's EURUSDT tap — the euro breathing in real time",
|
||||
"fx.eurusd_vel": "EUR/USD tick-to-tick velocity",
|
||||
"fx.gbpusd": "GBP/USD — cable, from the ECB daily fix (glacial: a drone, not a melody)",
|
||||
"fx.usdjpy": "USD/JPY — the yen, from the ECB daily fix",
|
||||
"fx.audusd": "AUD/USD — the aussie, from the ECB daily fix",
|
||||
"filter.cutoff": "master brightness — opens & closes the whole sound",
|
||||
"reverb.size": "the size of the space everything sits in",
|
||||
"delay.feedback": "echo — how much the sound repeats and smears",
|
||||
@ -2304,7 +2319,11 @@
|
||||
const s = msg.sources[k];
|
||||
const n = ensureSource(k, s);
|
||||
if (s.label) n.label = s.label;
|
||||
if (typeof s.raw === "number") n.raw = s.raw;
|
||||
if (typeof s.raw === "number") {
|
||||
n.raw = s.raw;
|
||||
const hh = n.hist || (n.hist = []); // a couple of minutes of memory, for the sparkline
|
||||
if (hh[hh.length - 1] !== s.raw || hh.length < 2) { hh.push(s.raw); if (hh.length > 120) hh.shift(); }
|
||||
}
|
||||
if (typeof s.norm === "number") n.norm = clamp(s.norm, 0, 1);
|
||||
if (s.event) n.isEvent = true;
|
||||
n.muted = !!s.muted;
|
||||
@ -2982,7 +3001,7 @@
|
||||
if (["wiki","net","news","social","edit","econ","debt"].includes(p) || // V · abstract thought & money-as-idea
|
||||
/inflation/.test(k) || k === "market.turnover" || k === "crypto.price") return "mental";
|
||||
if (/vel$|velocity/.test(k) || // VI · desire, fire, felt sensation
|
||||
["fire","crypto","market","light","ambient","audio","mic","me"].includes(p)) return "astral";
|
||||
["fire","crypto","fx","market","light","ambient","audio","mic","me"].includes(p)) return "astral";
|
||||
return "physical"; // VII · the dense body of the world
|
||||
}
|
||||
|
||||
@ -3029,7 +3048,7 @@
|
||||
if (k === "astro.mars" || p === "fire" || k === "quake.event" || k === "astro.tension" || k === "world.hunger") return "gevurah";
|
||||
if (/^sky\./.test(k)) return "tiferet"; // the solar heart
|
||||
if (/season|almanac\.(element|fertile)/.test(k)) return "netzach"; // Venus: nature's rhythms
|
||||
if (["wiki","market","crypto","planes","econ","me"].includes(p) || /retro/.test(k)) return "hod"; // Mercury: mind & number
|
||||
if (["wiki","market","crypto","fx","planes","econ","me"].includes(p) || /retro/.test(k)) return "hod"; // Mercury: mind & number
|
||||
if (/^astro\.moon|almanac\.dec|ambient|light\./.test(k)) return "yesod"; // Levanah: the astral machinery
|
||||
return "malkut"; // the Kingdom: body & world
|
||||
}
|
||||
@ -3065,7 +3084,7 @@
|
||||
if (p === "hand" || p === "tof" || p === "touch") // the candidate at the porch
|
||||
return /\.x$|open/.test(k) ? "boaz" : "jachin";
|
||||
if (/^sky\.|sun_lon|_lon|zodiac/.test(k) || k === "astro.saturn" || k === "astro.mars") return "east"; // the heavens, read from the East
|
||||
if (["crypto","market","econ","debt","me"].includes(p)) return "west"; // the wages, paid in the West
|
||||
if (["crypto","fx","market","econ","debt","me"].includes(p)) return "west"; // the wages, paid in the West
|
||||
if (p === "audio" || /season|almanac|lifeexp|births/.test(k)) return "south"; // Beauty holds music & the arts
|
||||
if (/deaths|hunger|poverty|quake|pm25|aqi/.test(k)) return "north"; // what waits in the dark
|
||||
if (k === "sun.speed" || k === "astro.harmony" || k === "almanac.fertile") return "star"; // Providence
|
||||
@ -3101,7 +3120,7 @@
|
||||
if (k === "astro.mars" || p === "fire" || /tension|quake\.event|deaths/.test(k)) return "mars";
|
||||
if (k === "sun.speed" || /^sky\./.test(k)) return "sun";
|
||||
if (/season|almanac\.element|centroid/.test(k)) return "venus";
|
||||
if (["wiki","market","crypto","planes","iss","me"].includes(p) || /retro/.test(k)) return "mercury";
|
||||
if (["wiki","market","crypto","fx","planes","iss","me"].includes(p) || /retro/.test(k)) return "mercury";
|
||||
return "moon"; // the sublunary world of flux
|
||||
}
|
||||
|
||||
@ -3134,7 +3153,7 @@
|
||||
if (/ambient|light\.|harmony/.test(k)) return "alfheim";
|
||||
if (p === "wiki" || p === "hand" || p === "planes" || /lifeexp|popvel/.test(k)) return "midgard";
|
||||
if (/wind|pressure|tension|quake\.event/.test(k)) return "jotunheim"; // the storm-giants
|
||||
if (["crypto","market","debt","econ","me"].includes(p)) return "nidavellir"; // the dwarves' gold & its curse
|
||||
if (["crypto","fx","market","debt","econ","me"].includes(p)) return "nidavellir"; // the dwarves' gold & its curse
|
||||
if (p === "fire" || k === "weather.temp") return "muspelheim";
|
||||
if (/pm25|aqi/.test(k)) return "niflheim"; // the poison-mists
|
||||
if (/deaths|hunger|poverty|quake\.depth/.test(k)) return "helheim";
|
||||
@ -3192,7 +3211,7 @@
|
||||
if (/births|popvel|season|fertile|lifeexp|wind|harmony|almanac\.element/.test(k)) return "wood";
|
||||
if (p === "fire" || /temp|mars|tension|ambient|light\.|sky\./.test(k) || k === "sun.speed") return "fire";
|
||||
if (/saturn|quake|hunger|poverty|pressure|debt\.total/.test(k)) return "earth";
|
||||
if (["market","planes","audio","me"].includes(p) || /inflation|crypto\.price|debt\.vel/.test(k)) return "metal"; // coin, bells, metal birds
|
||||
if (["market","fx","planes","audio","me"].includes(p) || /inflation|crypto\.price|debt\.vel/.test(k)) return "metal"; // coin, bells, metal birds
|
||||
return "water"; // the deep: moon, wisdom, the word
|
||||
}
|
||||
|
||||
@ -3231,7 +3250,7 @@
|
||||
if (/quake\.depth|debt\.total|saturn|poverty/.test(k)) return "foundation"; // the deep structure beneath
|
||||
if (/tension|quake\.event|pm25|aqi|inflation|hunger/.test(k)) return "crossing"; // what stands in the way
|
||||
if (/births|popvel|season|fertile|almanac/.test(k)) return "before"; // what approaches
|
||||
if (["crypto","market","fire","me"].includes(p) || k === "debt.vel") return "hopes"; // money & fire: hope and fear at once
|
||||
if (["crypto","fx","market","fire","me"].includes(p) || k === "debt.vel") return "hopes"; // money & fire: hope and fear at once
|
||||
if (p === "weather" || p === "planes" || p === "wiki") return "house"; // the world around you, talking
|
||||
if (/lifeexp|astro\.moon|harmony/.test(k)) return "outcome"; // the culminating light
|
||||
return "present"; // what covers you right now
|
||||
@ -3269,7 +3288,7 @@
|
||||
function zodiacOf(key) { // by traditional rulership/affinity
|
||||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||||
if (p === "fire") return "aries"; // Mars burns
|
||||
if (p === "me" || k === "market.turnover" || k === "crypto.price") return "taurus"; // holdings
|
||||
if (p === "me" || p === "fx" || k === "market.turnover" || k === "crypto.price") return "taurus"; // holdings & value: the second house
|
||||
if (p === "wiki" || /mercury_retro/.test(k)) return "gemini"; // the word; Mercury's own condition
|
||||
if (p === "weather" || k === "almanac.dec") return "cancer"; // the home sky
|
||||
if (p === "sun" || /^sky\.|light|ambient/.test(k)) return "leo"; // the light itself
|
||||
@ -3441,7 +3460,7 @@
|
||||
});
|
||||
function futharkOf(key) {
|
||||
const k = (key || "").toLowerCase(), p = k.split(".")[0];
|
||||
if (p === "me" || k === "crypto.price" || /inflation/.test(k)) return "fehu"; // "wealth is strife among kinsmen"
|
||||
if (p === "me" || p === "fx" || k === "crypto.price" || /inflation/.test(k)) return "fehu"; // "wealth is strife among kinsmen"
|
||||
if (p === "body" || k === "quake.depth") return "uruz"; // primal strength
|
||||
if (k === "quake.event") return "thurisaz"; // the giant's blow
|
||||
if (p === "wiki") return "ansuz"; // Odin's word
|
||||
@ -3501,7 +3520,7 @@
|
||||
if (/births|fertile/.test(k) || p === "natal") return "imbolc"; // first stirrings; your birth
|
||||
if (p === "fire" || ["hand", "tof", "touch", "body", "syn"].includes(p)) return "beltane"; // the fires, the dance, the union
|
||||
if (p === "sun" || /^sky\./.test(k)) return "litha"; // the sun at its height
|
||||
if (["wiki", "audio", "market", "me", "crypto", "planes", "iss"].includes(p)) return "lughnasadh"; // the great fair
|
||||
if (["wiki", "audio", "market", "me", "crypto", "fx", "planes", "iss"].includes(p)) return "lughnasadh"; // the great fair
|
||||
return "mabon"; // the reckoning of the year
|
||||
}
|
||||
function drawYearOverlay(t, g) { // the sun walking the year, live
|
||||
@ -3568,7 +3587,7 @@
|
||||
if (/harmony|fertile|births|light\.|ambient/.test(k)) return "white"; // silence, pregnant with possibility
|
||||
if (/deaths|debt|poverty/.test(k)) return "black"; // the silence with no future
|
||||
if (p === "body" || p === "fire" || k === "quake.event" || k === "astro.mars") return "red"; // heartbeat & flame: the drum
|
||||
if (["wiki", "net", "news", "social", "edit", "crypto", "market", "econ", "me"].includes(p) ||
|
||||
if (["wiki", "net", "news", "social", "edit", "crypto", "fx", "market", "econ", "me"].includes(p) ||
|
||||
/inflation/.test(k)) return "yellow"; // the trumpet, blown louder and louder
|
||||
if (p === "clock" || p === "audio" || /season|zodiac|hour|lifeexp/.test(k)) return "orange"; // the bell, rung on the hour
|
||||
if (["weather", "air", "planes", "plane"].includes(p) || /temp|wind|pressure|humid|rain|precip/.test(k)) return "green"; // nature at rest
|
||||
@ -3674,7 +3693,7 @@
|
||||
if (["wiki", "net", "news", "social", "edit", "audio"].includes(p) || k === "astro.harmony" || p === "syn") return "dui"; // the mouth and tongue; joy
|
||||
if (["weather", "air", "planes", "plane"].includes(p) || /wind|pressure|temp/.test(k)) return "xun"; // what rides the wind
|
||||
if (p === "iss" || p === "space" || p === "natal" || /^sky\.|_lon/.test(k) || p === "astro") return "qian"; // the heavens
|
||||
if (["crypto", "market", "econ", "me"].includes(p)) return "qian"; // "Qian is jade, is gold" (licence: the markets)
|
||||
if (["crypto", "fx", "market", "econ", "me"].includes(p)) return "qian"; // "Qian is jade, is gold" (licence: the markets)
|
||||
if (/births|fertile|poverty|hunger|popvel/.test(k) || p === "world" || p === "almanac") return "kun"; // the mother, the multitude, nourishment
|
||||
return "kun"; // the receptive earth holds the rest
|
||||
}
|
||||
@ -4425,6 +4444,14 @@
|
||||
const spacing = 46;
|
||||
let totalW = 0;
|
||||
const segs = [];
|
||||
for (const pk of tickerPins) { // the watchlist — your chosen numbers, live, ahead of the news
|
||||
const pn = sources.get(pk);
|
||||
if (!pn) continue;
|
||||
const label = "📌 " + (pn.label || pk) + " " + fmtVal(pn.raw);
|
||||
const w = ctx.measureText(label).width;
|
||||
segs.push({ label, w, col: pn.col });
|
||||
totalW += w + spacing;
|
||||
}
|
||||
for (const e of eventTicker) {
|
||||
const ago = Math.max(0, Math.floor(now() - e.tAdded));
|
||||
const label = e.text + " · " + ago + "s ago";
|
||||
@ -4708,7 +4735,21 @@
|
||||
const ctxEl = document.createElement("div");
|
||||
ctxEl.id = "ctxmenu";
|
||||
document.body.appendChild(ctxEl);
|
||||
function closeCtx() { ctxEl.classList.remove("open"); ctxEl.innerHTML = ""; }
|
||||
let ctxLive = null; // the open menu's live-readout timer
|
||||
function closeCtx() { ctxEl.classList.remove("open"); ctxEl.innerHTML = ""; if (ctxLive) { clearInterval(ctxLive); ctxLive = null; } }
|
||||
function fmtVal(v) { // raw numbers, human-sized
|
||||
if (typeof v !== "number" || !isFinite(v)) return "—";
|
||||
const a = Math.abs(v);
|
||||
if (a >= 1e12) return (v / 1e12).toFixed(2) + "T";
|
||||
if (a >= 1e9) return (v / 1e9).toFixed(2) + "B";
|
||||
if (a >= 1e6) return (v / 1e6).toFixed(2) + "M";
|
||||
if (a >= 1000) return Math.round(v).toLocaleString("en-US");
|
||||
if (a >= 10) return v.toFixed(1);
|
||||
return v.toFixed(3);
|
||||
}
|
||||
let tickerPins = new Set(); // your watchlist — live values riding the ticker
|
||||
try { tickerPins = new Set(JSON.parse(localStorage.getItem("gs_pins") || "[]")); } catch (e) {}
|
||||
function savePins() { try { localStorage.setItem("gs_pins", JSON.stringify([...tickerPins])); } catch (e) {} }
|
||||
window.addEventListener("mousedown", (e) => { if (!ctxEl.contains(e.target)) closeCtx(); });
|
||||
window.addEventListener("keydown", (e) => { if (e.key === "Escape") closeCtx(); });
|
||||
|
||||
@ -4742,6 +4783,7 @@
|
||||
|
||||
function openCtxMenu(node, px, py) {
|
||||
ctxEl.innerHTML = "";
|
||||
if (ctxLive) { clearInterval(ctxLive); ctxLive = null; }
|
||||
if (node) {
|
||||
const isSrc = node.kind === "source";
|
||||
const t0 = tw(node.key);
|
||||
@ -4751,6 +4793,40 @@
|
||||
sp.textContent = node.key + (isSrc ? " · " + activeSkin.stationOf(node.key) + " " + activeSkin.word : " · voice");
|
||||
hd.appendChild(b); hd.appendChild(sp); ctxEl.appendChild(hd);
|
||||
|
||||
if (isSrc) { // the actual data — live value + two minutes of shape
|
||||
const lv = document.createElement("div");
|
||||
lv.style.cssText = "color:#9fd0ff;font-size:11px;padding:2px 12px 0";
|
||||
const spark = document.createElement("canvas");
|
||||
spark.width = 220; spark.height = 30;
|
||||
spark.style.cssText = "display:block;margin:2px 12px 4px;width:220px;height:30px";
|
||||
const sc = spark.getContext("2d");
|
||||
const drawLive = () => {
|
||||
lv.textContent = "live: " + fmtVal(node.raw) +
|
||||
(typeof node.norm === "number" ? " · " + node.norm.toFixed(2) + " into the matrix" : "");
|
||||
const h = node.hist || [];
|
||||
sc.clearRect(0, 0, spark.width, spark.height);
|
||||
if (h.length > 1) {
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (const v of h) { if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||
const span = hi - lo || 1;
|
||||
sc.beginPath();
|
||||
for (let i = 0; i < h.length; i++)
|
||||
sc.lineTo(4 + (i / (h.length - 1)) * (spark.width - 8),
|
||||
spark.height - 4 - ((h[i] - lo) / span) * (spark.height - 8));
|
||||
sc.strokeStyle = rgba(node.col, 0.9); sc.lineWidth = 1.4; sc.stroke();
|
||||
sc.fillStyle = "rgba(150,170,200,0.6)"; sc.font = "8px 'Helvetica Neue', Arial, sans-serif";
|
||||
sc.textAlign = "left";
|
||||
sc.fillText(fmtVal(hi), 4, 8); sc.fillText(fmtVal(lo), 4, spark.height - 1);
|
||||
} else {
|
||||
sc.fillStyle = "rgba(150,170,200,0.4)"; sc.font = "9px 'Helvetica Neue', Arial, sans-serif";
|
||||
sc.fillText("gathering history…", 6, 18);
|
||||
}
|
||||
};
|
||||
drawLive();
|
||||
ctxLive = setInterval(drawLive, 400);
|
||||
ctxEl.appendChild(lv); ctxEl.appendChild(spark);
|
||||
}
|
||||
|
||||
ctxItem(node.muted ? "unmute" : "mute", () => toggleMuteLocal(node.key), "click");
|
||||
if (isSrc) ctxItem(soloKey === node.key ? "unsolo" : "solo",
|
||||
() => { soloKey = soloKey === node.key ? null : node.key; }, "⌥-click");
|
||||
@ -4797,6 +4873,8 @@
|
||||
if (isSrc) ctxItem(selection.has(node.key) ? "deselect (group)" : "select (group)",
|
||||
() => toggleSelect(node.key), "⌘-click");
|
||||
ctxItem("open inspector tab", () => openInspector(node), "⇧-click");
|
||||
if (isSrc) ctxItem(tickerPins.has(node.key) ? "📌 unpin from the ticker" : "📌 pin to the ticker",
|
||||
() => { if (tickerPins.has(node.key)) tickerPins.delete(node.key); else tickerPins.add(node.key); savePins(); });
|
||||
ctxItem("reset to neutral", () => resetTweaks(node.key), "⌘⇧-click");
|
||||
ctxItem("📽 project this node", () => openStage({ type: "node", key: node.key }));
|
||||
const mapSub = ctxSub("⌨ map a key to this");
|
||||
|
||||
@ -1,53 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""BTC/USD live trade ticks from Binance — the fastest, most chaotic source.
|
||||
Every sub-second trade fires the market's pulse; price-to-price velocity is raw
|
||||
volatility you can hear, driving rhythm and jitter in the modulation matrix.
|
||||
"""Live trade ticks from Binance — the fastest, most chaotic sources.
|
||||
|
||||
One websocket tap, four markets: BTC (the lead, legacy keys), ETH, SOL, and
|
||||
EUR/USD via the EURUSDT pair — a real FX rate riding the same firehose. Every
|
||||
trade fires price + tick-to-tick velocity.
|
||||
|
||||
And a trend kit on BTC, computed on 5-second closes so it is stable no matter
|
||||
how fast the trades arrive: the MACD line (fast EMA minus slow), Wilder's
|
||||
RSI(14), and the cross bells — /gs/crypto/cross_up rings 1.0 the moment the
|
||||
fast average climbs over the slow (a golden cross, in miniature), cross_down
|
||||
when it falls through. The market's mood, as playable signals.
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from pythonosc.udp_client import SimpleUDPClient
|
||||
|
||||
NAME = "world_crypto"
|
||||
WS_URL = "wss://stream.binance.com:9443/ws/btcusdt@trade"
|
||||
REST_URL = "https://api.binance.com/api/v3/ticker/price?symbol=BTCUSDT"
|
||||
SYMBOLS = { # binance symbol -> (price key, velocity key)
|
||||
"BTCUSDT": ("crypto/price", "crypto/vel"), # legacy keys: BTC is the lead
|
||||
"ETHUSDT": ("crypto/eth", "crypto/eth_vel"),
|
||||
"SOLUSDT": ("crypto/sol", "crypto/sol_vel"),
|
||||
"EURUSDT": ("fx/eurusd", "fx/eurusd_vel"), # the euro, live, off the same tap
|
||||
}
|
||||
# The cryptos ride raw trades; EURUSDT is thinly traded, so it rides the 1s
|
||||
# miniTicker instead — a steady pulse rather than sparse, minutes-apart ticks.
|
||||
STREAM_OF = {"EURUSDT": "@miniTicker"}
|
||||
WS_URL = ("wss://stream.binance.com:9443/stream?streams="
|
||||
+ "/".join(s.lower() + STREAM_OF.get(s, "@trade") for s in SYMBOLS))
|
||||
REST_URL = ("https://api.binance.com/api/v3/ticker/price?symbols="
|
||||
+ urllib.parse.quote(json.dumps(sorted(SYMBOLS), separators=(",", ":"))))
|
||||
POLL_S = 2.0 # REST fallback poll interval
|
||||
USER_AGENT = "godstrument/1.0 (+world_crypto)"
|
||||
TREND_SYMBOL = "BTCUSDT" # the trend kit listens to the lead
|
||||
|
||||
|
||||
def emit(client, last_price, price):
|
||||
"""Send price + tick-to-tick velocity. Returns the new last_price."""
|
||||
client.send_message("/gs/crypto/price", float(price))
|
||||
vel = 0.0 if last_price is None else abs(price - last_price)
|
||||
client.send_message("/gs/crypto/vel", float(vel))
|
||||
return price
|
||||
class TrendKit:
|
||||
"""MACD line, Wilder RSI and cross bells on sampled closes.
|
||||
|
||||
Tickless: feed it (t, price) whenever one arrives; it takes a close every
|
||||
`sample_s` seconds and returns {osc_key: value} to emit (empty between
|
||||
samples). EMAs seed on the first close; RSI stays quiet until it has
|
||||
rsi_n changes to chew on.
|
||||
"""
|
||||
|
||||
def __init__(self, fast=12, slow=60, rsi_n=14, sample_s=5.0):
|
||||
self.kf, self.ks = 2.0 / (fast + 1), 2.0 / (slow + 1)
|
||||
self.rsi_n, self.sample_s = rsi_n, sample_s
|
||||
self.ema_f = self.ema_s = None
|
||||
self.avg_gain = self.avg_loss = None
|
||||
self.changes = [] # pre-seed buffer for the RSI
|
||||
self.last_close = None
|
||||
self.next_t = None
|
||||
self.macd_sign = 0
|
||||
|
||||
def on_price(self, t, price):
|
||||
if self.next_t is None:
|
||||
self.next_t = t + self.sample_s
|
||||
if t < self.next_t:
|
||||
return {}
|
||||
self.next_t = t + self.sample_s
|
||||
return self._close(float(price))
|
||||
|
||||
def _close(self, c):
|
||||
out = {}
|
||||
# EMAs & the MACD line
|
||||
if self.ema_f is None:
|
||||
self.ema_f = self.ema_s = c
|
||||
else:
|
||||
self.ema_f += self.kf * (c - self.ema_f)
|
||||
self.ema_s += self.ks * (c - self.ema_s)
|
||||
macd = self.ema_f - self.ema_s
|
||||
out["crypto/macd"] = macd
|
||||
# the cross bells — ring on the sign flip, silent otherwise
|
||||
sign = 1 if macd > 0 else (-1 if macd < 0 else 0)
|
||||
out["crypto/cross_up"] = 1.0 if (sign > 0 and self.macd_sign < 0) else 0.0
|
||||
out["crypto/cross_down"] = 1.0 if (sign < 0 and self.macd_sign > 0) else 0.0
|
||||
if sign:
|
||||
self.macd_sign = sign
|
||||
# Wilder RSI
|
||||
if self.last_close is not None:
|
||||
ch = c - self.last_close
|
||||
gain, loss = max(ch, 0.0), max(-ch, 0.0)
|
||||
if self.avg_gain is None:
|
||||
self.changes.append((gain, loss))
|
||||
if len(self.changes) >= self.rsi_n:
|
||||
self.avg_gain = sum(g for g, _ in self.changes) / self.rsi_n
|
||||
self.avg_loss = sum(l for _, l in self.changes) / self.rsi_n
|
||||
else:
|
||||
a = 1.0 / self.rsi_n
|
||||
self.avg_gain += a * (gain - self.avg_gain)
|
||||
self.avg_loss += a * (loss - self.avg_loss)
|
||||
if self.avg_gain is not None:
|
||||
rs = self.avg_gain / self.avg_loss if self.avg_loss > 1e-12 else float("inf")
|
||||
out["crypto/rsi"] = 100.0 - 100.0 / (1.0 + rs)
|
||||
self.last_close = c
|
||||
return out
|
||||
|
||||
|
||||
async def run_websocket(client):
|
||||
"""Stream trades over the websocket. Returns True if it ever connected
|
||||
(so the caller keeps favoring the socket), False if it never connected
|
||||
(so the caller can fall back to REST)."""
|
||||
import websockets # part of the required stack; import here to keep top clean
|
||||
def make_emitter(client):
|
||||
"""Per-symbol price+velocity emit, plus the trend kit on the lead."""
|
||||
last = {}
|
||||
kit = TrendKit()
|
||||
|
||||
ever_connected = False
|
||||
last_price = None
|
||||
def emit(t, symbol, price):
|
||||
keys = SYMBOLS.get(symbol)
|
||||
if not keys:
|
||||
return
|
||||
client.send_message("/gs/" + keys[0], float(price))
|
||||
prev = last.get(symbol)
|
||||
client.send_message("/gs/" + keys[1], 0.0 if prev is None else abs(price - prev))
|
||||
last[symbol] = price
|
||||
if symbol == TREND_SYMBOL:
|
||||
for k, v in kit.on_price(t, price).items():
|
||||
client.send_message("/gs/" + k, float(v))
|
||||
return emit
|
||||
|
||||
|
||||
async def run_websocket(emit):
|
||||
"""Stream trades over the combined websocket."""
|
||||
import websockets
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
async with websockets.connect(WS_URL, ping_interval=20, ping_timeout=20) as ws:
|
||||
ever_connected = True
|
||||
async for raw in ws:
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
price = float(msg["p"])
|
||||
msg = json.loads(raw)["data"]
|
||||
symbol = msg["s"]
|
||||
price = float(msg["p"] if "p" in msg else msg["c"]) # trade / miniTicker close
|
||||
except (ValueError, KeyError, TypeError):
|
||||
continue # malformed / non-trade frame; skip
|
||||
last_price = emit(client, last_price, price)
|
||||
return ever_connected
|
||||
continue # malformed / unknown frame; skip
|
||||
emit(loop.time(), symbol, price)
|
||||
|
||||
|
||||
async def poll_rest(client):
|
||||
"""Fallback: poll the REST ticker forever at POLL_S. Never returns."""
|
||||
last_price = None
|
||||
async def poll_rest(emit):
|
||||
"""Fallback: poll the combined REST ticker forever at POLL_S."""
|
||||
loop = asyncio.get_running_loop()
|
||||
while True:
|
||||
try:
|
||||
@ -56,27 +146,53 @@ async def poll_rest(client):
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
data = await loop.run_in_executor(None, fetch)
|
||||
price = float(data["price"])
|
||||
last_price = emit(client, last_price, price)
|
||||
for row in await loop.run_in_executor(None, fetch):
|
||||
emit(loop.time(), row["symbol"], float(row["price"]))
|
||||
except Exception as e: # transient network / parse error — keep going
|
||||
print(f"[{NAME}] REST poll error: {e}; retrying")
|
||||
await asyncio.sleep(POLL_S)
|
||||
|
||||
|
||||
def selftest():
|
||||
"""The one runnable check: the trend kit's math, on synthetic prices."""
|
||||
kit = TrendKit(fast=3, slow=8, rsi_n=3, sample_s=1.0)
|
||||
t, out = 0.0, {}
|
||||
seen = {"up": 0, "down": 0}
|
||||
rsis = []
|
||||
for i in range(60): # rise then fall then rise
|
||||
price = 100 + (i if i < 20 else (40 - i if i < 40 else i - 40))
|
||||
t += 1.0
|
||||
out = kit.on_price(t + 0.5, price) # t+0.5 always past the 1s boundary
|
||||
seen["up"] += out.get("crypto/cross_up", 0) > 0
|
||||
seen["down"] += out.get("crypto/cross_down", 0) > 0
|
||||
if "crypto/rsi" in out:
|
||||
rsis.append(out["crypto/rsi"])
|
||||
assert "crypto/macd" in out, "macd should emit every close"
|
||||
assert seen["down"] >= 1 and seen["up"] >= 1, f"expected both crosses, saw {seen}"
|
||||
assert all(0.0 <= r <= 100.0 for r in rsis) and rsis, "rsi must stay in [0,100]"
|
||||
assert min(rsis) < 30 < 70 < max(rsis), "rsi should swing wide on a full reversal"
|
||||
assert kit.on_price(t + 1.2, 100.0) == {} , "between samples the kit is silent"
|
||||
print(f"[{NAME}] selftest ok — {len(rsis)} rsi samples, crosses {seen}")
|
||||
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="Binance BTCUSDT trade ticks -> OSC")
|
||||
parser = argparse.ArgumentParser(description="Binance trade ticks (+BTC trend kit) -> OSC")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=9000)
|
||||
parser.add_argument("--selftest", action="store_true", help="check the trend math and exit")
|
||||
args = parser.parse_args()
|
||||
if args.selftest:
|
||||
selftest()
|
||||
return
|
||||
|
||||
client = SimpleUDPClient(args.host, args.port)
|
||||
print(f"[{NAME}] emitting /gs/crypto/price,/gs/crypto/vel every <1s (ws) / {POLL_S:g}s (rest)")
|
||||
emit = make_emitter(SimpleUDPClient(args.host, args.port))
|
||||
print(f"[{NAME}] emitting {', '.join('/gs/' + k for k, _ in SYMBOLS.values())} "
|
||||
f"+ btc trend kit (macd/rsi/cross) every <1s (ws) / {POLL_S:g}s (rest)")
|
||||
|
||||
backoff = 1.0
|
||||
while True:
|
||||
try:
|
||||
await run_websocket(client)
|
||||
await run_websocket(emit)
|
||||
# Clean disconnect (stream ended): reconnect quickly.
|
||||
print(f"[{NAME}] websocket closed; reconnecting")
|
||||
backoff = 1.0
|
||||
@ -86,7 +202,7 @@ async def main():
|
||||
# the socket — never latch onto REST for the rest of the session.
|
||||
print(f"[{NAME}] websocket error: {e}; REST bridge {backoff:g}s, then retry")
|
||||
try:
|
||||
await asyncio.wait_for(poll_rest(client), timeout=backoff)
|
||||
await asyncio.wait_for(poll_rest(emit), timeout=backoff)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except Exception as e2:
|
||||
|
||||
71
workers/world_fx.py
Normal file
71
workers/world_fx.py
Normal file
@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env python3
|
||||
"""The great currencies as drones — ECB daily reference rates via frankfurter.app.
|
||||
|
||||
The ECB fixes its reference rates once per working day, so these feeds are
|
||||
glacial: not melodies but tectonics, like the national debt. The pound, the
|
||||
yen and the aussie drift by pips over hours — patch them somewhere slow (a
|
||||
drone's weight, a filter's resting point) and hear the currencies lean on
|
||||
each other. The euro is deliberately absent: fx.eurusd already ticks live
|
||||
off the Binance tap in world_crypto.py.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
from pythonosc.udp_client import SimpleUDPClient
|
||||
|
||||
NAME = "world_fx"
|
||||
URL = "https://api.frankfurter.app/latest?from=USD&to=JPY,GBP,AUD"
|
||||
POLL_S = 900.0 # the fix changes once a day; 15 min is plenty
|
||||
USER_AGENT = "godstrument/1.0 (+world_fx)"
|
||||
|
||||
|
||||
def fetch():
|
||||
req = urllib.request.Request(URL, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def emit(client, rates):
|
||||
"""USD-based rates -> the conventional market pairs."""
|
||||
out = {}
|
||||
if rates.get("JPY"):
|
||||
out["fx/usdjpy"] = float(rates["JPY"]) # yen per dollar, as quoted
|
||||
if rates.get("GBP"):
|
||||
out["fx/gbpusd"] = 1.0 / float(rates["GBP"]) # cable: dollars per pound
|
||||
if rates.get("AUD"):
|
||||
out["fx/audusd"] = 1.0 / float(rates["AUD"]) # dollars per aussie
|
||||
for k, v in out.items():
|
||||
client.send_message("/gs/" + k, v)
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="ECB daily FX fixes -> OSC")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=9000)
|
||||
parser.add_argument("--once", action="store_true", help="one fetch+emit, then exit (doubles as the self-check)")
|
||||
args = parser.parse_args()
|
||||
client = SimpleUDPClient(args.host, args.port)
|
||||
print(f"[{NAME}] emitting /gs/fx/usdjpy,/gs/fx/gbpusd,/gs/fx/audusd every {POLL_S:g}s")
|
||||
|
||||
while True:
|
||||
try:
|
||||
out = emit(client, fetch().get("rates", {}))
|
||||
assert all(v > 0 for v in out.values()), f"nonsense rates: {out}"
|
||||
print(f"[{NAME}] " + " ".join(f"{k.split('/')[1]} {v:.4f}" for k, v in out.items()))
|
||||
if args.once:
|
||||
return
|
||||
except Exception as e: # transient network / parse error — keep going
|
||||
print(f"[{NAME}] error: {e}; retrying in {POLL_S:g}s")
|
||||
if args.once:
|
||||
raise
|
||||
time.sleep(POLL_S)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Loading…
Reference in New Issue
Block a user