robot_lab-durable 0.2.1 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.envrc +2 -0
- data/.loki +10 -0
- data/.quality/reek_baseline.txt +0 -0
- data/.rubocop.yml +1 -173
- data/CHANGELOG.md +42 -0
- data/CLAUDE.md +65 -0
- data/README.md +65 -30
- data/Rakefile +47 -31
- data/docs/document_store_backend_design.md +4 -37
- data/docs/index.md +230 -25
- data/examples/01_day_trader.rb +92 -0
- data/examples/{33_stock_generator.rb → day_trader_lib/generator.rb} +6 -4
- data/examples/{33_stock_predictor.rb → day_trader_lib/predictor.rb} +92 -73
- data/lib/robot_lab/durable/adapter.rb +42 -0
- data/lib/robot_lab/durable/entry.rb +8 -39
- data/lib/robot_lab/durable/hook.rb +48 -0
- data/lib/robot_lab/durable/version.rb +1 -1
- data/lib/robot_lab/durable.rb +6 -8
- data/lib/robot_lab/recall_knowledge.rb +6 -10
- data/lib/robot_lab/record_knowledge.rb +7 -17
- metadata +29 -12
- data/lib/robot_lab/durable/learning.rb +0 -39
- data/lib/robot_lab/durable/reflector.rb +0 -47
- data/lib/robot_lab/durable/store.rb +0 -120
data/docs/index.md
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
|
-
# robot_lab-durable
|
|
1
|
+
# robot_lab-durable — Reference Documentation
|
|
2
2
|
|
|
3
|
-
Cross-session durable
|
|
3
|
+
Cross-session durable memory for the [RobotLab](https://github.com/MadBomber/robot_lab) LLM agent framework, backed by the [htm gem](https://madbomber.github.io/htm) (PostgreSQL + pgvector).
|
|
4
4
|
|
|
5
5
|
> [!CAUTION]
|
|
6
6
|
> This gem is under active development. APIs may change without notice.
|
|
7
7
|
|
|
8
8
|
## What it provides
|
|
9
9
|
|
|
10
|
-
- **`Durable::
|
|
11
|
-
- **`Durable::
|
|
12
|
-
- **`Durable::
|
|
13
|
-
- **`
|
|
14
|
-
- **`
|
|
15
|
-
- **`RecordKnowledge`** tool — lets robots write new knowledge during a session
|
|
10
|
+
- **`Durable::Adapter`** — thin wrapper around an `HTM` instance; scoped to a robot by name. Exposes `record` and `recall` methods plus direct `htm` access.
|
|
11
|
+
- **`Durable::Entry`** — presenter over raw HTM node hashes returned by recall. Provides typed fields.
|
|
12
|
+
- **`Durable::Hook`** — RobotLab hook handler that wires the `Adapter` lifecycle into `around_run` and `on_learn` automatically.
|
|
13
|
+
- **`RecallKnowledge`** tool — lets a robot query its durable memory before answering.
|
|
14
|
+
- **`RecordKnowledge`** tool — lets a robot persist new knowledge during a session.
|
|
16
15
|
|
|
17
|
-
##
|
|
16
|
+
## Prerequisites
|
|
17
|
+
|
|
18
|
+
- PostgreSQL with the pgvector extension enabled.
|
|
19
|
+
- The `htm` gem — see documentation at https://madbomber.github.io/htm.
|
|
20
|
+
- The `robot_lab` gem.
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
## Installation
|
|
20
23
|
|
|
21
24
|
```ruby
|
|
22
25
|
gem "robot_lab"
|
|
@@ -30,33 +33,235 @@ require "robot_lab"
|
|
|
30
33
|
require "robot_lab/durable"
|
|
31
34
|
|
|
32
35
|
robot = RobotLab.build(
|
|
33
|
-
name:
|
|
34
|
-
system_prompt: "You are a financial advisor
|
|
35
|
-
|
|
36
|
-
learn_domain: "finance"
|
|
36
|
+
name: "advisor",
|
|
37
|
+
system_prompt: "You are a financial advisor.",
|
|
38
|
+
local_tools: [RobotLab::RecallKnowledge, RobotLab::RecordKnowledge]
|
|
37
39
|
)
|
|
40
|
+
robot.on(RobotLab::Durable::Hook)
|
|
38
41
|
|
|
39
|
-
# RecallKnowledge and RecordKnowledge tools are automatically available.
|
|
40
|
-
# At the end of each run, the Reflector promotes learned facts to
|
|
41
|
-
# ~/.robot_lab/durable/finance.yml for use in future sessions.
|
|
42
42
|
result = robot.run("What do you know about my risk tolerance?")
|
|
43
43
|
puts result.last_text_content
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
No `domain:` argument is required. HTM scopes all storage to the robot's `name` automatically.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Adapter API
|
|
51
|
+
|
|
52
|
+
`Durable::Adapter` is initialised by `Hook#around_run` for each run and stored as a thread-local. It wraps `HTM.new(robot_name: name)`.
|
|
53
|
+
|
|
54
|
+
| Method | Arguments | Returns | Notes |
|
|
55
|
+
|--------|-----------|---------|-------|
|
|
56
|
+
| `record` | `content:` (String, required), `reasoning:` (String, optional), `category:` (String/Symbol, default `'fact'`) | Integer (HTM node_id) | Calls `htm.remember(content, metadata: { reasoning:, category: })`. HTM deduplicates by SHA-256 content hash — safe to call with the same content multiple times. |
|
|
57
|
+
| `recall` | `query:` (String, required), `limit:` (Integer, default 20), `strategy:` (Symbol, default `:hybrid`) | `Array<Entry>` | Calls `htm.recall(query, limit:, strategy:, raw: true)` then wraps each result in `Entry.from_node`. |
|
|
58
|
+
| `htm` | — | `HTM` instance | Direct access to the underlying HTM object for advanced operations. |
|
|
59
|
+
|
|
60
|
+
### Accessing the current adapter
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
adapter = RobotLab::Durable::Hook.current_adapter
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Returns the `Adapter` for the current thread, or `nil` when called outside an active `around_run`. Useful for direct access from custom tools or middleware.
|
|
67
|
+
|
|
68
|
+
### Direct HTM access
|
|
69
|
+
|
|
70
|
+
```ruby
|
|
71
|
+
adapter = RobotLab::Durable::Hook.current_adapter
|
|
72
|
+
raw_nodes = adapter.htm.recall("risk tolerance", strategy: :vector, raw: true)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use `adapter.htm` when you need HTM operations not exposed through `Adapter`, such as bulk deletion or direct node inspection.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Entry Fields
|
|
80
|
+
|
|
81
|
+
`Durable::Entry` is constructed via `Entry.from_node(node)` from a raw HTM node hash. HTM nodes use string keys (not symbols), so `from_node` reads `node['id']`, not `node[:id]`.
|
|
82
|
+
|
|
83
|
+
| Field | Type | Description | Source in HTM node |
|
|
84
|
+
|-------|------|-------------|--------------------|
|
|
85
|
+
| `node_id` | Integer | Unique identifier for the stored node | `node['id']` |
|
|
86
|
+
| `content` | String | The stored knowledge text | `node['content']` |
|
|
87
|
+
| `reasoning` | String / nil | Optional rationale recorded at write time | `node['metadata']['reasoning']` |
|
|
88
|
+
| `category` | Symbol | Category of knowledge (e.g. `:fact`, `:observation`) | `(node['metadata']['category'] || 'fact').to_sym` |
|
|
89
|
+
| `created_at` | String | Raw timestamp as returned by HTM (e.g. `'2026-05-06T12:00:00Z'`) — not parsed into a `Time` object | `node['created_at']` |
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Hook Lifecycle
|
|
94
|
+
|
|
95
|
+
The `Durable::Hook` handler implements two lifecycle callbacks.
|
|
96
|
+
|
|
97
|
+
### around_run
|
|
98
|
+
|
|
99
|
+
1. Reads `ctx.robot.name` from the run context.
|
|
100
|
+
2. Instantiates `Durable::Adapter.new(robot_name: name)`, which in turn creates `HTM.new(robot_name: name)`.
|
|
101
|
+
3. Stores the adapter as a thread-local so tools can reach it via `Hook.current_adapter`.
|
|
102
|
+
4. Yields to execute the actual robot run.
|
|
103
|
+
5. In `ensure`, clears the thread-local adapter so state never leaks to subsequent runs on the same thread.
|
|
104
|
+
|
|
105
|
+
### on_learn
|
|
106
|
+
|
|
107
|
+
Fires after each call to `robot.learn(text)`, but only persists when both of these hold:
|
|
108
|
+
|
|
109
|
+
1. `ctx.stored` is true — `robot.learn` sets this only when the text was actually added to the robot's in-memory `@learnings` list. If `text` is a substring of an existing learning (or vice versa, see `robot.learn`'s dedup rule in `robot_lab` core), `ctx.stored` stays `false` and `on_learn` returns early without recording.
|
|
110
|
+
2. A durable session is active (`Hook.current_adapter` is non-nil).
|
|
111
|
+
|
|
112
|
+
When both hold, calls:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
adapter.record(content: ctx.text, category: :observation)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
This persists the learning immediately. HTM's content-hash deduplication ensures that re-seeding the same fact across sessions does not create duplicate entries.
|
|
119
|
+
|
|
120
|
+
### Enabling the hook
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
robot.on(RobotLab::Durable::Hook)
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
No additional context keys are required. HTM scopes storage to the robot name automatically.
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Tools
|
|
131
|
+
|
|
132
|
+
### RecallKnowledge
|
|
133
|
+
|
|
134
|
+
Allows the LLM to query the robot's durable memory store.
|
|
135
|
+
|
|
136
|
+
| Parameter | Type | Required | Description |
|
|
137
|
+
|-----------|------|----------|-------------|
|
|
138
|
+
| `query` | String | yes | Natural-language description of the decision you are about to make |
|
|
139
|
+
|
|
140
|
+
`limit` and `strategy` are not exposed as tool parameters — the tool always calls `adapter.recall` with its defaults (`limit: 20`, `strategy: :hybrid`). Use `Hook.current_adapter` directly (see [Adapter API](#adapter-api)) if you need to control those.
|
|
141
|
+
|
|
142
|
+
Returns formatted text entries (`"[category] content — reasoning"`, one per line, prefixed with `"Relevant past knowledge:"`) when matches are found. Returns `"No relevant past knowledge found for: <query>. When in doubt, skip."` when the recall is empty, or `"No durable session active on this robot."` when no `Hook.current_adapter` is active (e.g. the hook wasn't registered with `robot.on`).
|
|
143
|
+
|
|
144
|
+
Implementation calls:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
Hook.current_adapter.recall(query: query)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### RecordKnowledge
|
|
151
|
+
|
|
152
|
+
Allows the LLM to persist new knowledge during a session.
|
|
153
|
+
|
|
154
|
+
| Parameter | Type | Required | Description |
|
|
155
|
+
|-----------|------|----------|-------------|
|
|
156
|
+
| `content` | String | yes | The knowledge text to store |
|
|
157
|
+
| `reasoning` | String | yes | Rationale or context for why this is being stored |
|
|
158
|
+
| `category` | String | yes | One of: fact, preference, pattern, correction |
|
|
159
|
+
|
|
160
|
+
All three are required — neither `param` (which defaults to `required: true`) nor the tool's `execute(content:, reasoning:, category:)` signature declares a default. (The `'fact'` default described in the [Adapter API](#adapter-api) applies only when calling `Adapter#record` directly, not through this tool.)
|
|
161
|
+
|
|
162
|
+
Returns `"Recorded: <content>"` on success, or `"No durable session active on this robot."` when no `Hook.current_adapter` is active.
|
|
163
|
+
|
|
164
|
+
Implementation calls:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
adapter.record(content: content, reasoning: reasoning, category: category)
|
|
168
|
+
robot.learn(content)
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`robot.learn` updates the current session context so the LLM has immediate access to the new knowledge without waiting for the next run. Because HTM deduplicates by content hash, the subsequent `on_learn` callback does not produce a duplicate entry.
|
|
172
|
+
|
|
173
|
+
---
|
|
174
|
+
|
|
175
|
+
## HTM Recall Strategies
|
|
176
|
+
|
|
177
|
+
| Strategy | Mechanism | Best for |
|
|
178
|
+
|----------|-----------|----------|
|
|
179
|
+
| `:hybrid` | Combines vector similarity and full-text scoring | General use — recommended default |
|
|
180
|
+
| `:vector` | Embedding-based cosine similarity (pgvector) | Semantic / conceptual queries where exact keywords are unknown |
|
|
181
|
+
| `:fulltext` | PostgreSQL full-text search (tsvector / tsquery) | Keyword lookup, proper nouns, technical identifiers |
|
|
182
|
+
|
|
183
|
+
---
|
|
184
|
+
|
|
185
|
+
## Day Trader Demo
|
|
186
|
+
|
|
187
|
+
`examples/01_day_trader.rb` demonstrates cross-session durable memory in a quantitative trading context.
|
|
188
|
+
|
|
189
|
+
It spawns two subprocesses:
|
|
190
|
+
|
|
191
|
+
- **`[GEN ]`** — GBM (Geometric Brownian Motion) stock price generator.
|
|
192
|
+
- **`[PRED]`** — SMA+EMA ensemble predictor that issues directional forecasts.
|
|
193
|
+
|
|
194
|
+
Each prediction window receives a GOOD / MISS verdict printed to the terminal. The durable agent accumulates parameter tuning knowledge across sessions: window sizes, smoothing factors, and signal thresholds that worked well in past runs are recalled and applied, improving prediction accuracy over time.
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
ruby examples/01_day_trader.rb
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Migration Guide — v0.2.x to v0.3.0
|
|
203
|
+
|
|
204
|
+
v0.3.0 replaces the YAML file store, the `Durable::Store` class, the `Durable::Reflector`, the `Durable::Learning` mixin, and the `skip_persist` mechanism with a direct adapter over the `htm` gem. All domain/confidence concepts are gone.
|
|
205
|
+
|
|
206
|
+
### Gemfile
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
# Before
|
|
210
|
+
gem "robot_lab-durable"
|
|
211
|
+
|
|
212
|
+
# After — htm gem is required; ensure PostgreSQL + pgvector are available
|
|
213
|
+
gem "robot_lab-durable"
|
|
214
|
+
gem "htm"
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### Registering the hook
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
# Before (v0.2.x)
|
|
221
|
+
robot.on(RobotLab::Durable::Hook, context: { domain: "finance" })
|
|
47
222
|
|
|
223
|
+
# After (v0.3.0) — no domain argument; scoped by robot name automatically
|
|
224
|
+
robot.on(RobotLab::Durable::Hook)
|
|
48
225
|
```
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
226
|
+
|
|
227
|
+
### Building a robot
|
|
228
|
+
|
|
229
|
+
```ruby
|
|
230
|
+
# Before (v0.2.x)
|
|
231
|
+
robot = RobotLab.build(
|
|
232
|
+
name: "advisor",
|
|
233
|
+
system_prompt: "...",
|
|
234
|
+
learn: true,
|
|
235
|
+
learn_domain: "finance"
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
# After (v0.3.0) — learn: / learn_domain: removed; use tools + hook instead
|
|
239
|
+
robot = RobotLab.build(
|
|
240
|
+
name: "advisor",
|
|
241
|
+
system_prompt: "...",
|
|
242
|
+
local_tools: [RobotLab::RecallKnowledge, RobotLab::RecordKnowledge]
|
|
243
|
+
)
|
|
244
|
+
robot.on(RobotLab::Durable::Hook)
|
|
53
245
|
```
|
|
54
246
|
|
|
55
|
-
|
|
247
|
+
### Storage location
|
|
248
|
+
|
|
249
|
+
Before v0.3.0, knowledge was stored as YAML files under `~/.robot_lab/durable/`. In v0.3.0, all knowledge is stored in PostgreSQL via HTM. Existing YAML data is not migrated automatically — if you need to preserve past learnings, insert them into HTM using `adapter.record` before removing the old files.
|
|
250
|
+
|
|
251
|
+
### Removed classes
|
|
252
|
+
|
|
253
|
+
The following classes no longer exist and should be removed from any code that references them:
|
|
254
|
+
|
|
255
|
+
- `Durable::Store`
|
|
256
|
+
- `Durable::Reflector`
|
|
257
|
+
- `Durable::Learning` (mixin)
|
|
258
|
+
- `Hook.skip_persist` block helper
|
|
259
|
+
|
|
260
|
+
---
|
|
56
261
|
|
|
57
262
|
## Links
|
|
58
263
|
|
|
59
|
-
- [Implementation Plan](superpowers/plans/2026-05-06-durable-learning.md)
|
|
60
|
-
- [Design Spec](superpowers/specs/2026-05-06-durable-learning-design.md)
|
|
61
264
|
- [RobotLab Core](https://github.com/MadBomber/robot_lab)
|
|
265
|
+
- [HTM gem docs](https://madbomber.github.io/htm)
|
|
62
266
|
- [RubyGems](https://rubygems.org/gems/robot_lab-durable)
|
|
267
|
+
- [GitHub](https://github.com/MadBomber/robot_lab-durable)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Day Trader: subprocess launcher with interleaved prefixed output
|
|
5
|
+
#
|
|
6
|
+
# Spawns the generator and predictor as child processes, reads their
|
|
7
|
+
# stdout in background threads, and prints every line with a color-coded
|
|
8
|
+
# prefix so both streams are visible in a single terminal.
|
|
9
|
+
#
|
|
10
|
+
# [GEN ] lines — amber — price ticks from the XYZZY generator
|
|
11
|
+
# [PRED] lines — cyan — predictions, window results, and GOOD/MISS verdicts
|
|
12
|
+
#
|
|
13
|
+
# Ctrl-C forwards SIGINT to both children and shuts everything down.
|
|
14
|
+
#
|
|
15
|
+
# Prerequisites:
|
|
16
|
+
# Redis server running on localhost:6379
|
|
17
|
+
# PostgreSQL configured for HTM (see HTM gem setup)
|
|
18
|
+
#
|
|
19
|
+
# Usage (from the robot_lab-durable root):
|
|
20
|
+
# ruby examples/01_day_trader.rb
|
|
21
|
+
|
|
22
|
+
require 'open3'
|
|
23
|
+
|
|
24
|
+
RUBY = RbConfig.ruby
|
|
25
|
+
EXAMPLES_DIR = File.expand_path("day_trader_lib", __dir__)
|
|
26
|
+
GENERATOR = File.join(EXAMPLES_DIR, "generator.rb")
|
|
27
|
+
PREDICTOR = File.join(EXAMPLES_DIR, "predictor.rb")
|
|
28
|
+
|
|
29
|
+
GEN_COLOR = "\e[33m" # amber
|
|
30
|
+
PRED_COLOR = "\e[36m" # cyan
|
|
31
|
+
RESET = "\e[0m"
|
|
32
|
+
GEN_LABEL = "[GEN ] "
|
|
33
|
+
PRED_LABEL = "[PRED] "
|
|
34
|
+
|
|
35
|
+
def die(msg)
|
|
36
|
+
warn msg
|
|
37
|
+
exit 1
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
[GENERATOR, PREDICTOR].each { |f| die("Missing: #{f}") unless File.exist?(f) }
|
|
41
|
+
|
|
42
|
+
puts <<~BANNER
|
|
43
|
+
#{"=" * 62}
|
|
44
|
+
Day Trader Demo
|
|
45
|
+
Generator : XYZZY price stream via Redis (5s ticks, GBM model)
|
|
46
|
+
Predictor : SMA+EMA ensemble tuned by a RobotLab durable agent
|
|
47
|
+
Feedback : ✓ GOOD / ✗ MISS verdict on each prediction window
|
|
48
|
+
Stop : Ctrl-C
|
|
49
|
+
#{"=" * 62}
|
|
50
|
+
BANNER
|
|
51
|
+
|
|
52
|
+
# ── Spawn generator ───────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
_gen_in, gen_out, gen_thread = Open3.popen2e(RUBY, GENERATOR)
|
|
55
|
+
gen_pid = gen_thread.pid
|
|
56
|
+
|
|
57
|
+
# Give the generator one tick to connect to Redis before the predictor
|
|
58
|
+
# subscribes, avoiding a missed first message.
|
|
59
|
+
sleep 1
|
|
60
|
+
|
|
61
|
+
# ── Spawn predictor ───────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
_pred_in, pred_out, pred_thread = Open3.popen2e(RUBY, PREDICTOR)
|
|
64
|
+
pred_pid = pred_thread.pid
|
|
65
|
+
|
|
66
|
+
# ── Ctrl-C: forward SIGINT to both children ───────────────────────────────────
|
|
67
|
+
|
|
68
|
+
trap("INT") do
|
|
69
|
+
[gen_pid, pred_pid].each { |pid| Process.kill("INT", pid) rescue nil }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# ── Relay each process's output with a color-coded label ─────────────────────
|
|
73
|
+
|
|
74
|
+
def relay(io, label, color)
|
|
75
|
+
Thread.new do
|
|
76
|
+
io.each_line do |line|
|
|
77
|
+
$stdout.print "#{color}#{label}#{RESET}#{line}"
|
|
78
|
+
$stdout.flush
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
t1 = relay(gen_out, GEN_LABEL, GEN_COLOR)
|
|
84
|
+
t2 = relay(pred_out, PRED_LABEL, PRED_COLOR)
|
|
85
|
+
|
|
86
|
+
t1.join
|
|
87
|
+
t2.join
|
|
88
|
+
|
|
89
|
+
gen_thread.value
|
|
90
|
+
pred_thread.value
|
|
91
|
+
|
|
92
|
+
puts "\nDay Trader stopped."
|
|
@@ -1,22 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
#
|
|
4
|
+
# Day Trader: XYZZY Stock Price Generator
|
|
5
5
|
#
|
|
6
6
|
# Publishes fake streaming prices for ticker XYZZY to a Redis channel
|
|
7
7
|
# using Geometric Brownian Motion with occasional volatility regime shifts.
|
|
8
8
|
#
|
|
9
|
+
# Launched by examples/01_day_trader.rb.
|
|
10
|
+
# Can also be run standalone.
|
|
11
|
+
#
|
|
9
12
|
# Prerequisites:
|
|
10
13
|
# gem install redis
|
|
11
14
|
# Redis server running on localhost:6379
|
|
12
|
-
#
|
|
13
|
-
# Usage:
|
|
14
|
-
# ruby examples/33_stock_generator.rb
|
|
15
15
|
|
|
16
16
|
require "redis"
|
|
17
17
|
require "json"
|
|
18
18
|
require "time"
|
|
19
19
|
|
|
20
|
+
$stdout.sync = true # flush every line immediately through the pipe
|
|
21
|
+
|
|
20
22
|
CHANNEL = "stock:xyzzy"
|
|
21
23
|
START_PRICE = 100.0
|
|
22
24
|
BASE_VOL = 0.008 # baseline volatility per tick (~0.8%)
|
|
@@ -1,30 +1,40 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
#
|
|
4
|
+
# Day Trader: XYZZY Stock Price Predictor
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
# predicts
|
|
8
|
-
#
|
|
9
|
-
# after each window closes.
|
|
6
|
+
# Subscribes to the XYZZY Redis channel, accumulates ticks into windows,
|
|
7
|
+
# predicts each window's high/low using an SMA+EMA ensemble, and uses a
|
|
8
|
+
# RobotLab durable agent to tune parameters across sessions.
|
|
10
9
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# After each window closes the predictor prints a GOOD/MISS verdict:
|
|
11
|
+
# ✓ GOOD mean prediction error ≤ GOOD_THRESHOLD
|
|
12
|
+
# ✗ MISS mean prediction error > GOOD_THRESHOLD
|
|
13
|
+
#
|
|
14
|
+
# Launched by examples/01_day_trader.rb.
|
|
15
|
+
# Can also be run standalone alongside generator.rb.
|
|
13
16
|
#
|
|
14
17
|
# Prerequisites:
|
|
15
18
|
# gem install redis
|
|
16
19
|
# Redis server running on localhost:6379
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
#
|
|
20
|
+
# PostgreSQL configured for HTM (see HTM gem setup)
|
|
21
|
+
|
|
22
|
+
$stdout.sync = true # flush every line immediately through the pipe
|
|
23
|
+
|
|
24
|
+
$LOAD_PATH.unshift File.expand_path('../../../robot_lab/lib', __dir__)
|
|
25
|
+
$LOAD_PATH.unshift File.expand_path('../../lib', __dir__)
|
|
20
26
|
|
|
21
27
|
require "robot_lab"
|
|
22
28
|
require "robot_lab/durable"
|
|
23
29
|
require "redis"
|
|
24
30
|
require "json"
|
|
25
31
|
|
|
26
|
-
|
|
27
|
-
|
|
32
|
+
DEBUG_MODE = ARGV.delete("--debug")
|
|
33
|
+
RubyLLM.configure { |c| c.log_level = DEBUG_MODE ? Logger::DEBUG : Logger::WARN }
|
|
34
|
+
|
|
35
|
+
CHANNEL = "stock:xyzzy"
|
|
36
|
+
WINDOW_SIZE = 12 # ticks per prediction window
|
|
37
|
+
GOOD_THRESHOLD = 2.00 # mean error (dollars) at or below which a window is GOOD
|
|
28
38
|
|
|
29
39
|
# ── Mutable predictor parameters ──────────────────────────────────────────────
|
|
30
40
|
|
|
@@ -139,14 +149,12 @@ class AdjustParameters < RobotLab::Tool
|
|
|
139
149
|
|
|
140
150
|
clamped = value.to_f.clamp(spec[:min], spec[:max])
|
|
141
151
|
clamped = clamped.round if spec[:integer]
|
|
142
|
-
|
|
143
152
|
PredictorConfig.send(:"#{parameter}=", clamped)
|
|
144
|
-
|
|
145
153
|
"Set #{parameter} = #{clamped}. #{reasoning}"
|
|
146
154
|
end
|
|
147
155
|
end
|
|
148
156
|
|
|
149
|
-
# ──
|
|
157
|
+
# ── Window evaluation ──────────────────────────────────────────────────────────
|
|
150
158
|
|
|
151
159
|
WindowResult = Data.define(
|
|
152
160
|
:window_num,
|
|
@@ -170,6 +178,17 @@ def evaluate_window(window_num, predicted, actuals)
|
|
|
170
178
|
)
|
|
171
179
|
end
|
|
172
180
|
|
|
181
|
+
def print_window_result(result)
|
|
182
|
+
verdict = result.mean_err <= GOOD_THRESHOLD ? "✓ GOOD" : "✗ MISS"
|
|
183
|
+
puts "─" * 58
|
|
184
|
+
puts "Window #{result.window_num} #{verdict} " \
|
|
185
|
+
"mean_err=$#{"%.2f" % result.mean_err} (threshold $#{"%.2f" % GOOD_THRESHOLD})"
|
|
186
|
+
puts " Predicted h=$%-8.2f l=$%.2f" % [result.predicted_high, result.predicted_low]
|
|
187
|
+
puts " Actual h=$%-8.2f l=$%.2f err h=$%.2f l=$%.2f" % [
|
|
188
|
+
result.actual_high, result.actual_low, result.high_err, result.low_err
|
|
189
|
+
]
|
|
190
|
+
end
|
|
191
|
+
|
|
173
192
|
def tuner_prompt(result)
|
|
174
193
|
<<~PROMPT
|
|
175
194
|
Window #{result.window_num} just closed.
|
|
@@ -193,49 +212,52 @@ end
|
|
|
193
212
|
|
|
194
213
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
195
214
|
|
|
196
|
-
puts "=" *
|
|
215
|
+
puts "=" * 58
|
|
197
216
|
puts "XYZZY Stock Predictor"
|
|
198
|
-
puts "=" *
|
|
217
|
+
puts "=" * 58
|
|
199
218
|
puts "Channel : #{CHANNEL}"
|
|
200
219
|
puts "Window : #{WINDOW_SIZE} ticks"
|
|
201
220
|
puts "Model : SMA + EMA Ensemble with Durable Learning"
|
|
202
221
|
puts "Warmup : #{PredictorConfig.sma_window} ticks"
|
|
203
|
-
puts "
|
|
204
|
-
puts "-" *
|
|
205
|
-
|
|
206
|
-
redis
|
|
207
|
-
prices
|
|
208
|
-
robot
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
222
|
+
puts "Good threshold: $#{"%.2f" % GOOD_THRESHOLD} mean error"
|
|
223
|
+
puts "-" * 58
|
|
224
|
+
|
|
225
|
+
redis = Redis.new
|
|
226
|
+
prices = []
|
|
227
|
+
robot = RobotLab.build(
|
|
228
|
+
name: "predictor_tuner",
|
|
229
|
+
model: "gpt-4.1-mini",
|
|
230
|
+
provider: :openai,
|
|
231
|
+
system_prompt: <<~PROMPT,
|
|
232
|
+
You are a quantitative analyst tuning an ensemble stock price range
|
|
233
|
+
predictor for ticker XYZZY. Each prediction covers the high and low
|
|
234
|
+
price over the next #{WINDOW_SIZE} ticks.
|
|
235
|
+
|
|
236
|
+
The ensemble combines a Simple Moving Average (SMA) band and an
|
|
237
|
+
Exponential Moving Average (EMA) band. Adjustable parameters:
|
|
238
|
+
|
|
239
|
+
sma_window (3-30 int) — lookback period for SMA
|
|
240
|
+
sma_std_multiplier (0.5-4.0) — band width relative to SMA stddev
|
|
241
|
+
ema_alpha (0.05-0.5) — EMA smoothing (higher = more reactive)
|
|
242
|
+
ema_vol_multiplier (0.5-4.0) — band width relative to EMA volatility
|
|
243
|
+
sma_weight (0.0-1.0) — SMA share in ensemble (EMA = 1 - weight)
|
|
244
|
+
|
|
245
|
+
Workflow per window:
|
|
246
|
+
1. Call RecallKnowledge to check past findings before acting.
|
|
247
|
+
2. If the error is clearly too high/low in one direction, adjust the
|
|
248
|
+
relevant band multiplier via AdjustParameters.
|
|
249
|
+
3. Make at most two adjustments per window to isolate cause and effect.
|
|
250
|
+
4. If you observe a reliable pattern, call RecordKnowledge to preserve it.
|
|
251
|
+
5. When uncertain, do nothing rather than guess.
|
|
252
|
+
PROMPT
|
|
253
|
+
local_tools: [AdjustParameters,
|
|
254
|
+
RobotLab::RecallKnowledge,
|
|
255
|
+
RobotLab::RecordKnowledge]
|
|
256
|
+
)
|
|
257
|
+
robot.on(RobotLab::Durable::Hook)
|
|
236
258
|
|
|
237
259
|
warmed_up = false
|
|
238
|
-
pending_pred = nil
|
|
260
|
+
pending_pred = nil
|
|
239
261
|
window_num = 0
|
|
240
262
|
|
|
241
263
|
trap("INT") { puts "\nPredictor stopped."; exit }
|
|
@@ -251,7 +273,7 @@ redis.subscribe(CHANNEL) do |on|
|
|
|
251
273
|
EMAPredictor.update(price)
|
|
252
274
|
prices << price
|
|
253
275
|
|
|
254
|
-
# ── Warmup phase
|
|
276
|
+
# ── Warmup phase ────────────────────────────────────────────────────────
|
|
255
277
|
unless warmed_up
|
|
256
278
|
if prices.size < PredictorConfig.sma_window
|
|
257
279
|
puts "Tick %5d $%8.2f [warming up %d/%d]" % [tick, price, prices.size, PredictorConfig.sma_window]
|
|
@@ -261,44 +283,41 @@ redis.subscribe(CHANNEL) do |on|
|
|
|
261
283
|
warmed_up = true
|
|
262
284
|
pred = EnsemblePredictor.predict(prices)
|
|
263
285
|
pending_pred = { prediction: pred, window_prices: [] }
|
|
264
|
-
|
|
265
|
-
puts "
|
|
266
|
-
puts " First prediction → high=$#{pred[:high]} low=$#{pred[:low]}"
|
|
286
|
+
puts "Tick %5d $%8.2f [warmup done — first window open]" % [tick, price]
|
|
287
|
+
puts " First prediction → h=$#{pred[:high]} l=$#{pred[:low]}"
|
|
267
288
|
next
|
|
268
289
|
end
|
|
269
290
|
|
|
270
|
-
# ── Accumulate current window
|
|
291
|
+
# ── Accumulate current window ──────────────────────────────────────────
|
|
271
292
|
pending_pred[:window_prices] << price
|
|
272
293
|
progress = pending_pred[:window_prices].size
|
|
273
294
|
pred = pending_pred[:prediction]
|
|
274
295
|
|
|
275
|
-
puts "Tick %5d $%8.2f [%2d/#{WINDOW_SIZE}]
|
|
296
|
+
puts "Tick %5d $%8.2f [%2d/#{WINDOW_SIZE}] pred h=$#{pred[:high]} l=$#{pred[:low]}" %
|
|
276
297
|
[tick, price, progress]
|
|
277
298
|
|
|
278
299
|
next unless progress >= WINDOW_SIZE
|
|
279
300
|
|
|
280
|
-
# ── Window closed
|
|
301
|
+
# ── Window closed: evaluate, print verdict, tune ───────────────────────
|
|
281
302
|
window_num += 1
|
|
282
303
|
result = evaluate_window(window_num, pred, pending_pred[:window_prices])
|
|
283
304
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
puts "
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
puts "\r [tuner] #{tuner_line}#{" " * 20}"
|
|
305
|
+
print_window_result(result)
|
|
306
|
+
|
|
307
|
+
puts " Tuning..."
|
|
308
|
+
begin
|
|
309
|
+
tuner_response = robot.run(tuner_prompt(result))
|
|
310
|
+
tuner_line = tuner_response.reply.lines.first&.chomp || "(no response)"
|
|
311
|
+
puts " → #{tuner_line}"
|
|
312
|
+
rescue StandardError => e
|
|
313
|
+
puts " → Tuning skipped: #{e.message.lines.first&.chomp}"
|
|
314
|
+
end
|
|
295
315
|
puts " Params: #{PredictorConfig.summary}"
|
|
296
|
-
puts
|
|
316
|
+
puts "─" * 58
|
|
297
317
|
|
|
298
|
-
# ── Start next window
|
|
318
|
+
# ── Start next window ─────────────────────────────────────────────────
|
|
299
319
|
new_pred = EnsemblePredictor.predict(prices)
|
|
300
320
|
pending_pred = { prediction: new_pred, window_prices: [] }
|
|
301
|
-
puts "
|
|
302
|
-
puts
|
|
321
|
+
puts "Next prediction → h=$#{new_pred[:high]} l=$#{new_pred[:low]}"
|
|
303
322
|
end
|
|
304
323
|
end
|