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 | /** |
| #2 | * ooda/claude-decision.ts — Claude as the OODA decision function |
| #3 | * |
| #4 | * This is the LLM-in-the-loop adapter described in the Ralph README: |
| #5 | * "You can pass any Callable[[State, dict], dict] to run_loop to swap in |
| #6 | * a model call. Whatever you pass must still produce a decision that |
| #7 | * passes validate_decision." |
| #8 | * |
| #9 | * Design: Fresh context per tick. No conversation history. No memory. |
| #10 | * The per-tick prompt (RALPH.md) + observations → one JSON decision. |
| #11 | * |
| #12 | * Sponsor: Anthropic / Claude API |
| #13 | */ |
| #14 | |
| #15 | import { readFileSync } from 'node:fs'; |
| #16 | import { join, dirname } from 'node:path'; |
| #17 | import { fileURLToPath } from 'node:url'; |
| #18 | import type { State, Candle } from './state.js'; |
| #19 | import type { TickEntry } from './journal.js'; |
| #20 | |
| #21 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| #22 | const RALPH_PATH = join(__dirname, 'RALPH.md'); |
| #23 | |
| #24 | let _client: unknown | null = null; |
| #25 | |
| #26 | async function getClient(): Promise<any> { |
| #27 | if (!_client) { |
| #28 | const { default: Anthropic } = await import('@anthropic-ai/sdk'); |
| #29 | _client = new Anthropic({ apiKey: process.env['ANTHROPIC_API_KEY'] }); |
| #30 | } |
| #31 | return _client; |
| #32 | } |
| #33 | |
| #34 | export interface Observations { |
| #35 | tick: number; |
| #36 | now: string; |
| #37 | mode: 'paper'; |
| #38 | network: 'devnet'; |
| #39 | candles: Candle[]; |
| #40 | perps_oi_signal?: unknown; |
| #41 | book: { positions: unknown[]; cash_lamports: number }; |
| #42 | last_decisions: TickEntry[]; |
| #43 | } |
| #44 | |
| #45 | /** |
| #46 | * Build the per-tick prompt by injecting observations into RALPH.md. |
| #47 | * This mirrors the Python harness: read the file fresh each tick so |
| #48 | * any in-flight edits to RALPH.md take effect immediately. |
| #49 | */ |
| #50 | export function buildPrompt(obs: Observations): string { |
| #51 | const ralph = readFileSync(RALPH_PATH, 'utf8'); |
| #52 | const obsBlock = `\`\`\`json\n${JSON.stringify(obs, null, 2)}\n\`\`\``; |
| #53 | return ralph.replace( |
| #54 | '<!-- harness will inject the observations JSON here, then invoke you -->', |
| #55 | obsBlock, |
| #56 | ); |
| #57 | } |
| #58 | |
| #59 | /** |
| #60 | * Call Claude once with the fresh per-tick prompt. |
| #61 | * Returns the raw parsed JSON (validation happens in validate.ts). |
| #62 | * |
| #63 | * Model choice: claude-haiku-4-5 — fast, cheap, sufficient for a |
| #64 | * one-JSON-object decision. Override with OODA_MODEL env var. |
| #65 | */ |
| #66 | export async function claudeDecision(obs: Observations): Promise<unknown> { |
| #67 | const client = await getClient(); |
| #68 | const model = process.env['OODA_MODEL'] ?? 'claude-haiku-4-5-20251001'; |
| #69 | const prompt = buildPrompt(obs); |
| #70 | |
| #71 | const msg = await client.messages.create({ |
| #72 | model, |
| #73 | max_tokens: 256, |
| #74 | system: [ |
| #75 | 'You are a single tick of an OODA trading loop.', |
| #76 | 'You MUST respond with ONLY a single JSON object matching one of the three shapes.', |
| #77 | 'No markdown. No explanation. No preamble. Just the JSON object.', |
| #78 | 'If uncertain, return {"action":"hold","reason":"<one sentence>"}.', |
| #79 | ].join(' '), |
| #80 | messages: [{ role: 'user', content: prompt }], |
| #81 | }); |
| #82 | |
| #83 | const text = msg.content |
| #84 | .filter(b => b.type === 'text') |
| #85 | .map(b => (b as { type: 'text'; text: string }).text) |
| #86 | .join(''); |
| #87 | |
| #88 | // Strip any accidental markdown fences |
| #89 | const cleaned = text.replace(/```(?:json)?\s*/g, '').replace(/```/g, '').trim(); |
| #90 | |
| #91 | // Extract the first JSON object in the response |
| #92 | const match = cleaned.match(/\{[\s\S]*\}/); |
| #93 | if (!match) throw new Error(`Claude returned no JSON object: ${text.slice(0, 200)}`); |
| #94 | |
| #95 | return JSON.parse(match[0]); |
| #96 | } |
| #97 | |
| #98 | /** |
| #99 | * Deterministic fallback decision_fn (no API key needed). |
| #100 | * Implements the v0 momentum rule from RALPH.md exactly. |
| #101 | * Use this for testing the harness mechanics without an API key. |
| #102 | */ |
| #103 | export function deterministicDecision(obs: Observations): unknown { |
| #104 | const { candles, book } = obs; |
| #105 | if (candles.length < 3) return { action: 'hold', reason: 'fewer than 3 candles — insufficient data' }; |
| #106 | |
| #107 | const last3 = candles.slice(-3); |
| #108 | const closes = last3.map(c => c.c); |
| #109 | const rising = closes[1]! > closes[0]! && closes[2]! > closes[1]!; |
| #110 | const falling = closes[1]! < closes[0]! && closes[2]! < closes[1]!; |
| #111 | |
| #112 | if (book.positions.length === 0) { |
| #113 | if (rising) return { |
| #114 | action: 'open', |
| #115 | side: 'long', |
| #116 | size_lamports: 250_000, |
| #117 | reason: '3 consecutive rising closes — opening long at 0.25x cap', |
| #118 | }; |
| #119 | if (falling) return { |
| #120 | action: 'open', |
| #121 | side: 'short', |
| #122 | size_lamports: 250_000, |
| #123 | reason: '3 consecutive falling closes — opening short at 0.25x cap', |
| #124 | }; |
| #125 | return { action: 'hold', reason: 'no clear momentum signal' }; |
| #126 | } |
| #127 | |
| #128 | // Check reversal (2 consecutive bars against position) |
| #129 | const pos = book.positions[0] as { side: string; entry_price: number }; |
| #130 | const lastClose = closes[2]!; |
| #131 | const prevClose = closes[1]!; |
| #132 | const prevPrevClose = closes[0]!; |
| #133 | |
| #134 | if (pos.side === 'long') { |
| #135 | if (lastClose < prevClose && prevClose < prevPrevClose) { |
| #136 | return { action: 'close', position_id: (book.positions[0] as {id: string}).id, reason: '2 bars down against long — closing position' }; |
| #137 | } |
| #138 | } else { |
| #139 | if (lastClose > prevClose && prevClose > prevPrevClose) { |
| #140 | return { action: 'close', position_id: (book.positions[0] as {id: string}).id, reason: '2 bars up against short — closing position' }; |
| #141 | } |
| #142 | } |
| #143 | |
| #144 | return { action: 'hold', reason: 'position open, no reversal signal' }; |
| #145 | } |
| #146 |