diff --git a/app/javascript/controllers/time_series_bar_chart_controller.js b/app/javascript/controllers/time_series_bar_chart_controller.js index 9a9f80c..44a6d59 100644 --- a/app/javascript/controllers/time_series_bar_chart_controller.js +++ b/app/javascript/controllers/time_series_bar_chart_controller.js @@ -1,49 +1,49 @@ -import { Controller } from "@hotwired/stimulus" -import tailwindColors from "@maybe/tailwindcolors" -import * as d3 from "d3" +import { Controller } from "@hotwired/stimulus"; +import tailwindColors from "@maybe/tailwindcolors"; +import * as d3 from "d3"; export default class extends Controller { - static values = { series: Object, data: Array, useLabels: { type: Boolean, default: true } } + static values = { series: Object, data: Array, useLabels: { type: Boolean, default: true } }; - #initialElementWidth = 0 - #initialElementHeight = 0 - #d3TooltipMemo = null - #d3GroupMemo = null - #d3SvgMemo = null - #data = [] + #initialElementWidth = 0; + #initialElementHeight = 0; + #d3TooltipMemo = null; + #d3GroupMemo = null; + #d3SvgMemo = null; + #data = []; connect() { - this.#install() - document.addEventListener("turbo:load", this.#reinstall) + this.#install(); + document.addEventListener("turbo:load", this.#reinstall); } disconnect() { - this.#teardown() - document.removeEventListener("turbo:load", this.#reinstall) + this.#teardown(); + document.removeEventListener("turbo:load", this.#reinstall); } #reinstall = () => { - this.#teardown() - this.#install() - } + this.#teardown(); + this.#install(); + }; #teardown() { - this.#d3TooltipMemo = null - this.#d3GroupMemo = null - this.#d3SvgMemo = null + this.#d3TooltipMemo = null; + this.#d3GroupMemo = null; + this.#d3SvgMemo = null; - this.#d3Element.selectAll("*").remove() + this.#d3Element.selectAll("*").remove(); } #install() { - this.#rememberInitialElementSize() - this.#drawGridlines() - this.#drawBarChart() + this.#rememberInitialElementSize(); + this.#drawGridlines(); + this.#drawBarChart(); if (this.useLabelsValue) { - this.#drawXAxis() - this.#drawLegend() + this.#drawXAxis(); + this.#drawLegend(); } - this.#installTooltip() + this.#installTooltip(); } // Normalize data when it is set @@ -51,33 +51,33 @@ export default class extends Controller { this.#data = value.map(d => ({ ...d, date: new Date(d.date), - })) + })); } #rememberInitialElementSize() { - this.#initialElementWidth = this.element.clientWidth - this.#initialElementHeight = this.element.clientHeight + this.#initialElementWidth = this.element.clientWidth; + this.#initialElementHeight = this.element.clientHeight; } get #contentWidth() { - return this.#initialElementWidth - this.#margin.left - this.#margin.right + return this.#initialElementWidth - this.#margin.left - this.#margin.right; } get #contentHeight() { - return this.#initialElementHeight - this.#margin.top - this.#margin.bottom + return this.#initialElementHeight - this.#margin.top - this.#margin.bottom; } get #margin() { if (this.useLabelsValue) { - return { top: 10, right: 0, bottom: 40, left: 0 } + return { top: 10, right: 0, bottom: 40, left: 0 }; } else { - return { top: 0, right: 0, bottom: 0, left: 0 } + return { top: 0, right: 0, bottom: 0, left: 0 }; } } #drawBarChart() { - const x = this.#d3XScale - const y = this.#d3YScale + const x = this.#d3XScale; + const y = this.#d3YScale; // Append a group for each series, and a rect for each element in the series. this.#d3Content @@ -90,7 +90,7 @@ export default class extends Controller { .data(D => D.map(d => (d.key = D.key, d))) .join("path") .attr("d", (d, i) => { - const isTopSeries = Object.keys(this.seriesValue).reverse()[0] === d.key + const isTopSeries = Object.keys(this.seriesValue).reverse()[0] === d.key; return this.#rectPathWithRadius({ x: x(d.data.date), @@ -98,7 +98,7 @@ export default class extends Controller { width: x.bandwidth(), height: y(d[0]) - y(d[1]), radius: !isTopSeries ? 0 : Math.min(x.bandwidth() / 4, 10), - }) + }); }); } @@ -118,12 +118,12 @@ export default class extends Controller { const axisGenerator = d3.axisRight(this.#d3YScale) .ticks(10) .tickSize(this.#contentWidth) - .tickFormat("") + .tickFormat(""); const gridlines = this.#d3Content .append("g") .attr("class", "d3gridlines") - .call(axisGenerator) + .call(axisGenerator); gridlines .selectAll("line") @@ -134,40 +134,40 @@ export default class extends Controller { gridlines .select(".domain") - .remove() + .remove(); } #drawXAxis() { - const formattedDateToday = d3.timeFormat("%d %b %Y")(new Date()) + const formattedDateToday = d3.timeFormat("%d %b %Y")(new Date()); const axisGenerator = d3.axisBottom(this.#d3XScale) .tickValues([ this.#data[0].date, this.#data[this.#data.length - 1].date ]) .tickSize(0) .tickFormat((date) => { - const formattedDate = d3.timeFormat("%d %b %Y")(date) - return formattedDate === formattedDateToday ? "Today" : formattedDate - }) + const formattedDate = d3.timeFormat("%d %b %Y")(date); + return formattedDate === formattedDateToday ? "Today" : formattedDate; + }); const axis = this.#d3Content .append("g") .attr("transform", `translate(0, ${this.#contentHeight - this.#margin.bottom / 2 - 6})`) - .call(axisGenerator) + .call(axisGenerator); axis .select(".domain") - .remove() + .remove(); axis .selectAll(".tick text") .style("fill", tailwindColors.gray[500]) .style("font-size", "14px") .style("font-weight", "400") - .attr("text-anchor", (_, i) => i === 0 ? "start" : "end") + .attr("text-anchor", (_, i) => i === 0 ? "start" : "end"); } #drawLegend() { const legend = this.#d3Content - .append("g") + .append("g"); let offsetX = 0; Object.values(this.seriesValue).forEach((series, i) => { @@ -180,7 +180,7 @@ export default class extends Controller { .attr("width", 4) .attr("class", series.fillClass) .attr("rx", 2) - .attr("ry", 2) + .attr("ry", 2); item.append("text") .attr("x", 10) @@ -189,14 +189,14 @@ export default class extends Controller { .style("fill", tailwindColors.gray[900]) .style("font-size", "14px") .style("font-weight", "400") - .text(series.name) + .text(series.name); const itemWidth = item.node().getBBox().width; offsetX += itemWidth + 12; - }) + }); const legendWidth = legend.node().getBBox().width; - legend.attr("transform", `translate(${this.#contentWidth/2 - legendWidth/2}, ${this.#contentHeight})`) + legend.attr("transform", `translate(${this.#contentWidth/2 - legendWidth/2}, ${this.#contentHeight})`); } #installTooltip() { @@ -207,10 +207,10 @@ export default class extends Controller { .attr("fill", "none") .attr("pointer-events", "all") .on("mousemove", (event) => { - const x = this.#d3XScale - const d = this.#findDatumByPointer(event) + const x = this.#d3XScale; + const d = this.#findDatumByPointer(event); - this.#d3Content.selectAll(".guideline").remove() + this.#d3Content.selectAll(".guideline").remove(); this.#d3Content .insert("path", ":first-child") @@ -222,31 +222,31 @@ export default class extends Controller { width: x.bandwidth() + x.step() / 4, height: this.#contentHeight - this.#margin.bottom, radius: Math.min(x.bandwidth() / 4, 10), - })) + })); this.#d3Tooltip .html(this.#tooltipTemplate(d)) .style("opacity", 1) .style("z-index", 999) .style("left", this.#tooltipLeft(event) + "px") - .style("top", event.pageY - 10 + "px") + .style("top", event.pageY - 10 + "px"); }) .on("mouseout", (event) => { - const hoveringOnGuideline = event.toElement?.classList.contains("guideline") + const hoveringOnGuideline = event.toElement?.classList.contains("guideline"); if (!hoveringOnGuideline) { - this.#d3Content.selectAll(".guideline").remove() - this.#d3Tooltip.style("opacity", 0) + this.#d3Content.selectAll(".guideline").remove(); + this.#d3Tooltip.style("opacity", 0); } - }) + }); } #tooltipLeft(event) { - const estimatedTooltipWidth = 250 - const pageWidth = document.body.clientWidth - const tooltipX = event.pageX + 10 - const overflowX = tooltipX + estimatedTooltipWidth - pageWidth - const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX - return adjustedX + const estimatedTooltipWidth = 250; + const pageWidth = document.body.clientWidth; + const tooltipX = event.pageX + 10; + const overflowX = tooltipX + estimatedTooltipWidth - pageWidth; + const adjustedX = overflowX > 0 ? event.pageX - overflowX - 20 : tooltipX; + return adjustedX; } #tooltipTemplate(datum) { @@ -283,54 +283,54 @@ export default class extends Controller { `).join("") } - `) + `); } get #d3Tooltip() { - if (this.#d3TooltipMemo) return this.#d3TooltipMemo + if (this.#d3TooltipMemo) return this.#d3TooltipMemo; return this.#d3TooltipMemo = this.#d3Element .append("div") .attr("class", "absolute text-sm bg-white border border-alpha-black-100 p-2 rounded-lg") .style("pointer-events", "none") - .style("opacity", 0) + .style("opacity", 0); } get #d3Content() { - if (this.#d3GroupMemo) return this.#d3GroupMemo + if (this.#d3GroupMemo) return this.#d3GroupMemo; return this.#d3GroupMemo = this.#d3Svg .append("g") - .attr("transform", `translate(${this.#margin.left},${this.#margin.top})`) + .attr("transform", `translate(${this.#margin.left},${this.#margin.top})`); } get #d3Svg() { - if (this.#d3SvgMemo) return this.#d3SvgMemo + if (this.#d3SvgMemo) return this.#d3SvgMemo; this.#d3SvgMemo = this.#d3Element .append("svg") .attr("width", this.#initialElementWidth) .attr("height", this.#initialElementHeight) - .attr("viewBox", [ 0, 0, this.#initialElementWidth, this.#initialElementHeight ]) + .attr("viewBox", [ 0, 0, this.#initialElementWidth, this.#initialElementHeight ]); this.#d3SvgMemo.append("defs") .append("clipPath") .attr("id", "rounded-top") .append("path") - .attr("d", "M0,10 Q0,0 10,0 H90 Q100,0 100,10 V100 H0 Z") + .attr("d", "M0,10 Q0,0 10,0 H90 Q100,0 100,10 V100 H0 Z"); - return this.#d3SvgMemo + return this.#d3SvgMemo; } get #d3Element() { - return d3.select(this.element) + return d3.select(this.element); } get #d3Series() { const stack = d3.stack() - .keys(Object.keys(this.seriesValue)) + .keys(Object.keys(this.seriesValue)); - return stack(this.#data) + return stack(this.#data); } get #d3XScale() { @@ -343,17 +343,17 @@ export default class extends Controller { get #d3YScale() { return d3.scaleLinear() .domain([0, d3.max(this.#d3Series, d => d3.max(d, d => d[1]))]) - .rangeRound([this.#contentHeight - this.#margin.bottom, this.#margin.top]) + .rangeRound([this.#contentHeight - this.#margin.bottom, this.#margin.top]); } #findDatumByPointer(event) { - const x = this.#d3XScale - const [xPos] = d3.pointer(event) + const x = this.#d3XScale; + const [xPos] = d3.pointer(event); - const index = Math.floor((xPos - x.bandwidth() / 2) / x.step()) + const index = Math.floor((xPos - x.bandwidth() / 2) / x.step()); - if (index < 0) return this.#data[0] - if (index >= this.#data.length) return this.#data[this.#data.length - 1] - return this.#data[index] + if (index < 0) return this.#data[0]; + if (index >= this.#data.length) return this.#data[this.#data.length - 1]; + return this.#data[index]; } } diff --git a/app/presenters/tool/presenter/stock_portfolio_backtest.rb b/app/presenters/tool/presenter/stock_portfolio_backtest.rb index 87319a9..2e6231f 100644 --- a/app/presenters/tool/presenter/stock_portfolio_backtest.rb +++ b/app/presenters/tool/presenter/stock_portfolio_backtest.rb @@ -55,12 +55,31 @@ def active_record @active_record ||= Tool.find_by! slug: "stock-portfolio-backtest" end + def portfolio_trend_by_date + @portfolio_trend_by_date ||= unique_dates.map do |date| + {}.tap do |h| + h[:yearMonth] = date.strftime("%b %Y") + h[:year] = date.year.to_s + h[:month] = date.month.to_s.rjust(2, "0") + h[:date] = date.iso8601 + h[:benchmark] = benchmark_value_at(date) + h[:portfolio] = portfolio_value_at(date) + end + end + end + def unique_dates @unique_dates ||= ohclv_data.flat_map do |data| data[:prices].map { |price| price["date"].to_date } end.uniq.sort end + # `ohclv_data` -> [ + # { + # ticker: "AAPL", + # prices: [{ "date": "2024-09-03", "open": 228.55, "close": 222.77, "high": 229, "low": 221.17, "volume": 49286866 }] + # }, ... + # ] def ohclv_data @ohclv_data ||= begin tickers = stocks + [ benchmark_stock ] @@ -73,52 +92,55 @@ def ohclv_data interval: "month", limit: 500 - { ticker: response.ticker, prices: response.prices } + if response.success? + { ticker: ticker, prices: response.prices } + else + { ticker: ticker, prices: [] } + end end end end - def portfolio_trend_by_date - @portfolio_trend_by_date ||= unique_dates.map do |date| - {}.tap do |h| - h[:yearMonth] = date.strftime("%b %Y") - h[:year] = date.year.to_s - h[:month] = date.month.to_s.rjust(2, "0") - h[:date] = date.iso8601 - h[:benchmark] = benchmark_value_at(date) - h[:portfolio] = portfolio_value_at(date) - end + def benchmark_value_at(date) + stock_shares(benchmark_stock, 1) * ohclv_at(date, stock: benchmark_stock)["close"] + end + + def stock_shares(stock, allocation) + initial_price = first_known_closing_price_for(stock) + + if initial_price.zero? + 0.0 + else + investment_amount * allocation / initial_price end end - def benchmark_value_at(date) - benchmark_shares * ohlc_at(date, stock: benchmark_stock)["close"] + def first_known_closing_price_for(stock) + ohclv = ohclvs_for(stock).find { |price| price["close"].present? } || null_ohclv + ohclv["close"] end - def benchmark_shares - investment_amount / initial_stock_price(benchmark_stock) + def ohclv_at(date, stock:) + ohclvs_for(stock).find { |price| price["date"].to_date == date } || null_ohclv(at: date) end - def initial_stock_price(stock) - ohclv_data.find { |data| data[:ticker] == stock }[:prices].first["close"] + def ohclvs_for(ticker) + ohclv_data.find { |data| data[:ticker] == ticker }[:prices] end - def ohlc_at(date, stock:) - all_ohlc = ohclv_data.find { |data| data[:ticker] == stock }[:prices] - all_ohlc.find { |price| price["date"].to_date == date } + def null_ohclv(at: unique_dates.first) + { "date" => at.iso8601, "open" => 0.0, "close" => 0.0, "high" => 0.0, "low" => 0.0, "volume" => 0.0 } end def portfolio_value_at(date) stocks.reduce(0.0) do |sum, stock| - sum + portfolio_shares_by_ticker[stock] * ohlc_at(date, stock: stock)["close"] + sum + portfolio_shares_by_ticker[stock] * ohclv_at(date, stock: stock)["close"] end end def portfolio_shares_by_ticker @portfolio_shares_by_ticker ||= stocks.zip(stock_allocations).map do |stock, allocation| - shares = investment_amount * allocation / initial_stock_price(stock) - - [ stock, shares ] + [ stock, stock_shares(stock, allocation) ] end.to_h end end diff --git a/test/presenters/tool/presenter/stock_portfolio_backtest_test.rb b/test/presenters/tool/presenter/stock_portfolio_backtest_test.rb index e6e3806..24d8761 100644 --- a/test/presenters/tool/presenter/stock_portfolio_backtest_test.rb +++ b/test/presenters/tool/presenter/stock_portfolio_backtest_test.rb @@ -54,6 +54,22 @@ class Tool::Presenter::StockPortfolioBacktestTest < ActiveSupport::TestCase assert_equal last_plot_point, @tool.plot_data.last end + test "with an unknown stock" do + VCR.use_cassette "synth/stock_portfolio_backtest_unknown_stock" do + tool = Tool::Presenter::StockPortfolioBacktest.new \ + benchmark_stock: "IXUS", + investment_amount: "$100,000.00", + start_date: "2024-08-07", + end_date: "2024-09-29", + stocks: %w[ AACIU AACIW ], + stock_allocations: [ 50, 50 ] + + assert_not_nil tool.plot_data + assert_not_nil tool.portfolio_growth + assert_not_nil tool.benchmark_growth + end + end + test "too many stocks" do assert_raises ArgumentError do Tool::Presenter::StockPortfolioBacktest.new(stocks: %w[ AGG SCHB SCHZ VTI VOO VEA VWO VTV VUG VIG VYM ]).stocks diff --git a/test/vcr_cassettes/synth/stock_portfolio_backtest_unknown_stock.yml b/test/vcr_cassettes/synth/stock_portfolio_backtest_unknown_stock.yml new file mode 100644 index 0000000..e12c88f --- /dev/null +++ b/test/vcr_cassettes/synth/stock_portfolio_backtest_unknown_stock.yml @@ -0,0 +1,203 @@ +--- +http_interactions: +- request: + method: get + uri: https://api.synthfinance.com/tickers/AACIU/open-close?end_date=2024-09-29&interval=month&limit=500&start_date=2024-08-07 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_marketing + X-Source-Type: + - api + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 02 Oct 2024 21:40:58 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"0a4d906b7d82bec092a253d048d3317e" + Referrer-Policy: + - strict-origin-when-cross-origin + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - e79a578e-fe39-4712-a750-26f38c0834b5 + X-Runtime: + - '0.214356' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=sFRUCVkVeqgYqJwk6lHXoX7FpIMYHxzwn7TkfHvGpJtA598fu3ksonsJhlA%2B1Gr03DgRkRYlMJkp5oC4XOA7LeQCyIMdrOMmc24WPvWOnK5xKWxB%2F8FweECS49FfxCbs2E%2B%2BnGcQ"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Server: + - cloudflare + Cf-Ray: + - 8cc7da990c78ac96-YYZ + body: + encoding: ASCII-8BIT + string: '{"ticker":"AACIU","prices":[],"paging":{"prev":"/tickers/AACIU/open-close?start_date=2024-08-07\u0026end_date=2024-09-29\u0026interval=month\u0026limit=500\u0026page=","next":"/tickers/AACIU/open-close?start_date=2024-08-07\u0026end_date=2024-09-29\u0026interval=month\u0026limit=500\u0026page=","total_records":0,"current_page":1,"per_page":500,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":781}}' + recorded_at: Wed, 02 Oct 2024 21:40:58 GMT +- request: + method: get + uri: https://api.synthfinance.com/tickers/AACIW/open-close?end_date=2024-09-29&interval=month&limit=500&start_date=2024-08-07 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_marketing + X-Source-Type: + - api + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 02 Oct 2024 21:40:59 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Alt-Svc: + - h3=":443"; ma=86400 + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"36d29007d7b57f0d7a68b6075c209cf8" + Referrer-Policy: + - strict-origin-when-cross-origin + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 48885efe-8684-48aa-9115-89e9f4da05cf + X-Runtime: + - '0.226892' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=kLbFD3zFqcU7LuW%2FEjZpfTZHwQZHhhZ%2BJIqv92RV5UkTZ6Ef2B0b3MRqFl7JPpWm9SmpGUpFG3aLGJeMM2uZVahnPoOAVRKP19IUcgnTciYq7w23ubj27WGEaD26BKKH%2BG9xWEI3"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Server: + - cloudflare + Cf-Ray: + - 8cc7da9cb98636ce-YYZ + body: + encoding: ASCII-8BIT + string: '{"ticker":"AACIW","prices":[],"paging":{"prev":"/tickers/AACIW/open-close?start_date=2024-08-07\u0026end_date=2024-09-29\u0026interval=month\u0026limit=500\u0026page=","next":"/tickers/AACIW/open-close?start_date=2024-08-07\u0026end_date=2024-09-29\u0026interval=month\u0026limit=500\u0026page=","total_records":0,"current_page":1,"per_page":500,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":780}}' + recorded_at: Wed, 02 Oct 2024 21:40:59 GMT +- request: + method: get + uri: https://api.synthfinance.com/tickers/IXUS/open-close?end_date=2024-09-29&interval=month&limit=500&start_date=2024-08-07 + body: + encoding: US-ASCII + string: '' + headers: + Authorization: + - Bearer + X-Source: + - maybe_marketing + X-Source-Type: + - api + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + User-Agent: + - Ruby + response: + status: + code: 200 + message: OK + headers: + Date: + - Wed, 02 Oct 2024 21:40:59 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Cache-Control: + - max-age=0, private, must-revalidate + Etag: + - W/"c9b9d1be69856e8992df3aa46ed71933" + Referrer-Policy: + - strict-origin-when-cross-origin + Strict-Transport-Security: + - max-age=63072000; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - 4ce1b815-2eac-4c47-abc2-92eed14a4e84 + X-Runtime: + - '0.222578' + X-Xss-Protection: + - '0' + Cf-Cache-Status: + - DYNAMIC + Report-To: + - '{"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=%2B%2BDMUY0uAWC%2B%2BTXBvNaAul8NIxgAaR1tsfs4lLK2CMPon97wP2Q48CEtAJxyodKDoCGJd%2Fxtfo4ofDb1XOaxsqWlzTDmO4UV%2BKiUy3vIanlKmtw%2FO8NJhF7XQ%2BOPI4FJQYP18spR"}],"group":"cf-nel","max_age":604800}' + Nel: + - '{"success_fraction":0,"report_to":"cf-nel","max_age":604800}' + Server: + - cloudflare + Cf-Ray: + - 8cc7daa0b9d3ab28-YYZ + body: + encoding: ASCII-8BIT + string: '{"ticker":"IXUS","prices":[{"date":"2024-09-01","open":70.44,"close":72.63,"high":73.53,"low":67.95,"volume":19366823}],"paging":{"prev":"/tickers/IXUS/open-close?start_date=2024-08-07\u0026end_date=2024-09-29\u0026interval=month\u0026limit=500\u0026page=","next":"/tickers/IXUS/open-close?start_date=2024-08-07\u0026end_date=2024-09-29\u0026interval=month\u0026limit=500\u0026page=","total_records":1,"current_page":1,"per_page":500,"total_pages":1},"meta":{"credits_used":1,"credits_remaining":779}}' + recorded_at: Wed, 02 Oct 2024 21:40:59 GMT +recorded_with: VCR 6.3.1