[{"content":" ","date":"19 July 2026","externalUrl":null,"permalink":"/","section":"","summary":"","title":"","type":"page"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/categories/deep-dives/","section":"Categories","summary":"","title":"Deep Dives","type":"categories"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/llm/","section":"Tags","summary":"","title":"LLM","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/ollama/","section":"Tags","summary":"","title":"Ollama","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/posts/","section":"Posts","summary":"","title":"Posts","type":"posts"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/r/","section":"Tags","summary":"","title":"R","type":"tags"},{"content":"I wanted a local LLM pipeline I could call from R - no cloud API keys, no per-token billing, just a prompt in and a chunk of text out. This post walks through setting that up with Ollama and the ollamar package, and - more usefully - the handful of things that quietly broke along the way.\nRepo: github.com/sactyr/local-llm-r\nHardware # Nothing exotic: a GTX 1080 (8GB VRAM), a Ryzen 7 5700G, and 32GB of system RAM. Firmly mid-range by 2026 standards, which turned out to matter more than expected.\nThe chicken-and-egg problem: which models even fit? # Ollama will happily let you pull a model that\u0026rsquo;s too big for your card and find out the hard way. Rather than guess, I wanted the pipeline itself to detect hardware and pick sensible models - partly for convenience, partly because it makes the demo portable: clone the repo on different hardware, get different (correct) recommendations.\nFirst attempt: llmfit # llmfit is a Rust CLI that scores models against your detected hardware across quality/speed/fit/context. It\u0026rsquo;s fast and has zero dependencies (a single Scoop/Homebrew install), but its model database spans multiple formats - Hugging Face safetensors, MLX, GGUF - not just Ollama. In practice, most of its top recommendations for my hardware turned out to be not runnable in Ollama at all: one was an MLX-only checkpoint (Apple Silicon exclusive), another was a bitsandbytes 4-bit format Ollama can\u0026rsquo;t load. Of the top 10 recommendations, only 3 resolved to something pullable - and even then, only by falling back to Ollama\u0026rsquo;s hf.co/{repo}:{quant} direct-pull syntax for community GGUF conversions, since llmfit\u0026rsquo;s ollama_name field was null for most results.\nSecond attempt: llm-checker # llm-checker (npm-installed, Node.js) takes a narrower, more useful approach for this specific job: it restricts results to the Ollama catalog directly via an --ollama-only flag, and prints a ready ollama pull \u0026lt;tag\u0026gt; command for each recommendation. No format-mismatch resolution needed.\nIt\u0026rsquo;s not perfect either - worth building a small sanity filter rather than trusting recommendations blindly. Two issues I hit:\nOne \u0026ldquo;recommendation\u0026rdquo; pointed at qwen3-embedding under a qwen3 display name. Embedding models produce vectors, not text - pulling this into a text-generation pipeline would silently fail. Filtered out anything matching embed|rerank in the model name. My hardware tier was reported as \u0026ldquo;MEDIUM LOW, max model size 6GB\u0026rdquo; by hw-detect, yet one of the top-3 recommendations was a 36B-parameter, 23.9GB model. It still ran (see benchmark below) via heavy CPU/RAM offload, just much slower - but it directly contradicted the tool\u0026rsquo;s own hardware ceiling. Lesson: treat automated hardware-fit tools as a starting point, not ground truth. A thin validation layer between \u0026ldquo;tool recommends X\u0026rdquo; and \u0026ldquo;pull X\u0026rdquo; earns its keep.\nPulling the models # With the filter in place, three models came through cleanly:\nmodel disk size pull time gemma3 3.3GB 40.8s qwen3.5 6.6GB 110.4s qwen3.6 23.9GB 382s (ollamar::pull() doesn\u0026rsquo;t show progress the way the CLI ollama pull does — it just blocks silently until done. Fine for a script, mildly nerve-wracking to sit and watch.)\nTwo bugs that cost more time than the setup itself # ollamar::test_connection() returns an httr2_response object by default, not a boolean. A naive if (!isTRUE(test_connection())) check fails even on a successful connection, because isTRUE() only matches a literal logical TRUE. Fix: pass logical = TRUE explicitly.\nReasoning models silently return empty text via the /api/generate endpoint. qwen3.5 and qwen3.6 are Qwen3-family models with chain-of-thought \u0026ldquo;thinking\u0026rdquo; mode on by default. Ollama\u0026rsquo;s /api/generate endpoint has a known bug (ollama/ollama#14793) where think: false is silently ignored - the model burns its entire token budget on hidden reasoning tokens and the visible response field comes back empty, no error thrown. /api/chat with think: false at the top level (not nested under options) works correctly. Non-reasoning models like gemma3 are unaffected either way, so routing everything through /api/chat with think: false set is the simplest fix that works uniformly.\nResults # Same prompt (\u0026ldquo;write a 3-sentence summary of what a GTX 1080 is\u0026rdquo;), all three models, ~500-character target:\nmodel elapsed chars out chars/sec gemma3 7.1s 443 62.4 qwen3.5 12.2s 363 29.8 qwen3.6 31.4s 454 14.5 qwen3.6 - the hardware-mismatched 36B model - is roughly 4x slower than gemma3 per character, which tracks with it running partly off system RAM instead of VRAM. Still usable, just clearly the wrong default choice for this card.\nWrapping up # What started as \u0026ldquo;install Ollama and pull a model\u0026rdquo; turned into a more useful exercise than expected: automated hardware-fit tools are a genuine time-saver, but they\u0026rsquo;re not infallible, and it\u0026rsquo;s worth building a thin validation layer rather than piping their output straight into pull(). The two real bugs - a non-boolean connection check and a silent-failure reasoning-model endpoint - are the kind of thing that cost far more debugging time than the actual setup, and neither shows up in the tools' documentation.\nIf you\u0026rsquo;re setting up something similar, the full repo is a reasonable starting point: github.com/sactyr/local-llm-r. Swap in your own hardware, run llm-checker check --ollama-only, and see what it recommends for you.\n","date":"19 July 2026","externalUrl":null,"permalink":"/posts/2026-07-19-local-llm-r/","section":"Posts","summary":"","title":"Running Local LLMs from R: Hardware-Aware Model Selection with Ollama","type":"posts"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/self-hosting/","section":"Tags","summary":"","title":"Self-Hosting","type":"tags"},{"content":"","date":"19 July 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":" Background # As part of building a quantitative algorithmic trading system in R, I needed to communicate with Interactive Brokers\u0026rsquo; Client Portal REST API - a lightweight HTTP interface for account management, market data, and order placement that runs as a Java process on localhost.\nThe problem: no R package existed for it. The established packages (IBrokers, rib) target the older TWS socket-based API, which requires a full TWS or IB Gateway installation. The Client Portal API is much better suited to cloud deployments - no GUI, no forced daily restart, roughly 50MB of memory. So I ended up writing the integration myself in a single ibkr_api.R file inside my trading project.\nOnce that file stabilised, the natural next step was to extract it into a proper R package - to clean up the trading project, and to give something back to the small community of R developers working with IBKR. The result is ibkrcp, which I\u0026rsquo;ve just submitted to CRAN.\nThis post covers the full packaging journey from scaffold to submission.\nPackage scope # ibkrcp is deliberately narrow. It wraps the Client Portal API and nothing else - no trading logic, no signal generation, no portfolio management. The functions cover four areas:\nSession management - ibkr_tickle(), ibkr_auth_status(), ibkr_reauthenticate() Account and portfolio - ibkr_get_accounts(), ibkr_get_summary(), ibkr_get_positions() Market data - ibkr_search_contracts(), ibkr_get_price_history(), ibkr_get_trading_schedule() Orders - ibkr_place_order(), ibkr_get_orders(), ibkr_cancel_order() Each function is a thin wrapper around an httr2 request, returning either a data frame or a named list. The only dependency is httr2 - jsonlite was initially included but turned out to be unused, since httr2::resp_body_json() handles all JSON parsing internally.\nSetting up the package structure # Starting from scratch with usethis:\nusethis::create_package(\u0026#34;ibkrcp\u0026#34;) usethis::use_mit_license() usethis::use_testthat() usethis::use_roxygen_md() The R files are split by domain - session.r, account.r, market_data.r, orders.r, and utils.r for the shared HTTP helpers (ibkr_get(), ibkr_post(), ibkr_delete()). Every request goes through one of these helpers, which handle SSL configuration, headers, and error responses in a single place:\nibkr_get \u0026lt;- function(endpoint, params = NULL) { req \u0026lt;- request(paste0(IBKR_BASE_URL, endpoint)) |\u0026gt; req_options(ssl_verifypeer = FALSE, ssl_verifyhost = FALSE) |\u0026gt; req_headers(\u0026#34;User-Agent\u0026#34; = \u0026#34;R/ibkrcp\u0026#34;, \u0026#34;Accept\u0026#34; = \u0026#34;*/*\u0026#34;) if (!is.null(params)) req \u0026lt;- req |\u0026gt; req_url_query(!!!params) resp \u0026lt;- req |\u0026gt; req_perform() if (resp_status(resp) != 200) { stop(sprintf(\u0026#34;IBKR API GET %s failed with status %d: %s\u0026#34;, endpoint, resp_status(resp), resp_body_string(resp))) } resp |\u0026gt; resp_body_json() } The SSL flags deserve a note: the Client Portal Gateway runs on localhost with a self-signed certificate. Verifying SSL against localhost is not meaningful, and IBKR\u0026rsquo;s own documentation acknowledges this. It\u0026rsquo;s flagged explicitly in the CRAN submission notes so reviewers aren\u0026rsquo;t surprised by it.\nDocumentation with roxygen2 # Every exported function has a full roxygen2 block - @param, @return, @export, and a description. The internal HTTP helpers are marked @noRd to keep them out of the generated manual.\nAlways verify the NAMESPACE after documenting:\ndevtools::document() readLines(\u0026#34;NAMESPACE\u0026#34;) Unit tests with httptest2 # Since ibkrcp makes HTTP calls to a locally running gateway, tests can\u0026rsquo;t hit a real server in CI. httptest2 solves this with file-based mock fixtures - pre-recorded JSON responses stored on disk, served in place of real HTTP calls during tests.\nThe fixture directory structure mirrors the URL path under tests/testthat/:\nlocalhost-5000/v1/api/ tickle.json iserver/ auth/status.json orders.json account/U1234567/orders-50e22c-POST.json marketdata/history-45530b.json secdef/search-5b4185.json portfolio/ accounts.json U1234567/summary.json U1234567/positions/0.json trsrv/secdef/schedule-c1b48e.json A few things that tripped me up:\nQuery parameter hashing. For GET requests with query parameters, httptest2 appends a hash of the query string to the fixture filename. So ibkr_search_contracts(\u0026quot;VGS\u0026quot;) doesn\u0026rsquo;t look for search.json - it looks for search-5b4185.json. The error message tells you exactly what hash to use:\nExpected mock file: localhost-5000/v1/api/iserver/secdef/search-5b4185.* POST requests follow the same pattern, with -POST appended after the hash:\nExpected mock file: localhost-5000/v1/api/iserver/account/U1234567/orders-50e22c-POST.* The approach for alternate scenarios - empty positions, unauthenticated session, no price history - is with_mock_dir(\u0026quot;scenario_name\u0026quot;, {...}), which looks for fixtures under a subdirectory of that name. For example, the unauthenticated tickle test:\nwith_mock_dir(\u0026#34;unauthenticated\u0026#34;, { test_that(\u0026#34;ibkr_tickle() stops when not authenticated\u0026#34;, { expect_error(ibkr_tickle(), \u0026#34;not authenticated\u0026#34;) }) }) The final test suite: 42 tests, 0 failures, 0 warnings.\nR CMD check # devtools::check() Result:\n0 errors | 0 warnings | 1 note The single note:\nNon-standard file/directory found at top level: \u0026#39;CRAN-comments.md\u0026#39; This is expected - CRAN-comments.md is a submission-only file that lives at the package root during development but isn\u0026rsquo;t part of the R package standard. It\u0026rsquo;s universally accepted by CRAN reviewers and disappears once the package is published.\nSubmission # devtools::submit_cran() The package is now with CRAN. Once accepted, the trading project can swap:\nsource(\u0026#34;R/live_trading/ibkr_api.R\u0026#34;) for:\nlibrary(ibkrcp) Until then, you can install the development version directly from GitHub:\npak::pak(\u0026#34;sactyr/ibkrcp\u0026#34;) The Client Portal Gateway must be running and authenticated before any function calls will work - see the package vignette for the full setup walkthrough.\nThe full source code is available on GitHub.\n","date":"3 May 2026","externalUrl":null,"permalink":"/posts/2026-05-03-ibkrcp/","section":"Posts","summary":"","title":"Building ibkrcp: An R Package for the IBKR Client Portal API","type":"posts"},{"content":"","date":"3 May 2026","externalUrl":null,"permalink":"/tags/cran/","section":"Tags","summary":"","title":"CRAN","type":"tags"},{"content":"","date":"3 May 2026","externalUrl":null,"permalink":"/tags/httptest2/","section":"Tags","summary":"","title":"Httptest2","type":"tags"},{"content":"","date":"3 May 2026","externalUrl":null,"permalink":"/tags/httr2/","section":"Tags","summary":"","title":"Httr2","type":"tags"},{"content":"","date":"3 May 2026","externalUrl":null,"permalink":"/tags/ibkr/","section":"Tags","summary":"","title":"IBKR","type":"tags"},{"content":"","date":"3 May 2026","externalUrl":null,"permalink":"/tags/roxygen2/","section":"Tags","summary":"","title":"Roxygen2","type":"tags"},{"content":"","date":"3 May 2026","externalUrl":null,"permalink":"/tags/testthat/","section":"Tags","summary":"","title":"Testthat","type":"tags"},{"content":"","date":"10 April 2026","externalUrl":null,"permalink":"/tags/algo-trading/","section":"Tags","summary":"","title":"Algo-Trading","type":"tags"},{"content":"","date":"10 April 2026","externalUrl":null,"permalink":"/tags/automation/","section":"Tags","summary":"","title":"Automation","type":"tags"},{"content":"","date":"10 April 2026","externalUrl":null,"permalink":"/tags/azure/","section":"Tags","summary":"","title":"Azure","type":"tags"},{"content":" Introduction # In Part 1 of this series, we subjected eight trading strategies to a Monte Carlo gauntlet of 1,000 randomised time windows, measuring each against the CAPS (Calmar-adjusted Probability Score) to find a strategy robust enough for live deployment. In Part 2, we built the cloud-based data pipeline - a fully automated hourly price collector running on Azure Container Instances, feeding into Blob Storage.\nThose two parts answered the two most important questions before going live: does the strategy work? and do we have reliable data? Part 3 answers the final question: can we actually execute it?\nThis post walks through the live trading engine - how a signal becomes an order, how the system remembers what it holds, how we tested it, and what we found when we finally pointed it at a real exchange.\nThe Trading Engine: Architecture Overview # The live trading system is a single R script - crypto_trader.R - that runs as a daily scheduled job on Azure. Each run follows a strict sequence:\n1. Connect to Azure Blob Storage → load bot state + price history 2. Generate trading signal (SMA crossover) from loaded price history 3. Check stop-loss shield 4. Execute order if required (private API) 5. Update and persist state back to Azure Each step is wrapped in tryCatch with structured logging via the logger package. If anything fails - an API timeout, a corrupted state file, a network blip - the script logs the error and halts cleanly rather than firing a half-baked trade.\nThe script runs in two modes controlled by a single variable:\ntrading_mode \u0026lt;- \u0026#34;TEST\u0026#34; # Simulates orders without executing them trading_mode \u0026lt;- \u0026#34;LIVE\u0026#34; # Executes real market orders This made it safe to iterate on the logic extensively before touching real money.\nSignal Generation in Production # In backtesting, signal generation is straightforward - you pass a full xts object of historical prices into the strategy function and get a vector of signals back. In production it\u0026rsquo;s slightly more nuanced, because you\u0026rsquo;re appending one new data point each day to a rolling history.\nThe strategy used was SMA crossover - buy when the fast SMA (26 periods) crosses above the slow SMA (50 periods), sell when it crosses below:\n# Generate signal from rolling price history fast_sma \u0026lt;- SMA(Ad(prices_xts), n = fast_period) # 26 slow_sma \u0026lt;- SMA(Ad(prices_xts), n = slow_period) # 50 signal \u0026lt;- case_when( fast_sma \u0026gt; slow_sma \u0026amp; lag(fast_sma) \u0026lt;= lag(slow_sma) ~ 1L, # BUY fast_sma \u0026lt; slow_sma \u0026amp; lag(fast_sma) \u0026gt;= lag(slow_sma) ~ -1L, # SELL TRUE ~ 0L # HOLD ) The key production consideration: the slow SMA needs at least 50 daily bars before it produces a valid signal. On day one, the script does nothing and waits until sufficient history accumulates. This is the same \u0026ldquo;warmup period\u0026rdquo; behaviour from backtesting - the code handles both contexts identically, which is exactly what you want.\nStop-Loss: The Shield # The SMA crossover strategy is a trend-follower - it stays in a position until the trend reverses, which can take a long time during a prolonged bear market. Without a stop-loss, a severe enough crash could wipe out the entire position before a sell signal fires.\nThe stop-loss \u0026ldquo;shield\u0026rdquo; is checked on every run, before signal generation:\nif (bot_state$in_position) { pnl_pct \u0026lt;- (current_price - bot_state$purchase_price) / bot_state$purchase_price if (pnl_pct \u0026lt;= -stop_loss_threshold) { log_warn(paste0( \u0026#34;SHIELD TRIGGERED: Price dropped \u0026#34;, round(pnl_pct * 100, 2), \u0026#34;% below entry. Forcing SELL.\u0026#34; )) final_decision \u0026lt;- \u0026#34;SELL\u0026#34; } } The threshold was set at -10% - if BTC drops more than 10% below the entry price, the position is force-closed regardless of what the SMA crossover says. During the end-to-end test run, the stop-loss fired correctly - BTC was sitting ~20% below the test entry price and the shield triggered before the signal logic even ran.\nConnecting to Independent Reserve\u0026rsquo;s Private API # Part 2 used Independent Reserve\u0026rsquo;s public API - no authentication required, just GET requests for price history. Placing orders requires the private API, which uses HMAC-SHA256 request signing.\nThe signing process works as follows: every private request includes an API key, a nonce (Unix timestamp in milliseconds for uniqueness), and a signature. The signature is computed by:\nBuilding a string from the endpoint URL and all parameters in a specific order Hashing that string with SHA-256 using the API secret as the key Converting the hash to uppercase hex ir_private_auth \u0026lt;- function(req, api_key, api_secret, extra_params = list()) { nonce \u0026lt;- as.integer(Sys.time() * 1000) url \u0026lt;- req$url # Parameters must be in this exact order for valid signature all_params \u0026lt;- c( list(apiKey = api_key, nonce = nonce), extra_params ) sig_string \u0026lt;- paste0( url, \u0026#34;,\u0026#34;, paste( paste0(names(all_params), \u0026#34;=\u0026#34;, unlist(all_params)), collapse = \u0026#34;,\u0026#34; ) ) signature \u0026lt;- toupper(as.character( openssl::sha256(chartr(\u0026#34;\u0026#34;, \u0026#34;\u0026#34;, sig_string), key = api_secret) )) all_params$signature \u0026lt;- signature req |\u0026gt; req_method(\u0026#34;POST\u0026#34;) |\u0026gt; req_body_json(all_params) } One quirk worth documenting: Independent Reserve\u0026rsquo;s private API uses different endpoint names and parameter names depending on the order direction.\nDirection Endpoint Volume Parameter Buy PlaceMarketBuyOrder volumeInSecondaryCurrencyAmount (AUD) Sell PlaceMarketSellOrder primaryCurrencyAmount (BTC) This asymmetry - buying in AUD, selling in BTC - is not immediately obvious from the documentation and took some trial and error to get right. The place_ir_order() function handles this branching internally:\nplace_ir_order \u0026lt;- function(type, amount, key, secret) { endpoint \u0026lt;- if (type == \u0026#34;MarketBuy\u0026#34;) \u0026#34;PlaceMarketBuyOrder\u0026#34; else \u0026#34;PlaceMarketSellOrder\u0026#34; volume_param \u0026lt;- if (type == \u0026#34;MarketBuy\u0026#34;) \u0026#34;volumeInSecondaryCurrencyAmount\u0026#34; else \u0026#34;primaryCurrencyAmount\u0026#34; extra_params \u0026lt;- list( primaryCurrencyCode = \u0026#34;xbt\u0026#34;, secondaryCurrencyCode = \u0026#34;aud\u0026#34; ) extra_params[[volume_param]] \u0026lt;- amount request(file.path(ir_private_api_url, endpoint)) |\u0026gt; ir_private_auth(api_key = key, api_secret = secret, extra_params = extra_params) |\u0026gt; req_retry(max_tries = 3) |\u0026gt; req_perform() |\u0026gt; resp_body_json() } State Management: The Bot\u0026rsquo;s Memory # A stateless trading bot is dangerous - if the Azure container restarts mid-run, or the script crashes between placing an order and recording it, the bot loses track of what it holds.\nThe solution is a persistent bot_state object stored in Azure Blob Storage, updated at the end of every run:\nbot_state \u0026lt;- list( in_position = FALSE, # Are we currently holding BTC? purchase_price = 0, # Price at which we bought last_trade_time = NA, # Timestamp of last executed trade current_cash_holdings = 1000, # AUD available current_crypto_holdings = 0 # BTC held ) On startup, the script downloads the latest state from Azure. At the end of every run - whether a trade fired or not - it uploads the updated state back. This means the bot always starts each day from a known, persisted position.\nThe state also includes a desync detection step. Before acting on the signal, the script cross-checks bot_state$in_position against the actual exchange balance:\nir_btc_balance \u0026lt;- get_ir_accounts(key, secret) |\u0026gt; filter(CurrencyCode == \u0026#34;XBT\u0026#34;) |\u0026gt; pull(AvailableBalance) if (bot_state$in_position \u0026amp;\u0026amp; ir_btc_balance \u0026lt; min_btc_threshold) { log_warn(\u0026#34;DESYNC: State says IN position but exchange shows no BTC. Correcting state.\u0026#34;) bot_state$in_position \u0026lt;- FALSE } This guards against the scenario where a SELL order was placed but the state file wasn\u0026rsquo;t updated (e.g. due to a crash), preventing the bot from trying to sell BTC it no longer holds.\nTesting: Building Confidence Before Going Live # A trading bot that hasn\u0026rsquo;t been tested is a donation to the market. Before running crypto_trader.R anywhere near real funds, two rounds of testing were completed.\nPhase 1: Unit Tests with testthat # The pure functions - those that take inputs and return outputs without side effects - were tested with testthat. Three test suites covered the most critical logic:\ntest_strat_sma_cross.R (9 tests): Verified that the strategy correctly identifies crossover events, handles edge cases like flat price data, and produces the expected signal vector structure.\ntest_place_ir_order.R (8 tests): Used httptest2 to mock the Independent Reserve API responses. Tests verified that correct endpoints were called for buys vs sells, that invalid order types were rejected, and that 4xx responses were handled gracefully.\ntest_parse_azure_connection.R (4 tests): Verified the connection string parser correctly extracted the account name and key, including strings with == padding in the base64 key.\n══ Results ════════════════════════════════════ [ FAIL 0 | WARN 0 | SKIP 0 | PASS 21 ] 21 tests, all green.\nPhase 2: End-to-End Local Run in TEST Mode # With unit tests passing, the full crypto_trader.R script was run locally with trading_mode \u0026lt;- \u0026quot;TEST\u0026quot;. This pointed at real Azure Blob Storage and the real Independent Reserve API for price data - but any orders were simulated rather than executed.\nThe end-to-end run confirmed:\nAzure connection and blob read/write working correctly Price history loading and daily downsampling to midnight-to-midnight bars SMA crossover signal generating correctly from live data Stop-loss shield triggering (BTC was ~20% below the test entry price at the time) State persisting correctly back to Azure after each run INFO | Daily bars available: 144 | From: 2025-10-01 | To: 2026-02-21 INFO | Current Price: 96259.9 | Signal: 0 INFO | Current P/L: -20.56% WARN | SHIELD TRIGGERED: Price dropped 10% below entry. Forcing SELL. INFO | FINAL DECISION: SELL INFO | TEST MODE: Market Sell simulated | BTC: 0.0066 | Price: 96259.9 SUCCESS | Bot memory successfully synced to Azure. The full loop - from Azure download to signal generation to simulated execution to state upload - ran cleanly.\nThe Reality of Crypto Markets # The system worked. All the pieces connected. And then came the part that backtests can never fully prepare you for: watching it run in the wild.\nA few things became apparent quickly.\n24/7 markets don\u0026rsquo;t respect daily bars. The SMA crossover strategy was designed around daily closing prices - a concept that doesn\u0026rsquo;t cleanly exist in crypto. Bitcoin trades continuously, which means \u0026ldquo;today\u0026rsquo;s close\u0026rdquo; is an arbitrary snapshot at midnight. A significant move at 11:59 PM and a reversal at 12:01 AM effectively constitutes two different \u0026ldquo;days\u0026rdquo; for the signal, creating a kind of temporal noise that the backtests on historical daily data didn\u0026rsquo;t fully capture.\nVolatility regimes shift dramatically. BTC in a bull market and BTC in a bear market behave like different instruments. The SMA crossover - and most trend-following strategies - performs best during sustained directional moves. Sideways chop with high intraday volatility generates whipsaws: multiple false signals in quick succession, each one costing a transaction fee. In the backtests, these periods were averaged out across 1,000 windows. In live trading, you experience one regime at a time, and if you happen to start during a choppy period, the early results are dispiriting.\nThe psychological dimension is real. A simulated SELL at a 20% loss is a number on a screen. A live SELL at a 20% loss is a different experience entirely. Building a system that you can trust enough to let run without second-guessing every decision requires a level of conviction in the strategy that is hard to maintain through drawdowns - even when the drawdown is well within what the backtests predicted.\nNone of these are criticisms of the approach. They are honest realities of live algorithmic trading in any asset class, and crypto amplifies all of them.\nClosing the Chapter: What Carried Over # The decision to pivot away from crypto wasn\u0026rsquo;t a failure of the system - it was a deliberate choice to apply the same framework to a more suitable market. ASX-listed ETFs offered lower volatility, regular dividends, and market hours that align naturally with a daily bar strategy.\nEverything built across this series carried over directly:\nThe Monte Carlo backtesting framework and CAPS scoring methodology - rerun verbatim on VGS, VAS and GOLD The state management pattern - the same concept of persisting bot_state to cloud storage, now using Google Cloud instead of Azure The price pipeline architecture - the same MD5 deduplication and incremental merge approach, now against IBKR\u0026rsquo;s API instead of Independent Reserve\u0026rsquo;s The structured logging and error handling patterns from logger The discipline of unit testing before going live The tools changed. The thinking didn\u0026rsquo;t.\nIf you\u0026rsquo;ve followed this series from the beginning - from the first Monte Carlo simulation through to a fully deployed cloud trading system - the most important takeaway is this: the value of building things properly compounds. The hours spent on unit tests, state management and error handling might seem disproportionate when you\u0026rsquo;re eager to go live. But they\u0026rsquo;re exactly what allows you to iterate quickly, debug confidently, and ultimately trust what you\u0026rsquo;ve built.\nA new series covering the ASX ETF trading system - from backtesting through to live execution via Interactive Brokers - is coming. The story continues.\nThe full source code for the crypto trading system is available on GitHub.\n","date":"10 April 2026","externalUrl":null,"permalink":"/posts/2026-04-10-building-an-automated-crypto-trader-part-3/","section":"Posts","summary":"","title":"Building an Automated Crypto Trader Part 3: From Signals to Live Orders - Completing the Loop","type":"posts"},{"content":"","date":"10 April 2026","externalUrl":null,"permalink":"/tags/cloud/","section":"Tags","summary":"","title":"Cloud","type":"tags"},{"content":"","date":"10 April 2026","externalUrl":null,"permalink":"/tags/crypto/","section":"Tags","summary":"","title":"Crypto","type":"tags"},{"content":"","date":"10 April 2026","externalUrl":null,"permalink":"/tags/data-pipeline/","section":"Tags","summary":"","title":"Data Pipeline","type":"tags"},{"content":"","date":"10 April 2026","externalUrl":null,"permalink":"/tags/independent-reserve/","section":"Tags","summary":"","title":"Independent-Reserve","type":"tags"},{"content":" Introduction # In my previous post on backtesting crypto trading strategies, I developed a simple moving average crossover strategy for trading Bitcoin. However, backtesting is only half the equation — to actually trade, you need reliable, up-to-date price data.\nManual data collection is error-prone and unsustainable for algorithmic trading. This post walks through how I built a fully automated system to fetch hourly Bitcoin (BTC-AUD) price data from the Independent Reserve exchange and deploy it to Azure for 24/7 operation.\nWhat we\u0026rsquo;ll cover:\nHow the price fetching mechanism works in R Data validation and deduplication techniques Deploying R code to Azure Container Instances Setting up automated hourly scheduling Cost optimization strategies By the end, you\u0026rsquo;ll have a blueprint for building your own cloud-based crypto data pipeline.\nWhy Automate Price Collection? # For any algorithmic trading system, you need:\nConsistent data collection - No gaps in your historical record Reliability - System runs 24/7 without manual intervention Real-time updates - Fresh data for trading decisions Validation - Ensure data quality before using it Manual collection fails on all these fronts. An automated cloud-based system solves these problems while keeping costs minimal (~$6-7/month for hourly data collection).\nThe Architecture # Here\u0026rsquo;s the high-level architecture of the system:\n┌─────────────────┐ │ Azure Logic │──── Triggers every hour at :05 │ Apps │ └────────┬────────┘ │ ▼ ┌─────────────────┐ │ Container │──── Runs R script │ Instance │ └────────┬────────┘ │ ├──────► Independent Reserve API (fetch prices) │ └──────► Azure Blob Storage (read/write data) Key components:\nAzure Container Instances: Runs our R script on-demand Azure Blob Storage: Stores historical price data and logs Azure Logic Apps: Triggers the container every hour Managed Identity: Secure authentication (no hardcoded credentials) Part 1: The R Price Fetching Mechanism # Connecting to Independent Reserve API # Independent Reserve provides a public API for historical trade data. Here\u0026rsquo;s the core function:\n#\u0026#39; Get hourly historical price summary from Independent Reserve get_price_history \u0026lt;- function( pri_curr_code ,sec_curr_code ,number_of_past_hours = 240 ,ir_pub_api_url ) { url \u0026lt;- file.path(ir_pub_api_url, \u0026#34;GetTradeHistorySummary\u0026#34;) # Build the request req \u0026lt;- request(url) %\u0026gt;% req_url_query( primaryCurrencyCode = pri_curr_code ,secondaryCurrencyCode = sec_curr_code ,numberOfHoursInThePastToRetrieve = number_of_past_hours ) %\u0026gt;% req_retry(max_tries = 3) # Perform the request resp \u0026lt;- req_perform(req) # Handle response if (resp_status(resp) == 200) { data_raw \u0026lt;- resp_body_json(resp) # Process into tibble format data \u0026lt;- data_raw$HistorySummaryItems %\u0026gt;% map_dfr(as_tibble) %\u0026gt;% mutate( across( .cols = c(StartTimestampUtc, EndTimestampUtc) ,.fns = ~convert_utc_aest(utc_dttm = .x) ) ,dttm_updated = Sys.time() ) %\u0026gt;% rename( sttm_aest = StartTimestampUtc ,edtm_aest = EndTimestampUtc ) return(data) } else { stop(paste(\u0026#34;Failed to retrieve price history. Status:\u0026#34;, resp_status(resp))) } } Key design decisions:\nAPI limits: Independent Reserve allows fetching up to 240 hours (10 days) of data per request Timezone handling: Convert UTC timestamps to AEST immediately for consistency Retry logic: Automatically retry up to 3 times if the API is busy Error handling: Stop execution if the request fails (logged for debugging) Timezone Conversion # All timestamps from Independent Reserve come in UTC. Since I\u0026rsquo;m trading in Canberra, converting to Australian Eastern time immediately prevents timezone confusion:\nconvert_utc_aest \u0026lt;- function(utc_dttm) { utc_time \u0026lt;- ymd_hms(utc_dttm, tz = \u0026#34;UTC\u0026#34;) with_tz(utc_time, \u0026#34;Australia/Sydney\u0026#34;) } This handles both AEST and AEDT (daylight saving) automatically.\nData Validation and Deduplication # The trickiest part of this system is merging new data with existing historical data while ensuring:\nNo duplicate rows No missing hours (gaps in data) Data integrity Here\u0026rsquo;s how I solve this:\n1. MD5 Hashing for Deduplication\nEach row gets a unique MD5 hash based on all its values (except the update timestamp):\ndata \u0026lt;- data %\u0026gt;% unite( col = \u0026#34;temp_concat\u0026#34; ,-dttm_updated ,remove = FALSE ) %\u0026gt;% mutate(md5_hash = md5(temp_concat)) %\u0026gt;% select(-temp_concat) Why MD5? If Independent Reserve ever corrects historical data (e.g., fixes a trade volume error), the hash will change and we\u0026rsquo;ll detect it. This prevents both duplicates and allows updates.\n2. Smart Merging\nThe merge function identifies new rows by comparing MD5 hashes:\nmerge_price_history \u0026lt;- function( pri_curr_code ,sec_curr_code ,ir_pub_api_url ,base_df ) { # Fetch last 240 hours from API delta_df \u0026lt;- get_price_history( pri_curr_code = pri_curr_code ,sec_curr_code = sec_curr_code ,ir_pub_api_url = ir_pub_api_url ,number_of_past_hours = 240 ) # Find new rows based on MD5 hashes diff_rows_md5 \u0026lt;- setdiff(delta_df$md5_hash, base_df$md5_hash) if (length(diff_rows_md5) \u0026gt; 0) { log_info(\u0026#34;Found \u0026#34;, length(diff_rows_md5), \u0026#34; additional price history rows\u0026#34;) # Extract and merge new rows diff_df \u0026lt;- delta_df %\u0026gt;% filter(md5_hash %in% diff_rows_md5) merged_df \u0026lt;- base_df %\u0026gt;% bind_rows(diff_df) # Validate completeness... return(merged_df) } else { log_info(\u0026#34;No new rows detected\u0026#34;) return(NULL) } } 3. Gap Detection\nAfter merging, we validate that there are no missing hours:\n# Generate expected hourly sequence missing_row \u0026lt;- tibble( sttm_aest = seq( min(merged_df$sttm_aest) ,floor_date(lubridate::now() - hours(1), unit = \u0026#34;hours\u0026#34;) ,by = \u0026#34;hour\u0026#34; ) ) %\u0026gt;% left_join(merged_df, by = \u0026#34;sttm_aest\u0026#34;) %\u0026gt;% filter(if_any(.cols = -sttm_aest, .fns = ~(is.na(.x)))) if (nrow(missing_row) \u0026gt; 1) { log_error(\u0026#34;Missing rows found in price history. Requires manual investigation\u0026#34;) stop() } This ensures data integrity — if there\u0026rsquo;s a gap (e.g., the API was down), the script stops and sends an alert rather than silently producing incomplete data.\nPart 2: Azure Deployment # Why Azure? # I chose Azure over other cloud providers for several reasons:\nManaged Identity: Azure\u0026rsquo;s Managed Identity is simpler than GCP\u0026rsquo;s service accounts Container Instances: Pay-per-execution model (no always-on costs) Logic Apps: Visual workflow designer for scheduling Cost: Running hourly costs ~$0.50/month for compute Prerequisites # Before deploying, you\u0026rsquo;ll need:\nAzure account (free tier available) Docker Desktop installed Azure CLI installed R 4.5+ with required packages Step 1: Containerize Your R Code # Create a Dockerfile in your project root:\n# Use Rocker R base image with R 4.5.2 FROM rocker/r-ver:4.5.2 # Install system dependencies RUN apt-get update \u0026amp;\u0026amp; apt-get install -y \\ libcurl4-openssl-dev \\ libssl-dev \\ libxml2-dev \\ libsodium-dev \\ \u0026amp;\u0026amp; rm -rf /var/lib/apt/lists/* # Set working directory WORKDIR /app # Copy R scripts COPY R/ /app/R/ # Install R packages RUN R -e \u0026#34;install.packages(c(\u0026#39;httr2\u0026#39;, \u0026#39;jsonlite\u0026#39;, \u0026#39;dplyr\u0026#39;, \u0026#39;tidyr\u0026#39;, \\ \u0026#39;lubridate\u0026#39;, \u0026#39;logger\u0026#39;, \u0026#39;openssl\u0026#39;, \u0026#39;AzureStor\u0026#39;, \u0026#39;AzureAuth\u0026#39;, \\ \u0026#39;purrr\u0026#39;, \u0026#39;readr\u0026#39;, \u0026#39;stringr\u0026#39;), repos=\u0026#39;https://cran.rstudio.com/\u0026#39;)\u0026#34; # Set environment variables ENV CRYPTO_TRADING_FOLDER=/app ENV AZURE_CONTAINER_INSTANCE=true # Run the script CMD [\u0026#34;Rscript\u0026#34;, \u0026#34;/app/R/crypto_get_price_history.R\u0026#34;] Key points:\nBase image: rocker/r-ver:4.5.2 ensures consistent R version System dependencies: Required for packages like httr2 and openssl Environment variables: Tell the script it\u0026rsquo;s running in Azure (not locally) Step 2: Azure Resource Setup # Create the necessary Azure resources:\n# Login to Azure az login # Set variables RESOURCE_GROUP=\u0026#34;crypto-trading-rg\u0026#34; LOCATION=\u0026#34;australiaeast\u0026#34; # Sydney data center STORAGE_ACCOUNT=\u0026#34;cryptobtcaud2025\u0026#34; # Must be globally unique CONTAINER_NAME=\u0026#34;crypto-data\u0026#34; SUBSCRIPTION_ID=$(az account show --query id --output tsv) # Create resource group az group create \\ --name $RESOURCE_GROUP \\ --location $LOCATION # Create storage account az storage account create \\ --name $STORAGE_ACCOUNT \\ --resource-group $RESOURCE_GROUP \\ --location $LOCATION \\ --sku Standard_LRS # Create blob container az storage container create \\ --name $CONTAINER_NAME \\ --account-name $STORAGE_ACCOUNT \\ --auth-mode login Step 3: Build and Push Docker Image # Create a container registry and push your image:\n# Create Azure Container Registry REGISTRY_NAME=\u0026#34;cryptotradingregistry\u0026#34; az acr create \\ --name $REGISTRY_NAME \\ --resource-group $RESOURCE_GROUP \\ --location $LOCATION \\ --sku Basic # Enable admin access az acr update --name $REGISTRY_NAME --admin-enabled true # Login az acr login --name $REGISTRY_NAME # Build image docker build -t $REGISTRY_NAME.azurecr.io/crypto-price-fetcher:latest . # Push to registry docker push $REGISTRY_NAME.azurecr.io/crypto-price-fetcher:latest Step 4: Deploy Container Instance # Deploy your containerized R code:\n# Get registry password REGISTRY_PASSWORD=$(az acr credential show \\ --name $REGISTRY_NAME \\ --query \u0026#34;passwords[0].value\u0026#34; \\ --output tsv) # Create container instance az container create \\ --resource-group $RESOURCE_GROUP \\ --name crypto-price-fetcher \\ --image $REGISTRY_NAME.azurecr.io/crypto-price-fetcher:latest \\ --registry-login-server $REGISTRY_NAME.azurecr.io \\ --registry-username $REGISTRY_NAME \\ --registry-password $REGISTRY_PASSWORD \\ --cpu 1 \\ --memory 1.5 \\ --os-type Linux \\ --restart-policy Never \\ --environment-variables \\ AZURE_STORAGE_ACCOUNT=$STORAGE_ACCOUNT \\ AZURE_CONTAINER_NAME=$CONTAINER_NAME \\ --assign-identity [system] Important settings:\n--restart-policy Never: Container runs once and stops (we\u0026rsquo;ll trigger it hourly) --assign-identity [system]: Creates a Managed Identity for secure authentication Environment variables: Tell the R script which Azure storage to use Step 5: Grant Storage Permissions # The container needs permission to read/write to Blob Storage:\n# Get container\u0026#39;s Managed Identity principal ID PRINCIPAL_ID=$(az container show \\ --resource-group $RESOURCE_GROUP \\ --name crypto-price-fetcher \\ --query identity.principalId \\ --output tsv) # Grant Storage Blob Data Contributor role az role assignment create \\ --assignee $PRINCIPAL_ID \\ --role \u0026#34;Storage Blob Data Contributor\u0026#34; \\ --scope /subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Storage/storageAccounts/$STORAGE_ACCOUNT Step 6: Set Up Hourly Scheduling # Use Azure Logic Apps to trigger the container every hour:\nGo to Azure Portal → Create Logic App\nChoose Consumption plan\nIn the designer, add a Recurrence trigger:\nInterval: 1 Frequency: Day Time zone: AUS Eastern Standard Time Schedule hours: [0,1,2,...,23] Schedule minutes: [5] Add an HTTP action:\nMethod: POST URI: https://management.azure.com/subscriptions/{subscriptionId}/resourceGroups/{resourceGroup}/providers/Microsoft.ContainerInstance/containerGroups/crypto-price-fetcher/start?api-version=2023-05-01 Authentication: Managed Identity Audience: https://management.azure.com/ Enable the Logic App\u0026rsquo;s Managed Identity and grant it permissions:\n# Get Logic App principal ID LOGIC_APP_PRINCIPAL_ID=$(az resource show \\ --resource-group $RESOURCE_GROUP \\ --name crypto-price-fetcher-scheduler \\ --resource-type Microsoft.Logic/workflows \\ --query identity.principalId \\ --output tsv) # Grant Contributor role on container az role assignment create \\ --assignee $LOGIC_APP_PRINCIPAL_ID \\ --role \u0026#34;Contributor\u0026#34; \\ --scope /subscriptions/$SUBSCRIPTION_ID/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.ContainerInstance/containerGroups/crypto-price-fetcher Now the system runs automatically every hour at 5 minutes past (00:05, 01:05, 02:05, etc.).\nAuthentication: Local vs Cloud # One elegant aspect of this setup is how authentication works differently in local vs cloud environments:\n# Detect environment is_azure \u0026lt;- Sys.getenv(\u0026#34;AZURE_CONTAINER_INSTANCE\u0026#34;) != \u0026#34;\u0026#34; || Sys.getenv(\u0026#34;WEBSITE_INSTANCE_ID\u0026#34;) != \u0026#34;\u0026#34; # Set timezone for Azure (containers default to UTC) if (is_azure) { Sys.setenv(TZ = \u0026#34;Australia/Sydney\u0026#34;) } # Authenticate get_azure_container \u0026lt;- function(is_azure_cloud = FALSE) { if (!is_azure_cloud) { # LOCAL: Use connection string from .Renviron conn_str \u0026lt;- Sys.getenv(\u0026#34;AZURE_STORAGE_CONNECTION_STRING\u0026#34;) creds \u0026lt;- parse_azure_connection(conn_str) blob_endpoint \u0026lt;- blob_endpoint( endpoint = creds$endpoint ,key = creds$account_key ) } else { # AZURE CLOUD: Use Managed Identity (no credentials needed) token_url \u0026lt;- \u0026#34;http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01\u0026amp;resource=https://storage.azure.com/\u0026#34; token_response \u0026lt;- httr2::request(token_url) %\u0026gt;% httr2::req_headers(\u0026#34;Metadata\u0026#34; = \u0026#34;true\u0026#34;) %\u0026gt;% httr2::req_perform() %\u0026gt;% httr2::resp_body_json() access_token \u0026lt;- token_response$access_token blob_endpoint \u0026lt;- blob_endpoint( endpoint = paste0(\u0026#34;https://\u0026#34;, Sys.getenv(\u0026#34;AZURE_STORAGE_ACCOUNT\u0026#34;), \u0026#34;.blob.core.windows.net/\u0026#34;) ,token = access_token ) } container_name \u0026lt;- Sys.getenv(\u0026#34;AZURE_CONTAINER_NAME\u0026#34;) container \u0026lt;- storage_container(blob_endpoint, container_name) return(container) } Local development: Use a connection string stored in .Renviron\nAzure cloud: Use the container\u0026rsquo;s Managed Identity to get a token from Azure\u0026rsquo;s metadata service — no secrets needed!\nLogging and Monitoring # Structured Logging # The R script logs all activities:\nlog_info(\u0026#34;--- GETTING HISTORICAL PRICES START ---\u0026#34;) log_info(\u0026#34;Running on \u0026#34;, if (is_azure) \u0026#34;Azure\u0026#34; else \u0026#34;Local machine\u0026#34;) log_success(\u0026#34;Retrieved xbt_aud_price_history.rds [Rows: \u0026#34;, nrow(base_history), \u0026#34;]\u0026#34;) log_info(\u0026#34;Found \u0026#34;, length(diff_rows_md5), \u0026#34; additional price history rows\u0026#34;) log_success(\u0026#34;Price history update cycle complete.\u0026#34;) log_info(\u0026#34;--- GETTING HISTORICAL PRICES END ---\u0026#34;) Logs are uploaded to Azure Blob Storage:\nlogs/ crypto_get_price_history/ 2026-02-07/ get_price_history_log.log 2026-02-08/ get_price_history_log.log Email Alerts # I\u0026rsquo;ve configured the Logic App to send email alerts on failures:\nAdd a Send an email action (Outlook/Gmail) Configure it to run only when the HTTP action fails Include error details and timestamp in AEDT This way, I get notified immediately if something breaks.\nCost Breakdown # Here\u0026rsquo;s what this system actually costs per month:\nService Cost Notes Container Instances ~$0.50 720 executions × ~2 seconds each Blob Storage ~$0.20 Small data files + logs Logic App ~$0.60 720 workflow executions Container Registry ~$5.00 Basic tier (stores Docker image) Total ~$6.30/month Cost optimization tips:\nDelete the registry between updates: Since the container pulls the image on each start, you need to keep the registry. However, if you\u0026rsquo;re not updating code frequently, you could delete it temporarily (saves $5/month), then recreate it when needed.\nUse Azure\u0026rsquo;s free tier: New Azure accounts get $200 credit for 30 days, which covers months of operation.\nReduce execution time: The faster your R script runs, the less you pay. Profile your code and optimize bottlenecks.\nFor comparison, running this on a always-on VM would cost $20-50/month. The serverless approach saves 80-90%.\nLessons Learned # 1. Timezone Headaches # Initially, I didn\u0026rsquo;t set the timezone in Azure containers, so all logs showed UTC timestamps. Adding this one line fixed it:\nif (is_azure) Sys.setenv(TZ = \u0026#34;Australia/Sydney\u0026#34;) Now all timestamps are in AEDT/AEST, making debugging much easier.\n2. MD5 Hashing is Essential # My first version used simple timestamp-based deduplication. This failed when Independent Reserve corrected historical data (they occasionally fix erroneous trades). MD5 hashing detects both duplicates AND changes to existing rows.\n3. Managed Identity Propagation # Azure role assignments can take 1-2 minutes to propagate. After granting permissions, wait before testing, or you\u0026rsquo;ll get mysterious 403 errors.\nNext Steps # This price collection system is the foundation for my trading bot. The next steps are:\nBuild the trading execution script - Use the same Azure architecture to execute trades based on signals Implement monitoring dashboards - Azure Application Insights for real-time metrics Add data quality checks - Detect anomalies (e.g., price spikes due to API errors) The beauty of this architecture is that it\u0026rsquo;s modular—I can add new containers for different tasks (backtesting, signal generation, trade execution) all communicating via Blob Storage.\nConclusion # Building an automated crypto data pipeline taught me several valuable lessons:\nCloud-native R is viable: With containers, R works great in production Serverless saves money: Pay-per-execution beats always-on VMs Data validation is critical: Never trust API data blindly Azure Managed Identity is elegant: No secrets management needed The entire system costs less than a Netflix subscription while providing reliable, validated hourly data for algorithmic trading.\nIf you\u0026rsquo;re building similar systems, I hope this guide saves you the trial-and-error I went through. The full code is available on GitHub (coming soon).\nHave questions or suggestions? Leave a comment or reach out on LinkedIn.\n","date":"11 February 2026","externalUrl":null,"permalink":"/posts/2026-02-11-automating-crypto-price-collection-azure/","section":"Posts","summary":"","title":"Building an Automated Crypto Trader Part 2: Automating Crypto Price Collection with R and Azure","type":"posts"},{"content":"","date":"19 December 2025","externalUrl":null,"permalink":"/tags/backtesting/","section":"Tags","summary":"","title":"Backtesting","type":"tags"},{"content":"Welcome to the first entry in a multi-part series where I document the journey of building a fully automated, production-grade cryptocurrency trading system from scratch using R, primarily focussed on BTC-AUD.\nBefore we write a single line of execution code or connect to an exchange API, we have to answer the most important question in quantitative finance: Does this strategy actually work, or is it just a fluke of historical noise?\nThe Philosophy: Survival of the Fittest # Many beginners pick one strategy (like an SMA Cross), run it on one year of Bitcoin data, and if the \u0026ldquo;line goes up,\u0026rdquo; they go live. This is a recipe for disaster.\nIn this project, we utilise a Monte Carlo Windowing approach. If you find the term \u0026ldquo;Monte Carlo\u0026rdquo; a bit too \u0026ldquo;Wall Street,\u0026rdquo; think of it this way:\nImagine 1,000 different traders all trading BTC-AUD, with a starting initial of $1,000 each. Each trader starts at a completely different point in time and trades for a different length of duration. Now, imagine every single one of those 1,000 traders is testing all 8 of our algorithms across every possible stop-loss setting.\nBy doing this, we aren\u0026rsquo;t just testing if a strategy works \u0026ldquo;on average\u0026rdquo;; we are testing if it survives in the hands of the \u0026ldquo;unlucky\u0026rdquo; trader who started right before a crash, or the \u0026ldquo;impatient\u0026rdquo; trader who only stayed in the market for three months. This allows us to see the distribution of outcomes across thousands of different market regimes.\nThe Candidates: The Algorithms Under the Microscope # To find our \u0026ldquo;Champion\u0026rdquo; strategy, we are testing a diverse range of algorithmic approaches. Each is designed to handle price action differently, from simple trend following to complex volatility-normalised momentum.\nBuy and Hold (The Benchmark)\nThis is our baseline. It involves buying the asset at the start of the window and holding it until the end, regardless of price fluctuations. Any strategy we build must significantly outperform this after accounting for risk and fees to be considered viable.\nSMA (The Simple Trend Follower)\nThe Simple Moving Average (SMA) is a pure trend-following strategy.\nThe Signal: We buy when the price moves above the SMA and sell when it drops below. The Goal: Detect trend direction changes early. It performs beautifully in trending markets but can suffer from \u0026ldquo;whipsaws\u0026rdquo; during sideways movement. SMA Crossover (The Dual-Window Filter)\nThis variant uses two SMAs: a \u0026ldquo;Fast\u0026rdquo; (short-term) and a \u0026ldquo;Slow\u0026rdquo; (long-term).\nThe Signal: We buy when the Fast SMA crosses above the Slow SMA (a Golden Cross) and sell when it falls below. The Goal: By using a second average as a filter, we aim to ignore minor price noise and capture the \u0026ldquo;meat\u0026rdquo; of a larger trend. RSI (The Momentum Gauge)\nThe Relative Strength Index (RSI) measures the speed and change of price movements, ranging from 0 to 100.\nThe Signal: We buy when RSI recovers from an \u0026ldquo;oversold\u0026rdquo; state (below 30) and sell when it retreats from an \u0026ldquo;overbought\u0026rdquo; state (above 70). The Goal: To identify exhausted price moves and profit from mean reversion or momentum shifts. Bollinger Bands (The Volatility Envelope)\nBollinger Bands track volatility by placing bands at a set number of standard deviations away from a moving average. -The Signal: We buy when the price drops below the lower band (oversold) and sell when it breaches the upper band (overbought).\nThe Signal: We buy when the price drops below the lower band (oversold) and sell when it breaches the upper band (overbought). The Goal: To trade the \u0026ldquo;rebound\u0026rdquo; when prices reach extreme statistical dispersals. MACD (The Momentum Oscillator)\nThe Moving Average Convergence Divergence (MACD) tracks the relationship between two exponential moving averages.\nThe Signal: We buy when the MACD line crosses above its Signal line (the zero-crossover of the histogram). The Goal: To catch momentum shifts faster than simple SMAs. While responsive, it can produce false breakouts during high-volatility periods. MACD-V (The Volatility-Normalised Hybrid)\nAuthored by Alex Spiroglou, MACD-V normalises the MACD line by dividing it by the Average True Range (ATR).\nThe Signal: It uses fixed \u0026ldquo;strength thresholds\u0026rdquo; (±50). We trade only when the momentum is within specific zones. The Goal: To transform raw momentum into \u0026ldquo;relative momentum,\u0026rdquo; making the indicator more robust across different volatility regimes. MACD-V Dynamic (The Adaptive Strategy)\nThis is an evolution of the MACD-V that replaces fixed thresholds with adaptive ones based on rolling quantiles.\nThe Signal: Signals are only executed if the current momentum is in the top 20% of recent histogram values. The Goal: To filter out low-conviction signals and only enter trades when the market exhibits historically significant strength. Tuning the Machine: The Parameters # An algorithm is only as good as its settings. In our backtesting engine, we don\u0026rsquo;t just test the logic; we test the parameters that define the strategy\u0026rsquo;s \u0026ldquo;personality\u0026rdquo;.\nA strategy with a \u0026ldquo;short\u0026rdquo; lookback period is aggressive and twitchy - it catches moves early but gets fooled by noise. A \u0026ldquo;long\u0026rdquo; lookback is conservative and smooth - it stays in trends longer but reacts slowly to sudden crashes.\nFor this project, we explored a massive parameter grid:\nLookback Periods: Ranging from short-term bursts (12–14 periods) to long-term foundations (250 periods for SMAs).\nThresholds: The RSI 30/70 bands and the MACD-V \u0026ldquo;Strength Threshold\u0026rdquo; of 50.\nStatistical Deviations: The standard deviation multiplier (usually 2.0) for Bollinger Bands.\nAdaptive Quantiles: In the Dynamic MACD-V, we utilise an 80% quantile over a 20-period rolling window to ensure we only act on the most significant momentum.\nStop Losses: Every algorithm is being tested with no stop loss, and also with the following: 2%, 5%, 10% and 20%.\nFinding the \u0026ldquo;Goldilocks\u0026rdquo; zone - where the parameters are sensitive enough to profit but robust enough to survive - is the primary goal of this entire simulation.\nTechnical Challenge: The Memory Wall # When running 185,000+ backtest permutations (Strategies x Parameters x Windows), R users often hit a \u0026ldquo;Memory Wall\u0026rdquo;. Storing every trade and equity curve for every simulation can easily exceed 64GB of RAM.\nTo solve this, I implemented a \u0026ldquo;Chunked Parallel\u0026rdquo; architecture. We process one stop-loss setting at a time, parallelise the backtests within that chunk, summarise the results into tiny performance metrics, and then aggressively clear the memory.\nExample:\n# The \u0026#39;Memory-Safe\u0026#39; Loop Pattern for (sl in stop_losses) { message(\u0026#34;Starting Monte Carlo for Stop Loss: \u0026#34;, sl * 100, \u0026#34;%\u0026#34;) # 1. Parallel execution of backtests for this specific chunk results_chunk \u0026lt;- future_pmap(mc_param_grid_sl, backtest_worker_function) # 2. Extract metrics (reduces data size by 99%) summary_chunk \u0026lt;- get_performance_metrics(results_chunk, initial_equity = 1000) # 3. Store the small summary and PURGE the large results object all_summaries[[paste0(\u0026#34;SL\u0026#34;, sl)]] \u0026lt;- summary_chunk # CRITICAL: Clear the massive result list and call garbage collector rm(results_chunk) gc(verbose = FALSE) } Measuring Success: The CAPS Score # Total return (CAGR) is a \u0026ldquo;vanity metric\u0026rdquo;. If a strategy makes 100% return but has an 80% drawdown, most traders will quit long before they see the profit.\nTo find the truly \u0026ldquo;robust\u0026rdquo; strategy, I developed the Calmar-adjusted Probability Score (CAPS). It is a weighted average of:\nGeometric Mean CAGR: How much we actually make.\nWin Rate (by Window): How often the strategy is profitable across different time periods.\nMedian Sharpe Ratio: Risk-adjusted consistency.\nCVaR Drawdown: The \u0026ldquo;Tail Risk\u0026rdquo; (the average of the worst-case drawdowns).\nThe purpose of this metric is to penalise high risk, high reward strategies (like Buy \u0026amp; Hold) and, at the same time, reward strategies that effectively manage downside risk and maintain consistency.\nVisualising the Efficient Frontier # By plotting our Tail Risk against our Return, we can see the \u0026ldquo;Efficient Frontier\u0026rdquo;. We are ideally looking for strategies in the top-left corner: high returns with low tail risk.\nHowever, a word of realism: In practice, it is incredibly rare to find a strategy that sits comfortably in that top-left corner. Finance is a game of trade-offs; usually, the strategies with the highest returns come with a \u0026ldquo;stinging\u0026rdquo; tail risk, while the safest strategies often barely beat the bank\u0026rsquo;s interest rate. Our goal isn\u0026rsquo;t necessarily to find a \u0026ldquo;magic bullet,\u0026rdquo; but to find the strategy that pushes the boundary of what is statistically possible.\nTo make the chart more intuitive, we map the point size to the CAPS Score - the bigger the bubble, the more robust the strategy.\nHere is the output for the top 5 (by CAPS) algorithms:\nstrategy_type stop_loss geo_mean_CAGR win_rate_windows median_Sharpe cvar_drawdown n_samples robust_calmar prob_score CAPS sma_cross 0.10 0.6220032 0.971 0.99870 0.657136 1000 0.9465364 0.9697377 0.9178921 macd 0.00 0.5591531 0.978 0.93535 0.561100 1000 0.9965303 0.9147723 0.9115983 macd 0.15 0.5458585 0.975 0.92975 0.561100 1000 0.9728364 0.9065062 0.8818823 macd 0.05 0.5062554 0.981 0.93435 0.539100 1000 0.9390752 0.9165974 0.8607538 buy_hold 0.00 0.7889268 0.979 0.85025 0.823200 1000 0.9583659 0.8323947 0.7977387 macd 0.02 0.4829502 0.988 0.94685 0.569642 1000 0.8478136 0.9354878 0.7931192 Based on the table above, we can conclude SMA Crossover with a 10% stop loss is the most suitable strategy that is consistent, and manages both risk and return well.\nYou may find the entire backtesting code here.\nWhat\u0026rsquo;s Next? # We now have a trading strategy.\nIn Part 2, we will dive into creating a trading bot: moving from historical data to live trading data.\nStay tuned - the maths is done, now it is time to build the machine.\n","date":"19 December 2025","externalUrl":null,"permalink":"/posts/2025-12-19-building-an-automated-crypto-trader-part-1-survival-of-the-fittest-backtesting/","section":"Posts","summary":"","title":"Building an Automated Crypto Trader Part 1: Survival of the Fittest (Backtesting)","type":"posts"},{"content":"","date":"19 December 2025","externalUrl":null,"permalink":"/tags/efficient-frontier/","section":"Tags","summary":"","title":"Efficient Frontier","type":"tags"},{"content":"","date":"19 December 2025","externalUrl":null,"permalink":"/tags/monte-carlo/","section":"Tags","summary":"","title":"Monte Carlo","type":"tags"},{"content":"","date":"19 December 2025","externalUrl":null,"permalink":"/tags/performance-metrics/","section":"Tags","summary":"","title":"Performance Metrics","type":"tags"},{"content":" Introduction # This privacy policy describes how your personal information is collected, used, and shared when you visit https://sactyr.github.io/.\nCookies and Web Beacons # We use cookies to store information about visitors\u0026rsquo; preferences and to record user-specific information on which pages the user accesses or visits.\nGoogle AdSense # Third-party vendors, including Google, use cookies to serve ads based on a user\u0026rsquo;s prior visits to your website or other websites. Google\u0026rsquo;s use of advertising cookies enables it and its partners to serve ads to your users based on their visit to your sites and/or other sites on the Internet. Users may opt out of personalized advertising by visiting Ads Settings. Contact # If you have any questions, contact me via LinkedIn\n","date":"18 December 2025","externalUrl":null,"permalink":"/privacy/","section":"","summary":"","title":"Privacy Policy","type":"page"},{"content":" First post # Hello World. EOM.\n","date":"17 November 2025","externalUrl":null,"permalink":"/posts/2025-11-17_hello_world/","section":"Posts","summary":"","title":"Hello World.","type":"posts"},{"content":"","date":"17 November 2025","externalUrl":null,"permalink":"/categories/shorts/","section":"Categories","summary":"","title":"Shorts","type":"categories"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"Below is a list of my active public repositories, pulled directly from my GitHub profile using GitHub Actions.\nsactyr.github.io July 2026 sactyr/sactyr.github.io Personal Blog, made with Hugo \u0026amp; Blowfish HTML 0 0 local-llm-r July 2026 sactyr/local-llm-r R 0 0 ibkrcp June 2026 sactyr/ibkrcp ibkrcp is a lightweight R client for the Interactive Brokers Client Portal REST API. R 0 0 quant_trading May 2026 sactyr/quant_trading Quantitative trading system for ASX ETFs and other financial instruments R 0 0 crypto_trading April 2026 sactyr/crypto_trading Fully automated crypto trading bot in R R 0 0 noise_generator May 2025 sactyr/noise_generator Generate white and pink noise programatically R 0 0 ","externalUrl":null,"permalink":"/projects/","section":"Projects","summary":"","title":"Projects","type":"projects"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"}]