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