repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources15d ago| #1 | #!/usr/bin/env node |
| #2 | /** |
| #3 | * ooda/loop.ts — Dark Ralph OODA Loop Harness |
| #4 | * |
| #5 | * TypeScript port of the clawd-operator agent/loop.py. |
| #6 | * Paper-trading, devnet-only, stdlib-Node implementation of the |
| #7 | * "Dark Ralph" adaptation of Geoffrey Huntley's Ralph harness. |
| #8 | * |
| #9 | * Safety contract (all enforced in code): |
| #10 | * - mode: paper only (rejects anything else in RALPH.md) |
| #11 | * - network: devnet only |
| #12 | * - mainnet RPC URLs rejected at startup |
| #13 | * - no key handling anywhere in this file |
| #14 | * - position size capped per tick (from frontmatter) |
| #15 | * - one position at a time |
| #16 | * - kill-switch after N consecutive losses |
| #17 | * - every decision is journalled (append-only) |
| #18 | * |
| #19 | * Usage: |
| #20 | * npx tsx ooda/loop.ts --ticks 50 --sleep 0 |
| #21 | * npx tsx ooda/loop.ts --ticks 200 --sleep 0.4 --tui |
| #22 | * npx tsx ooda/loop.ts --ticks 100 --sleep 0.25 --llm |
| #23 | */ |
| #24 | |
| #25 | import { readFileSync } from 'node:fs'; |
| #26 | import { join, dirname } from 'node:path'; |
| #27 | import { fileURLToPath } from 'node:url'; |
| #28 | import { parseArgs } from 'node:util'; |
| #29 | |
| #30 | import { createState, openPosition, closePosition, unrealisedPnl } from './state.js'; |
| #31 | import type { State, Candle } from './state.js'; |
| #32 | import { SynthObserver, rejectMainnet } from './observe.js'; |
| #33 | import { validate, parseRalphConfig } from './validate.js'; |
| #34 | import type { RalphConfig, Decision } from './validate.js'; |
| #35 | import { appendTick, readLastEntries } from './journal.js'; |
| #36 | import type { TickEntry } from './journal.js'; |
| #37 | import { deterministicDecision, claudeDecision } from './claude-decision.js'; |
| #38 | import type { Observations } from './claude-decision.js'; |
| #39 | |
| #40 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| #41 | |
| #42 | // ─── CLI flags ──────────────────────────────────────────────────────────────── |
| #43 | |
| #44 | const { values: flags } = parseArgs({ |
| #45 | options: { |
| #46 | ticks: { type: 'string', default: '50' }, |
| #47 | sleep: { type: 'string', default: '0.25' }, |
| #48 | seed: { type: 'string', default: '42' }, |
| #49 | 'commit-every': { type: 'string', default: '0' }, |
| #50 | tui: { type: 'boolean', default: false }, |
| #51 | llm: { type: 'boolean', default: false }, |
| #52 | mode: { type: 'string', default: 'paper' }, |
| #53 | goblin: { type: 'boolean', default: false }, // 👺 GOBLIN MODE |
| #54 | 'perps-oi': { type: 'boolean', default: false }, |
| #55 | 'perps-symbol': { type: 'string', default: 'SOL-PERP' }, |
| #56 | 'perps-signal-mode': { type: 'string', default: 'paper' }, |
| #57 | 'perps-oi-mock': { type: 'boolean', default: false }, |
| #58 | }, |
| #59 | strict: false, |
| #60 | }); |
| #61 | |
| #62 | const GOBLIN_MODE = flags['goblin'] as boolean; |
| #63 | // Goblin mode: load goblin.md instead of RALPH.md, use tighter sleep, more ticks default |
| #64 | const RALPH_FILE = GOBLIN_MODE ? 'goblin.md' : 'RALPH.md'; |
| #65 | const TICKS = parseInt(flags['ticks'] as string, 10) || (GOBLIN_MODE ? 100 : 50); |
| #66 | const SLEEP_MS = GOBLIN_MODE ? 0 : Math.round(parseFloat(flags['sleep'] as string) * 1000); |
| #67 | const SEED = parseInt(flags['seed'] as string, 10); |
| #68 | const COMMIT_EVERY = parseInt(flags['commit-every'] as string, 10); |
| #69 | const TUI_MODE = flags['tui'] as boolean; |
| #70 | const USE_LLM = flags['llm'] as boolean || GOBLIN_MODE; // goblin always uses LLM when key available |
| #71 | const USE_PERPS_OI = flags['perps-oi'] as boolean; |
| #72 | const PERPS_SYMBOL = flags['perps-symbol'] as string; |
| #73 | const PERPS_SIGNAL_MODE = flags['perps-signal-mode'] as string; |
| #74 | const PERPS_OI_MOCK = flags['perps-oi-mock'] as boolean; |
| #75 | |
| #76 | // ─── Emit helpers ───────────────────────────────────────────────────────────── |
| #77 | |
| #78 | function emit(obj: unknown): void { |
| #79 | if (TUI_MODE) { |
| #80 | // Structured JSONL on stdout for tui.ts to consume |
| #81 | process.stdout.write(JSON.stringify(obj) + '\n'); |
| #82 | } |
| #83 | } |
| #84 | |
| #85 | function log(msg: string): void { |
| #86 | if (!TUI_MODE) process.stderr.write(msg + '\n'); |
| #87 | } |
| #88 | |
| #89 | // ─── Sleep ──────────────────────────────────────────────────────────────────── |
| #90 | |
| #91 | function sleep(ms: number): Promise<void> { |
| #92 | return new Promise(r => setTimeout(r, ms)); |
| #93 | } |
| #94 | |
| #95 | type OiSignal = { |
| #96 | symbol: string; |
| #97 | regime: string; |
| #98 | side: 'long' | 'short' | 'flat'; |
| #99 | score: number; |
| #100 | confidence: number; |
| #101 | market: { |
| #102 | markPrice: number; |
| #103 | openInterestUsd: number; |
| #104 | openInterestDeltaPct: number; |
| #105 | priceDeltaPct: number; |
| #106 | }; |
| #107 | gates: { executable: boolean; reason?: string }; |
| #108 | action: { stopReason?: string }; |
| #109 | }; |
| #110 | |
| #111 | function signalToDecision(signal: OiSignal, state: State): unknown { |
| #112 | const reason = `perps OI ${signal.regime} score=${Math.round(signal.score)} conf=${signal.confidence.toFixed(2)}`; |
| #113 | const current = state.book.positions[0]; |
| #114 | |
| #115 | if (!signal.gates.executable || signal.side === 'flat' || signal.confidence < 0.25) { |
| #116 | return { action: 'hold', reason: signal.action.stopReason || signal.gates.reason || reason }; |
| #117 | } |
| #118 | if (!current) { |
| #119 | return { |
| #120 | action: 'open', |
| #121 | side: signal.side, |
| #122 | size_lamports: 250_000, |
| #123 | reason, |
| #124 | }; |
| #125 | } |
| #126 | if (current.side !== signal.side && signal.confidence >= 0.4) { |
| #127 | return { |
| #128 | action: 'close', |
| #129 | position_id: current.id, |
| #130 | reason: `perps OI flipped ${current.side}->${signal.side}; ${reason}`, |
| #131 | }; |
| #132 | } |
| #133 | return { action: 'hold', reason }; |
| #134 | } |
| #135 | |
| #136 | async function readPerpsOiSignal(previous?: { |
| #137 | ts: number; |
| #138 | symbol: string; |
| #139 | markPrice: number; |
| #140 | openInterestUsd: number; |
| #141 | }): Promise<OiSignal | undefined> { |
| #142 | if (!USE_PERPS_OI) return undefined; |
| #143 | try { |
| #144 | const mod = await import('../perps/clawd-agents-perps/src/signals/oi-core.ts'); |
| #145 | return await mod.buildClawdOiCoreSignal({ |
| #146 | symbol: PERPS_SYMBOL, |
| #147 | previous, |
| #148 | mode: PERPS_SIGNAL_MODE, |
| #149 | mock: PERPS_OI_MOCK, |
| #150 | }); |
| #151 | } catch (error) { |
| #152 | log(`[perps-oi] unavailable: ${error instanceof Error ? error.message : String(error)}`); |
| #153 | return { |
| #154 | symbol: PERPS_SYMBOL, |
| #155 | regime: 'DATA_INVALID', |
| #156 | side: 'flat', |
| #157 | score: 0, |
| #158 | confidence: 0, |
| #159 | market: { markPrice: 0, openInterestUsd: 0, openInterestDeltaPct: 0, priceDeltaPct: 0 }, |
| #160 | gates: { executable: false, reason: 'perps-oi-unavailable' }, |
| #161 | action: { stopReason: 'perps-oi-unavailable' }, |
| #162 | }; |
| #163 | } |
| #164 | } |
| #165 | |
| #166 | // ─── Commit journal to git ──────────────────────────────────────────────────── |
| #167 | |
| #168 | async function commitJournal(tick: number): Promise<void> { |
| #169 | if (COMMIT_EVERY <= 0 || tick % COMMIT_EVERY !== 0) return; |
| #170 | const { execa } = await import('execa'); |
| #171 | try { |
| #172 | await execa('git', ['add', 'ooda/journal/ticks.jsonl'], { cwd: join(__dirname, '..') }); |
| #173 | await execa('git', ['commit', '-m', `ooda: journal tick ${tick}`], { cwd: join(__dirname, '..') }); |
| #174 | log(`[git] committed journal at tick ${tick}`); |
| #175 | } catch { /* git may not be available */ } |
| #176 | } |
| #177 | |
| #178 | // ─── Main loop ──────────────────────────────────────────────────────────────── |
| #179 | |
| #180 | async function runLoop(): Promise<void> { |
| #181 | // Read + validate RALPH.md (or goblin.md) config |
| #182 | const ralphPath = join(__dirname, RALPH_FILE); |
| #183 | const ralphContent = readFileSync(ralphPath, 'utf8'); |
| #184 | const config: RalphConfig = parseRalphConfig(ralphContent); |
| #185 | |
| #186 | if (GOBLIN_MODE) { |
| #187 | log(`\n👺 GOBLIN MODE ACTIVATED — clawd-operator harness`); |
| #188 | log(` https://github.com/x402agent/clawd-operator`); |
| #189 | log(` max_pos=${config.max_position_size_lamports} killswitch=${config.loss_killswitch_consecutive} dark_defi=armed\n`); |
| #190 | } else { |
| #191 | log(`[ralph] mode=${config.mode} network=${config.network}`); |
| #192 | log(`[ralph] max_pos=${config.max_position_size_lamports} killswitch=${config.loss_killswitch_consecutive}`); |
| #193 | } |
| #194 | |
| #195 | // Reject mainnet |
| #196 | const rpcUrl = process.env['SOLANA_RPC_URL'] ?? 'https://api.devnet.solana.com'; |
| #197 | rejectMainnet(rpcUrl); |
| #198 | |
| #199 | const state: State = createState(); |
| #200 | const observer = new SynthObserver(SEED, 150_000, 20); |
| #201 | let previousOiTick: { ts: number; symbol: string; markPrice: number; openInterestUsd: number } | undefined; |
| #202 | |
| #203 | log(`[ralph] starting ${TICKS} ticks, sleep=${SLEEP_MS}ms, llm=${USE_LLM}, goblin=${GOBLIN_MODE}, perps_oi=${USE_PERPS_OI}`); |
| #204 | if (TUI_MODE) { |
| #205 | emit({ event: 'start', ticks: TICKS, config, goblin: GOBLIN_MODE, perps_oi: USE_PERPS_OI, perps_symbol: PERPS_SYMBOL }); |
| #206 | } |
| #207 | |
| #208 | for (let tick = 1; tick <= TICKS; tick++) { |
| #209 | state.tick = tick; |
| #210 | const now = new Date(); |
| #211 | |
| #212 | // ── OBSERVE ────────────────────────────────────────────────────────────── |
| #213 | const candles = observer.tick(now); |
| #214 | const currentPrice = candles[candles.length - 1]!.c; |
| #215 | const lastDecisions = readLastEntries(3); |
| #216 | const perpsOiSignal = await readPerpsOiSignal(previousOiTick); |
| #217 | if (perpsOiSignal?.market.markPrice && perpsOiSignal.market.openInterestUsd) { |
| #218 | previousOiTick = { |
| #219 | ts: Date.now(), |
| #220 | symbol: perpsOiSignal.symbol, |
| #221 | markPrice: perpsOiSignal.market.markPrice, |
| #222 | openInterestUsd: perpsOiSignal.market.openInterestUsd, |
| #223 | }; |
| #224 | } |
| #225 | |
| #226 | const obs: Observations = { |
| #227 | tick, |
| #228 | now: now.toISOString(), |
| #229 | mode: 'paper', |
| #230 | network: 'devnet', |
| #231 | candles: candles.slice(-10), // send last 10 to model |
| #232 | perps_oi_signal: perpsOiSignal, |
| #233 | book: { |
| #234 | positions: state.book.positions, |
| #235 | cash_lamports: state.book.cash_lamports, |
| #236 | }, |
| #237 | last_decisions: lastDecisions, |
| #238 | }; |
| #239 | |
| #240 | // ── ORIENT / DECIDE ────────────────────────────────────────────────────── |
| #241 | let rawDecision: unknown; |
| #242 | try { |
| #243 | if (USE_LLM && process.env['ANTHROPIC_API_KEY']) { |
| #244 | rawDecision = await claudeDecision(obs); |
| #245 | } else if (perpsOiSignal) { |
| #246 | rawDecision = signalToDecision(perpsOiSignal, state); |
| #247 | } else { |
| #248 | rawDecision = deterministicDecision(obs); |
| #249 | } |
| #250 | } catch (err) { |
| #251 | rawDecision = { action: 'hold', reason: `decision error: ${String(err).slice(0, 100)}` }; |
| #252 | } |
| #253 | |
| #254 | // ── VALIDATE ───────────────────────────────────────────────────────────── |
| #255 | const validation = validate(rawDecision, config, state.book); |
| #256 | const decision: Decision = validation.decision; |
| #257 | |
| #258 | // ── ACT ─────────────────────────────────────────────────────────────────── |
| #259 | let outcome: TickEntry['outcome'] = 'applied'; |
| #260 | let pnl: number | undefined; |
| #261 | |
| #262 | if (!validation.ok) { |
| #263 | outcome = 'rejected'; |
| #264 | log(`[tick ${tick}] REJECTED: ${validation.violation}`); |
| #265 | } else if (decision.action === 'open') { |
| #266 | openPosition(state, decision.side, decision.size_lamports, currentPrice); |
| #267 | log(`[tick ${tick}] OPEN ${decision.side} ${decision.size_lamports} @ ${currentPrice}`); |
| #268 | } else if (decision.action === 'close') { |
| #269 | pnl = closePosition(state, decision.position_id, currentPrice); |
| #270 | log(`[tick ${tick}] CLOSE ${decision.position_id} pnl=${pnl}`); |
| #271 | } else { |
| #272 | log(`[tick ${tick}] HOLD — ${decision.reason}`); |
| #273 | } |
| #274 | |
| #275 | // ── Kill-switch ──────────────────────────────────────────────────────── |
| #276 | if (state.consecutive_losses >= config.loss_killswitch_consecutive) { |
| #277 | const killEntry: TickEntry = { |
| #278 | tick, |
| #279 | now: now.toISOString(), |
| #280 | candles_last3: candles.slice(-3), |
| #281 | book_snapshot: { ...state.book }, |
| #282 | decision, |
| #283 | outcome: 'killswitch', |
| #284 | event: `killswitch: ${state.consecutive_losses} consecutive losses`, |
| #285 | total_pnl_lamports: state.total_pnl_lamports, |
| #286 | consecutive_losses: state.consecutive_losses, |
| #287 | }; |
| #288 | appendTick(killEntry); |
| #289 | emit({ event: 'killswitch', tick, consecutive_losses: state.consecutive_losses, goblin: GOBLIN_MODE }); |
| #290 | if (GOBLIN_MODE) { |
| #291 | log(`\n👺 GOBLIN KILLSWITCH: ${state.consecutive_losses} consecutive losses — even goblins respect the laws\n`); |
| #292 | } else { |
| #293 | log(`[ralph] KILLSWITCH: ${state.consecutive_losses} consecutive losses — halting`); |
| #294 | } |
| #295 | process.exit(1); |
| #296 | } |
| #297 | |
| #298 | // ── Journal ─────────────────────────────────────────────────────────────── |
| #299 | const entry: TickEntry = { |
| #300 | tick, |
| #301 | now: now.toISOString(), |
| #302 | candles_last3: candles.slice(-3), |
| #303 | book_snapshot: { |
| #304 | positions: state.book.positions, |
| #305 | cash_lamports: state.book.cash_lamports, |
| #306 | unrealised_pnl: Math.round(unrealisedPnl(state, currentPrice)), |
| #307 | }, |
| #308 | decision, |
| #309 | outcome, |
| #310 | violation: validation.violation, |
| #311 | pnl_lamports: pnl, |
| #312 | total_pnl_lamports: state.total_pnl_lamports, |
| #313 | consecutive_losses: state.consecutive_losses, |
| #314 | }; |
| #315 | appendTick(entry); |
| #316 | |
| #317 | // ── TUI emit ────────────────────────────────────────────────────────────── |
| #318 | emit({ |
| #319 | event: 'tick', |
| #320 | tick, |
| #321 | now: now.toISOString(), |
| #322 | price: currentPrice, |
| #323 | decision, |
| #324 | outcome, |
| #325 | pnl, |
| #326 | total_pnl_lamports: state.total_pnl_lamports, |
| #327 | cash_lamports: state.book.cash_lamports, |
| #328 | positions: state.book.positions.length, |
| #329 | consecutive_losses: state.consecutive_losses, |
| #330 | perps_oi_signal: perpsOiSignal, |
| #331 | }); |
| #332 | |
| #333 | // ── Commit journal ───────────────────────────────────────────────────── |
| #334 | await commitJournal(tick); |
| #335 | |
| #336 | if (SLEEP_MS > 0) await sleep(SLEEP_MS); |
| #337 | } |
| #338 | |
| #339 | // ── Final summary ────────────────────────────────────────────────────────── |
| #340 | const summary = { |
| #341 | event: 'done', |
| #342 | ticks: TICKS, |
| #343 | total_pnl_lamports: state.total_pnl_lamports, |
| #344 | total_trades: state.total_trades, |
| #345 | final_cash_lamports: state.book.cash_lamports, |
| #346 | open_positions: state.book.positions.length, |
| #347 | consecutive_losses: state.consecutive_losses, |
| #348 | }; |
| #349 | emit(summary); |
| #350 | if (GOBLIN_MODE) { |
| #351 | log(`\n👺 GOBLIN DONE. pnl=${state.total_pnl_lamports} trades=${state.total_trades} cash=${state.book.cash_lamports}`); |
| #352 | log(` The goblin rests. The laws held. The paper gains are real in spirit.\n`); |
| #353 | } else { |
| #354 | log(`\n[ralph] done. pnl=${state.total_pnl_lamports} trades=${state.total_trades} cash=${state.book.cash_lamports}`); |
| #355 | } |
| #356 | } |
| #357 | |
| #358 | runLoop().catch(err => { |
| #359 | process.stderr.write(`[ralph] fatal: ${String(err)}\n`); |
| #360 | process.exit(1); |
| #361 | }); |
| #362 |