galaaz 2.1.8 → 2.1.9

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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/Rakefile +20 -2
  4. data/bin/check_gemfile_lock_version +46 -0
  5. data/bin/release_bump +26 -0
  6. data/blogs/README.md +4 -0
  7. data/blogs/galaaz_2_0/galaaz_2_0.Rmd +385 -0
  8. data/blogs/galaaz_2_0/galaaz_2_0.md +409 -0
  9. data/blogs/galaaz_2_0/galaaz_2_0.tex +756 -0
  10. data/blogs/galaaz_2_0/images/galaaz-header.png +0 -0
  11. data/blogs/galaaz_2_0/images/galaaz-lockup-stacked.png +0 -0
  12. data/blogs/galaaz_ggplot/galaaz_ggplot.Rmd +14 -1
  13. data/blogs/galaaz_ggplot/galaaz_ggplot.md +123 -103
  14. data/blogs/galaaz_ggplot/galaaz_ggplot.tex +60 -23
  15. data/blogs/galaaz_ggplot/images/galaaz-lockup-stacked.png +0 -0
  16. data/blogs/gknit/gknit.Rmd +16 -1
  17. data/blogs/gknit/gknit.md +13 -1
  18. data/blogs/gknit/gknit.tex +64 -23
  19. data/blogs/gknit/gknit_files/figure-html/bubble-1.png +0 -0
  20. data/blogs/gknit/gknit_files/figure-html/diverging_bar.png +0 -0
  21. data/blogs/gknit/gknit_files/figure-latex/bubble-1.png +0 -0
  22. data/blogs/gknit/images/galaaz-lockup-stacked.png +0 -0
  23. data/blogs/manual/images/galaaz-lockup-stacked.png +0 -0
  24. data/blogs/manual/manual.Rmd +32 -11
  25. data/blogs/manual/manual.md +30 -23
  26. data/blogs/manual/manual.tex +88 -66
  27. data/blogs/manual/manual_files/figure-html/bubble-1.png +0 -0
  28. data/blogs/manual/manual_files/figure-latex/bubble-1.png +0 -0
  29. data/blogs/nse_dplyr/images/galaaz-lockup-stacked.png +0 -0
  30. data/blogs/nse_dplyr/nse_dplyr.Rmd +14 -1
  31. data/blogs/nse_dplyr/nse_dplyr.md +697 -649
  32. data/blogs/nse_dplyr/nse_dplyr.tex +61 -24
  33. data/blogs/oh_my/images/galaaz-lockup-stacked.png +0 -0
  34. data/blogs/oh_my/oh_my.Rmd +14 -1
  35. data/blogs/oh_my/oh_my.md +36 -26
  36. data/blogs/oh_my/oh_my.tex +95 -58
  37. data/blogs/r_on_rails_ledger/images/00_portfolio_page.png +0 -0
  38. data/blogs/r_on_rails_ledger/images/01_results_panel.png +0 -0
  39. data/blogs/r_on_rails_ledger/images/02_density_tail_risk.png +0 -0
  40. data/blogs/r_on_rails_ledger/images/03_mc_cone.png +0 -0
  41. data/blogs/r_on_rails_ledger/images/04_rolling_var.png +0 -0
  42. data/blogs/r_on_rails_ledger/images/galaaz-lockup-stacked.png +0 -0
  43. data/blogs/r_on_rails_ledger/r_on_rails_ledger.Rmd +354 -0
  44. data/blogs/r_on_rails_ledger/r_on_rails_ledger.md +365 -0
  45. data/blogs/r_on_rails_ledger/r_on_rails_ledger.tex +670 -0
  46. data/blogs/ruby_plot/images/galaaz-lockup-stacked.png +0 -0
  47. data/blogs/ruby_plot/ruby_plot.Rmd +14 -1
  48. data/blogs/ruby_plot/ruby_plot.md +11 -1
  49. data/blogs/ruby_plot/ruby_plot.tex +60 -23
  50. data/blogs/ruby_plot/ruby_plot_files/figure-html/facets_with_jitter.png +0 -0
  51. data/blogs/ruby_plot/ruby_plot_files/figure-html/final_violin_plot.png +0 -0
  52. data/blogs/ruby_plot/ruby_plot_files/figure-html/violin_with_jitter.png +0 -0
  53. data/blogs/ruby_plot/ruby_plot_files/figure-latex/facets_with_jitter.png +0 -0
  54. data/blogs/ruby_plot/ruby_plot_files/figure-latex/final_violin_plot.png +0 -0
  55. data/blogs/ruby_plot/ruby_plot_files/figure-latex/violin_with_jitter.png +0 -0
  56. data/blogs/ruby_plot/ruby_plot_files/ruby_plot_files/figure-latex/facets_with_jitter.png +0 -0
  57. data/blogs/ruby_plot/ruby_plot_files/ruby_plot_files/figure-latex/final_violin_plot.png +0 -0
  58. data/blogs/ruby_plot/ruby_plot_files/ruby_plot_files/figure-latex/violin_with_jitter.png +0 -0
  59. data/lib/galaaz/cli.rb +47 -3
  60. data/logos/icon-font/README.md +27 -0
  61. data/logos/icon-font/build_font.py +130 -0
  62. data/logos/icon-font/galaaz-mark.svg +34 -0
  63. data/script/omarchy/README.md +8 -1
  64. data/script/omarchy/fonts/galaaz.ttf +0 -0
  65. data/script/omarchy/install-galaaz.sh +8 -1
  66. data/script/omarchy/omarchy-menu.jsonc +21 -9
  67. data/sty/galaaz-header.png +0 -0
  68. data/sty/galaaz-headers-from-p3.tex +4 -0
  69. data/sty/galaaz.sty +54 -23
  70. data/version.rb +1 -1
  71. metadata +30 -9
  72. data/blogs/galaaz_ggplot/galaaz_ggplot.log +0 -745
  73. data/blogs/gknit/gknit_files/gknit_files/figure-latex/bubble-1.png +0 -0
  74. data/blogs/manual/manual.log +0 -1530
  75. data/blogs/manual/manual_files/manual_files/figure-latex/bubble-1.png +0 -0
  76. data/blogs/nse_dplyr/nse_dplyr.log +0 -824
  77. data/blogs/oh_my/oh_my.log +0 -974
  78. data/blogs/ruby_plot/ruby_plot.log +0 -887
@@ -0,0 +1,365 @@
1
+ ---
2
+ title: "R-on-Rails: ship the ledger on Rails, keep the science in R"
3
+ subtitle: "A portfolio stress tester that puts SQLite, Hotwire, and GNU R on one desk"
4
+ author: "Rodrigo Botafogo"
5
+ tags: [Galaaz, "R-on-Rails", Rails, Ruby, R, Ledger, Arrow, Plotly, Hotwire]
6
+ date: "2026"
7
+ output:
8
+ html_document:
9
+ self_contained: true
10
+ keep_md: true
11
+ toc: true
12
+ toc_float: true
13
+ toc_depth: 2
14
+ number_sections: true
15
+ includes:
16
+ before_body: _logo_before_body.html
17
+ pdf_document:
18
+ includes:
19
+ in_header:
20
+ - "../../sty/galaaz.sty"
21
+ - "../../sty/galaaz-headers-from-p3.tex"
22
+ keep_tex: yes
23
+ number_sections: yes
24
+ toc: true
25
+ toc_depth: 2
26
+ fontsize: 11pt
27
+ ---
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+ # Two audiences, one gap
36
+
37
+ **If you live in R**, you already have the hard part: VaR, density
38
+ estimates, Monte Carlo, Bioconductor, the whole CRAN catalog. What is
39
+ still painful is turning a notebook into a **product**—logins, a
40
+ database, background jobs, a page that updates when the job finishes,
41
+ and a deploy story that is not a pile of glue scripts.
42
+
43
+ **If you live in Ruby on Rails**, you already have the product shell.
44
+ What is still painful is serious statistics without standing up a
45
+ Python microservice fleet, learning another ORM, and ferrying CSV
46
+ files between processes.
47
+
48
+ **R-on-Rails** is the name we give to a simple division of labor:
49
+
50
+ * **Rails** owns HTTP, Active Record, jobs, Hotwire, and the UX.
51
+ * **GNU R** owns the math and the statistical vocabulary.
52
+ * **[Galaaz](https://github.com/rbotafogo/galaaz)** is the bridge that
53
+ makes R feel like a Ruby DSL—and moves bulky tables with Apache
54
+ Arrow when you need more than a few numbers.
55
+
56
+ This post walks through a real demo app—the
57
+ **[R-on-Rails Ledger](https://github.com/rbotafogo/r_on_rails_ledger)**—
58
+ so you can see the database, the calculators, and the charts on one
59
+ page.
60
+
61
+ # What the Ledger is
62
+
63
+ The Ledger is a **family-office style portfolio stress tester** on
64
+ **Rails 8**, **CRuby**, **SQLite**, **Solid Queue / Solid Cable**, and
65
+ **Hotwire**. It is a sibling app to the Galaaz gem (not packaged inside
66
+ the gem). One click runs two engines on the same portfolio return
67
+ panel:
68
+
69
+ * **Engine A — historical:** empirical VaR / expected shortfall /
70
+ return density from history (`R.quantile`, `R.mean`, `R.density`).
71
+ * **Engine B — Monte Carlo:** forward GBM paths calibrated to that
72
+ history, then forward VaR-style KPIs and a 30-day “cone.”
73
+
74
+ Rails stores the book and the job results; R does the science; the
75
+ browser gets Plotly charts from JSON that R (and a little Ruby)
76
+ produced.
77
+
78
+ # What’s in Rails (and the database)
79
+
80
+ The “ledger” here is a **portfolio price book**, not double-entry
81
+ accounting:
82
+
83
+ | Table | Role |
84
+ |---|---|
85
+ | `portfolios` | Named book (`name`, `total_value`) |
86
+ | `assets` | Tickers + weights |
87
+ | `historical_prices` | Bars: `adjusted_close`, `daily_return`, ... |
88
+ | `stress_tests` | Job status, scalar KPIs, `chart_payload` JSON |
89
+ | `stress_test_runs` | Per-engine (`historical` / `monte_carlo`) payloads |
90
+
91
+ Seed data is **synthetic GBM** (honest demo data—not a live feed).
92
+ `SEED_PROFILE=fast` is laptop-friendly (~10 assets x 2k bars);
93
+ `SEED_PROFILE=wow` is the “about a million bars” line.
94
+
95
+ The click path is ordinary Rails:
96
+
97
+ ```text
98
+ POST /portfolios/:id/stress_tests
99
+ -> StressTestsController#create
100
+ -> StressTestJob.perform_later(...)
101
+ -> Risk::DualOrchestrator
102
+ |- Risk::HistoricalEngine
103
+ |- Risk::MonteCarloEngine
104
+ -> Turbo::StreamsChannel broadcast
105
+ -> results partial + Plotly redraw
106
+ ```
107
+
108
+ No React SPA. No Redis required for the demo. Development often runs
109
+ the job **`:async`** in-process so one `bin/dev` is enough; Solid Queue
110
+ stays in the Gemfile for production-shaped runs.
111
+
112
+ # R calculators that read like Ruby
113
+
114
+ After returns land in R (via Arrow—see below), the historical engine
115
+ exposes small methods that call R the way Galaaz intends: **named
116
+ functions and vectors**, not a wall of quoted R source for every KPI.
117
+
118
+ From the Ledger’s `Risk::HistoricalEngine` (abbreviated):
119
+
120
+ ```ruby
121
+ def historical_var(returns_vec, probs:)
122
+ RValues.scalar_f(
123
+ R.as__numeric(
124
+ R.quantile(returns_vec,
125
+ probs: probs,
126
+ names: false,
127
+ type: 7)
128
+ )
129
+ )
130
+ end
131
+
132
+ def expected_shortfall(returns_vec, var_level)
133
+ thr = var_level.is_a?(Numeric) ?
134
+ var_level : RValues.scalar_f(var_level)
135
+ RValues.scalar_f(
136
+ R.as__numeric(
137
+ R.mean(returns_vec[returns_vec <= thr])
138
+ )
139
+ )
140
+ end
141
+
142
+ def return_density(returns_vec, n: 128)
143
+ ArrowHandoff.density_xy_for_plotly(returns_vec, n: n)
144
+ end
145
+ ```
146
+
147
+ That is the pitch for Ruby developers: `R.quantile` is a method call.
148
+ For R developers: the quantile is still **R’s** quantile—same
149
+ `type = 7` story you know—just invoked from the app that owns the
150
+ database.
151
+
152
+ A tiny live taste of the same idea (outside Rails):
153
+
154
+
155
+ ``` ruby
156
+ x = R.c(-0.02, -0.01, 0.0, 0.01, 0.015, -0.005)
157
+ q05 = R.as__numeric(
158
+ R.quantile(x, probs: 0.05, names: false, type: 7))
159
+ puts "5% quantile: #{q05 >> 0}"
160
+ ```
161
+
162
+ ```
163
+ ## 5% quantile: -0.017499999999999998
164
+ ```
165
+
166
+ Engine B calibrates mu/sigma in R, simulates GBM paths, and returns
167
+ structured fields (`sim_var_30d`, sample paths, terminal density)
168
+ that Rails stores on each run’s `payload` JSON.
169
+
170
+ # How data moves (Arrow, honestly)
171
+
172
+ Crossing a process boundary means you must be honest about **copies**.
173
+ Galaaz can move columnar data with Apache Arrow in three ways:
174
+
175
+ 1. **Copy into R** — Ruby builds batches; R materialises an Arrow
176
+ table / proxy. Simple and available today.
177
+ 2. **IPC file handoff** — Ruby writes an Arrow IPC file (often under
178
+ `/dev/shm`); R opens it by **path**. Only the path crosses
179
+ NewBridge—not megabytes of MsgPack. This is what the Ledger
180
+ prefers for the return panel.
181
+ 3. **Shared-memory zero-copy** — both processes attach to one live
182
+ segment. **Not implemented yet**; we do not claim it.
183
+
184
+ ## Example: copy into R
185
+
186
+ Good when the table is modest or you do not have a Ruby Arrow writer
187
+ installed. One call builds the R-side table; then you Remote-Control
188
+ it.
189
+
190
+
191
+ ``` ruby
192
+ arrow_ok = R::Support.eval(
193
+ "requireNamespace('arrow', quietly=TRUE)") == true
194
+ unless arrow_ok
195
+ puts '(Skip: need R package arrow.)'
196
+ else
197
+ batches = [
198
+ [{ daily_return: -0.01 }, { daily_return: 0.02 }],
199
+ [{ daily_return: 0.005 }, { daily_return: -0.003 }]
200
+ ]
201
+ tbl = R::Arrow.from_ruby_batches(batches)
202
+ puts "R class: #{tbl.rclass}"
203
+ vec = R.as__numeric(
204
+ R.dplyr___collect(tbl)[['daily_return']])
205
+ q05 = R.as__numeric(
206
+ R.quantile(vec, probs: 0.05, names: false, type: 7))
207
+ puts "5% quantile: #{q05 >> 0}"
208
+ end
209
+ ```
210
+
211
+ ```
212
+ ## R class: Table
213
+ ## 5% quantile: -0.00895
214
+ ```
215
+
216
+ The Ledger’s fallback path is similar: a small `data.frame` plus
217
+ `R::Arrow.table_from(df)` when IPC is unavailable.
218
+
219
+ ## Example: IPC file handoff
220
+
221
+ Preferred for larger same-machine panels. Ruby writes the file; R
222
+ opens by path; Ruby can unlink after R has the table. The reverse
223
+ direction (`R::Arrow.write_ipc` then `Galaaz::ArrowIpc.read`) is how
224
+ the Ledger can pull density coordinates back for Plotly.
225
+
226
+
227
+ ``` ruby
228
+ ipc_ok = Galaaz::ArrowIpc.available? &&
229
+ (R::Support.eval(
230
+ "requireNamespace('arrow', quietly=TRUE)") == true)
231
+ unless ipc_ok
232
+ puts '(Skip: need Arrow IPC backend + R arrow.)'
233
+ else
234
+ returns = [-0.01, 0.02, 0.005, -0.003, -0.008]
235
+ path = Galaaz::ArrowIpc.write(
236
+ 'daily_return' => returns.map(&:to_f))
237
+ begin
238
+ tbl = R::Arrow.open_ipc(path)
239
+ puts "R class: #{tbl.rclass}"
240
+ puts "IPC: #{File.basename(path)}"
241
+ vec = R.as__numeric(
242
+ R.dplyr___collect(tbl)[['daily_return']])
243
+ q05 = R.as__numeric(
244
+ R.quantile(vec, probs: 0.05, names: false, type: 7))
245
+ puts "5% quantile: #{q05 >> 0}"
246
+ ensure
247
+ Galaaz::ArrowIpc.release(path)
248
+ end
249
+ end
250
+ ```
251
+
252
+ ```
253
+ ## R class: Table
254
+ ## IPC: galaaz_ipc_34614_1dd599409d5edadd.arrow
255
+ ## 5% quantile: -0.0096
256
+ ```
257
+
258
+ Then the product rule is **Remote Control**: keep the heavy panel in
259
+ R; unbox **KPIs and chart coordinates** back to Ruby for SQLite and
260
+ the browser.
261
+
262
+ ```text
263
+ SQLite -> portfolio return series (Ruby)
264
+ -> Arrow IPC file (path on the bridge)
265
+ -> GNU R (quantile / density / GBM)
266
+ -> JSON-ish payloads on SQLite
267
+ -> Turbo Stream -> Stimulus -> Plotly.js
268
+ ```
269
+
270
+ # The page with the charts
271
+
272
+ After a Local R stress test, the portfolio show page replaces the
273
+ results panel in place. Captures below are from a live run of the
274
+ demo (synthetic seed, Local engines, Plotly in the browser—**not**
275
+ ggplot2 SVG).
276
+
277
+ ## Side-by-side KPIs
278
+
279
+ Engine A is **1-day historical** VaR. Engine B is **30-day forward**
280
+ Monte Carlo. The UI says so on purpose: compare directionally, not as
281
+ identical metrics.
282
+
283
+ <div class="figure">
284
+ <img src="images/01_results_panel.png" alt="Ledger results panel: Local R runtime banner, KPI table, and three Plotly charts." width="100%" />
285
+ <p class="caption">Ledger results panel: Local R runtime banner, KPI table, and three Plotly charts.</p>
286
+ </div>
287
+
288
+ ## Density and tail risk
289
+
290
+ Historical kernel density from Engine A, Monte Carlo terminal-return
291
+ density from Engine B, with VaR markers as vertical lines.
292
+
293
+ <div class="figure">
294
+ <img src="images/02_density_tail_risk.png" alt="Density and tail risk (Plotly): historical vs Monte Carlo terminal returns." width="100%" />
295
+ <p class="caption">Density and tail risk (Plotly): historical vs Monte Carlo terminal returns.</p>
296
+ </div>
297
+
298
+ ## Thirty-day Monte Carlo cone
299
+
300
+ Sample paths, a 5–95% band, median, and baseline—forward uncertainty
301
+ as a picture, not only a scalar.
302
+
303
+ <div class="figure">
304
+ <img src="images/03_mc_cone.png" alt="30-day Monte Carlo cone (Plotly): paths, median, and 5–95% band." width="100%" />
305
+ <p class="caption">30-day Monte Carlo cone (Plotly): paths, median, and 5–95% band.</p>
306
+ </div>
307
+
308
+ ## Rolling historical VaR and breaches
309
+
310
+ Trailing returns against a rolling 95% VaR line, with breach markers
311
+ when returns punch through the threshold.
312
+
313
+ <div class="figure">
314
+ <img src="images/04_rolling_var.png" alt="Rolling historical VaR and breaches (Plotly)." width="100%" />
315
+ <p class="caption">Rolling historical VaR and breaches (Plotly).</p>
316
+ </div>
317
+
318
+ # Why this is a production-shaped story
319
+
320
+ * **One desk.** Rails conventions for the app; CRAN for the science.
321
+ * **Jobs, not request-thread math.** Stress work runs in
322
+ `StressTestJob`; the UI waits on Turbo/Cable.
323
+ * **Process isolation.** GNU R is a separate process (NewBridge). A
324
+ bad R call need not take down Puma.
325
+ * **Optional dual-version R.** The same job can target Docker images
326
+ (e.g. R 3.6.3 || 4.3.3) when you want version isolation; Local alone
327
+ still tells the Arrow + DSL story.
328
+ * **OnRails family.** Ruby on Rails -> Omarchy / LinuxOnRails ->
329
+ **R-on-Rails**—same “one person can ship the whole product” energy,
330
+ applied to people whose science already lives in R.
331
+
332
+ # Try it
333
+
334
+ ```bash
335
+ git clone https://github.com/rbotafogo/r_on_rails_ledger.git
336
+ cd r_on_rails_ledger
337
+ bundle install
338
+ bin/rails db:prepare
339
+ SEED_PROFILE=fast bin/rails db:seed
340
+ bin/dev
341
+ # open http://localhost:3000 -> portfolio -> Run stress test
342
+ ```
343
+
344
+ With a Galaaz checkout nearby, `galaaz add ledger` (Omarchy / CLI
345
+ helper) can clone and boot the same demo. In-app docs live at `/docs`
346
+ (architecture, Ruby DSL excerpts, runbook).
347
+
348
+ For the bridge itself—JRuby/CRuby, Arrow handoffs, `R::Job`—see the
349
+ companion post **Galaaz 2.0** in this blog series.
350
+
351
+ # Honest limits
352
+
353
+ * Seed prices are **synthetic**, not broker data.
354
+ * VaR / GBM here are a **demo risk stack**, not a bank production
355
+ engine (no full PerformanceAnalytics suite in the live path).
356
+ * Charts are **Plotly.js** fed by JSON coordinates—not ggplot embedded
357
+ as SVG in this build.
358
+ * The Ledger’s Arrow path is an **IPC file / mmap handoff**, not
359
+ shared Ruby/R heap memory (zero-copy is still future work).
360
+ * Historical and Monte Carlo **horizons differ**; the KPI footnotes
361
+ exist so the demo does not oversell a single number.
362
+
363
+ The point of honesty is the same as the point of the architecture:
364
+ Rails can be the product, R can stay R, and you can show the charts
365
+ without pretending the hard parts vanished.