↓ Skip to main content

Building a Standalone Chart Fetching Script with Exchange Fallback

·2267 words·11 mins
Max Severance
Author
Max Severance
Architecting fabric convergence at hyperscale.

TLDR
#

  • Extracted chart image fetching from the main trading-to-blog script into a standalone fetch-charts.sh script
  • The script reads chart requests from debug.json (generated by the main script) and fetches chart images via the chart-img API
  • Added intelligent exchange fallback: when an exchange returns 422 Invalid Symbol, the script tries alternative exchanges (NASDAQ → NYSE → AMEX for stocks, BINANCE → COINBASE → etc for crypto)
  • chart-img provides API v3 endpoints (/v3/tradingview/exchange/list and /v3/tradingview/exchange/<EXCHANGE_ID>) to look up valid symbols
  • The root cause was AI model hallucinations - the LLM sometimes outputs the wrong exchange (e.g., NASDAQ instead of NYSE) for common stocks
  • Later eliminated duplication by invoking fetch-charts.sh as a subprocess with a FIFO and fd 3 to separate chart markdown output from log output
  • Added --skip-image-processing and --use-existing-post flags to the parent script to iterate on chart fetching without re-running expensive image processing or model calls

Introduction
#

This post documents a VibeCode session where I built a standalone script to fetch TradingView charts from previously generated debug data. The journey involved extracting code from an existing script, handling API errors gracefully, and implementing intelligent exchange fallback for invalid symbols.

The problem emerged when running the initial fetch-charts.sh - it worked for some symbols but failed for others with “Invalid Symbol” errors. Debugging revealed that the AI model generating the chart requests sometimes used the wrong exchange prefix (e.g., NASDAQ:RACE instead of NYSE:RACE).

The Original Code Structure
#

The main script trading-to-blog-with-charts.sh contained chart fetching logic starting around line 590. The key section was:

# Prepare directory for chart images
CHART_ASSET_DIR="$OUTPUT_DIR/assets"
mkdir -p "$CHART_ASSET_DIR"
CHART_MD_FILE="$TMPDIR/chart_md.txt"
> "$CHART_MD_FILE"

# Fetch charts if CHART_IMG_KEY is set and we have chart requests
if [[ -n "$CHART_IMG_KEY" && "$(echo "$CHARTS_JSON" | jq 'length')" -gt 0 ]]; then
    log_info "Fetching $(echo "$CHARTS_JSON" | jq 'length') chart(s) via Chart-IMG API"
    
    mapfile -t chartobjs < <(echo "$CHARTS_JSON" | jq -c '.[]')
    for chartobj in "${chartobjs[@]}"; do
        SYMBOL=$(echo "$chartobj" | jq -r '.symbol // empty')
        INTERVAL=$(echo "$chartobj" | jq -r '.interval // empty')
        FROM_DATE=$(echo "$chartobj" | jq -r '.from // empty')
        TO_DATE=$(echo "$chartobj" | jq -r '.to // empty')
        
        # Convert dates to ISO 8601 format
        FROM_ISO=$(date -d "$FROM_DATE" +%Y-%m-%dT00:00:00.000Z)
        TO_ISO=$(date -d "$TO_DATE" +%Y-%m-%dT23:59:59.999Z)
        
        # Call Chart-IMG API
        IMG_FILE="$CHART_ASSET_DIR/${DATE_DISPLAY}_${SYMBOL}_${INTERVAL}.png"
        curl -s -o "$IMG_FILE" \
            -H "Authorization: Bearer $CHART_IMG_KEY" \
            -H "Content-Type: application/json" \
            -d "{\"symbol\":\"$SYMBOL\", \"interval\":\"$INTERVAL\", ...}"
    done
fi

This code was tightly coupled with the blog post generation workflow. To make it reusable, I extracted it into a standalone script.

The Standalone Script
#

The new fetch-charts.sh script accepts:

  1. Week ending date (required) - Used to locate the debug.json file (YYYY.MM.DD-trading-debug.json)
  2. Symbol filter (optional) - If provided, only fetches charts for that specific symbol
  3. –debug flag - Enable verbose debug output

Usage Examples
#

# Fetch all charts from a week's debug file
./scripts/fetch-charts.sh 2026-03-22

# Fetch only a specific symbol's chart
./scripts/fetch-charts.sh 2026-03-22 NYSE:RACE

# Enable debug mode
./scripts/fetch-charts.sh 2026-03-22 --debug

Script Structure
#

#!/bin/bash
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
OUTPUT_DIR="${PROJECT_ROOT}/content/trading"
CHART_ASSET_DIR="${OUTPUT_DIR}/assets"
CHART_IMG_API_BASE="https://api.chart-img.com/v2/tradingview/advanced-chart"
CHART_IMG_KEY="${CHART_IMG_API_KEY:-}"

The script loads the API key from the CHART_IMG_API_KEY environment variable.

Extracting Charts from Debug JSON
#

The debug file contains the full model response including chart requests. Here’s how to extract them:

# Extract the final model response
FINAL_RESPONSE=$(jq -r '.final_model_output' "$DEBUG_FILE")

# Get the raw content
POST_CONTENT_RAW=$(echo "$FINAL_RESPONSE" | jq -r '.choices[0].message.content // empty')

# Extract the charts array (model outputs structured JSON with body and charts)
CHARTS_JSON=$(echo "$POST_CONTENT_RAW" | jq -c '.charts // []' 2>/dev/null)

The model is instructed to output a JSON object with:

  • body: Markdown content for the blog post
  • charts: Array of chart objects with symbol, interval, from, to, and optional alt text

Chart Object Format
#

[
  {
    "symbol": "NASDAQ:RACE",
    "interval": "1D",
    "from": "2026-03-15",
    "to": "2026-03-22",
    "alt": "Ferrari stock chart"
  },
  {
    "symbol": "NYSE:UBER",
    "interval": "1D",
    "from": "2026-03-15",
    "to": "2026-03-22"
  }
]

The Problem: Invalid Exchange Names
#

When running the initial script, we encountered errors:

[INFO] Fetching chart: NASDAQ:RACE 1D from 2026-03-08 to 2026-03-22
[WARN] Chart-IMG API returned status 422 for NASDAQ:RACE 1D
{"message":"Invalid Symbol"}

The error was 422 Unprocessable Entity with message “Invalid Symbol”. This happened because:

  1. RACE (Ferrari) trades on NYSE, not NASDAQ
  2. TWLO (Twilio) trades on NYSE, not NASDAQ
  3. The AI model hallucinated the exchange prefix

chart-img API v3 Exchange Lookup
#

chart-img provides endpoints to validate symbols and find the correct exchange. From the documentation:

Exchange List Endpoint
#

curl -s "https://api.chart-img.com/v3/tradingview/exchange/list" \
  -H "x-api-key: YOUR_API_KEY"

Response:

{
  "updatedAt": "2024-07-08T00:00:00.106Z",
  "payload": ["ACTIVTRADES", "ADX", "AMEX", "ASX", "BINANCE", "NYSE", "NASDAQ", ...]
}

Exchange Symbols Endpoint
#

curl -s "https://api.chart-img.com/v3/tradingview/exchange/nyse?key=YOUR_API_KEY" \
  | jq '.payload[] | select(.symbol == "RACE")'

Response:

{
  "symbol": "RACE",
  "description": "Ferrari NV",
  "type": "stock",
  "exchange": "NYSE",
  "currency_code": "USD",
  "country": "US",
  "typespecs": ["common"]
}

This confirmed that RACE is on NYSE, not NASDAQ.

Implementing Exchange Fallback
#

Instead of querying the API to validate every symbol upfront (adding latency), I implemented a fallback strategy: try the requested exchange first, and if it returns 422, try alternative exchanges.

The Fetch Function
#

fetch_chart_with_fallback() {
    local symbol="$1"
    local interval="$2"
    local from_iso="$3"
    local to_iso="$4"
    local exchanges="$5"
    
    for exchange in $exchanges; do
        local full_symbol="${exchange}:${symbol#*:}"
        http_status=$(curl -s -w "\nHTTPSTATUS:%{http_code}" -o "$IMG_FILE" \
            -H "Authorization: Bearer $CHART_IMG_KEY" \
            -H "Content-Type: application/json" \
            -d "{
                \"symbol\":\"$full_symbol\",
                \"interval\":\"$interval\",
                \"from\":\"$from_iso\",
                \"to\":\"$to_iso\",
                \"width\":800,
                \"height\":400,
                \"theme\":\"dark\",
                \"drawings\": [
                    {\"name\": \"Vertical Line\", \"input\": {\"datetime\": \"$from_iso\"}, \"override\": {\"color\": \"rgba(0, 150, 255, 0.5)\", \"linewidth\": 1}},
                    {\"name\": \"Vertical Line\", \"input\": {\"datetime\": \"$to_iso\"}, \"override\": {\"color\": \"rgba(0, 150, 255, 0.5)\", \"linewidth\": 1}}
                ]
            }" "$CHART_IMG_API_BASE" | tail -n1)
        http_status=$(echo "$http_status" | sed -e 's/.*HTTPSTATUS://')
        
        if [[ "$http_status" == "200" ]]; then
            echo "$full_symbol"
            return 0
        elif [[ "$http_status" == "422" ]]; then
            log_debug "Exchange $full_symbol invalid, trying next..."
            rm -f "$IMG_FILE"
            continue
        else
            log_warn "API error $http_status for $full_symbol"
            return 1
        fi
    done
    return 1
}

Exchange Fallback Rules
#

EXCHANGE=$(echo "$SYMBOL" | cut -d: -f1)
TICKER=$(echo "$SYMBOL" | cut -d: -f2)

US_EXCHANGES="NYSE NASDAQ AMEX"
CRYPTO_EXCHANGES="BINANCE BINANCEUS COINBASE BYBIT OKX"

if [[ "$EXCHANGE" == "NASDAQ" || "$EXCHANGE" == "NYSE" || "$EXCHANGE" == "AMEX" ]]; then
    EXCHANGES_TO_TRY="$EXCHANGE $US_EXCHANGES"
elif [[ "$EXCHANGE" == "BINANCE" || "$EXCHANGE" == "COINBASE" ]]; then
    EXCHANGES_TO_TRY="$EXCHANGE $CRYPTO_EXCHANGES"
else
    EXCHANGES_TO_TRY="$EXCHANGE"
fi

The logic is:

  • For US stocks: try the requested exchange first, then NYSE, NASDAQ, AMEX
  • For crypto: try the requested exchange first, then other major crypto exchanges
  • For other types: just use the requested exchange

Test Results
#

After implementing the fallback, all charts were fetched successfully:

[INFO] Found 4 chart(s) in debug file
[INFO] Fetching chart: NASDAQ:RACE 1D from 2026-03-15 to 2026-03-22
NYSE:RACE
[INFO] Saved chart: .../2026.03.22_NASDAQ:RACE_1D.png
[INFO] Fetching chart: NYSE:UBER 1D from 2026-03-15 to 2026-03-22
NYSE:UBER
[INFO] Saved chart: .../2026.03.22_NYSE:UBER_1D.png
[INFO] Fetching chart: NASDAQ:TWLO 1D from 2026-03-15 to 2026-03-22
NYSE:TWLO
[INFO] Saved chart: .../2026.03.22_NASDAQ:TWLO_1D.png
[INFO] Fetching chart: BINANCE:ETHUSDT 1D from 2026-03-15 to 2026-03-22
BINANCE:ETHUSDT
[INFO] Saved chart: .../2026.03.22_BINANCE:ETHUSDT_1D.png
[INFO] Done!

Notice how:

  • NASDAQ:RACE was corrected to NYSE:RACE (fallback to NYSE)
  • NASDAQ:TWLO was corrected to NYSE:TWLO (fallback to NYSE)
  • NYSE:UBER and BINANCE:ETHUSDT worked on first try

Why This Happens: AI Hallucination
#

The root cause is AI model hallucination. The language model generating chart requests sometimes uses common associations (like “tech stocks = NASDAQ”) rather than checking actual listings. Stocks like RACE (Ferrari) and TWLO (Twilio) are NYSE-listed, but the model guessed NASDAQ.

This is a common pattern when LLMs generate structured data - they follow statistical patterns rather than verified facts. The exchange fallback is a practical solution that doesn’t require:

  1. Pre-validating all symbols (adds latency)
  2. Maintaining a symbol database (maintenance overhead)
  3. Re-running the model (expensive)

The Final Script
#

The complete fetch-charts.sh script includes:

  1. Argument parsing - Week date and optional symbol filter
  2. Debug file loading - Reads from content/trading/YYYY.MM.DD-trading-debug.json
  3. Chart extraction - Parses the charts array from model output
  4. Symbol filtering - Optionally filter to a specific symbol
  5. Exchange fallback - Tries alternative exchanges on 422 errors
  6. Proper error handling - Logs warnings, continues on failures
  7. Debug mode - Verbose output for troubleshooting

Eliminating the Duplication: FIFO + fd 3 Pattern
#

After fetch-charts.sh was working standalone, it became clear the chart-fetching block in trading-to-blog-with-charts.sh was largely duplicated code. The refactoring goal was simple: invoke fetch-charts.sh as a subprocess and capture the chart markdown lines, without mixing them into the user-facing log output. This also made it possible to iterate on chart fetching independently during development.

The Challenge
#

fetch-charts.sh outputs two kinds of things to stdout:

  • log_info/log_warn messages — meant for the user to see
  • Chart markdown lines — meant for the calling script to capture and embed in the blog post

Simply piping stdout into the parent script would interleave both. We needed a way to separate them.

The fd 3 Strategy
#

The solution: have fetch-charts.sh write chart markdown to file descriptor 3 instead of stdout, and only dup stdout → fd 3 if fd 3 isn’t already open:

if [[ ! -e /dev/fd/3 ]]; then
    exec 3>&1
fi

This means:

  • Standalone usage (./fetch-charts.sh 2026-03-22): fd 3 doesn’t exist, so it’s duped from stdout — chart markdown goes to the terminal as expected
  • Subprocess usage: the parent opens fd 3 to a FIFO before invoking the script — chart markdown goes to the FIFO, log messages still go to stdout (fd 1)

The FIFO Pattern in the Parent Script
#

In trading-to-blog-with-charts.sh, after the debug JSON is written, we spawn fetch-charts.sh in the background with fd 3 pointing to a FIFO, then drain the FIFO into the chart markdown file:

CHART_MD_FILE="$TMPDIR/chart_md.txt"
> "$CHART_MD_FILE"
CHART_FIFO="$TMPDIR/chart_fifo_$$"
mkfifo "$CHART_FIFO"
"$SCRIPT_DIR/fetch-charts.sh" "$WEEK_DATE" 3>"$CHART_FIFO" &
CHART_FIFO_PID=$!
while IFS= read -r chart_md; do
    echo "$chart_md" >> "$CHART_MD_FILE"
done < "$CHART_FIFO"
wait $CHART_FIFO_PID
CHART_FETCH_EXIT=$?
rm -f "$CHART_FIFO"
if [[ $CHART_FETCH_EXIT -ne 0 ]]; then
    log_warn "Chart fetching exited with code $CHART_FETCH_EXIT"
fi

Key points:

  • mkfifo creates the named pipe before forking — this ensures the child can open fd 3 without a race
  • Running the child in the background (&) avoids a subshell, so wait $CHART_FIFO_PID correctly captures its exit code
  • The while read loop runs in the main shell (not a subshell) because the pipe is consumed by the while itself — bash’s while ... < fifo doesn’t spawn a subshell for the left side
  • PIPESTATUS isn’t needed here; wait $CHART_FIFO_PID gives the child’s exit code directly

What Changed in fetch-charts.sh
#

After each successful chart download, instead of appending to a file directly, the script now writes the markdown line to fd 3:

CHART_REL_PATH="../assets/${DATE_DISPLAY}_${SAFE_SYMBOL}_${INTERVAL}.png"
log_info "Saved chart: $IMG_FILE"
echo "![${ALT_TEXT}](${CHART_REL_PATH} \"${ALT_TEXT}\")" >&3

The title syntax ("${ALT_TEXT}" after the path) makes Hugo render the alt text as a visible <figcaption> below the image, not just an HTML attribute.

The path uses ../assets/... — correct relative to blog posts at content/trading/.

What Changed in trading-to-blog-with-charts.sh
#

The ~70 lines of duplicated chart-fetching code was replaced with ~15 lines of subprocess invocation. Several now-redundant variables and conditionals were removed:

  • CHART_IMG_API_BASE, CHART_IMG_KEY — only needed in fetch-charts.sh
  • CHARTS_JSON — the model output analysis is now entirely in fetch-charts.sh
  • The if [[ -n "$CHART_IMG_KEY" && $(echo "$CHARTS_JSON" | jq 'length') -gt 0 ]] block — the parent no longer needs to decide whether to fetch charts; it just calls fetch-charts.sh and lets it handle all of that

The net result: trading-to-blog-with-charts.sh is shorter and no longer duplicates the chart-fetching logic, while fetch-charts.sh remains fully standalone and reusable.

Iterating on Charts Without Re-running Everything
#

With fetch-charts.sh now standalone, the workflow for iterating on chart fetching became: run the full pipeline once to generate debug.json, then re-run just the chart fetching part during development. But the parent script had a USE_DEBUG flag that skipped image processing but still called the final model — which is slow and costly during rapid iteration.

Two new flags were added to trading-to-blog-with-charts.sh:

  • --skip-image-processing: Skip the vision model calls, read IBKR/Binance data from debug.json, but still call the final model to regenerate the blog post. Useful when you want to re-generate the post with different prompt instructions while keeping screenshots.
  • --use-existing-post: Skip image processing AND the final model call entirely — read everything (IBKR_DATA, BINANCE_DATA, FINAL_RESPONSE, POST_CONTENT, SUMMARY_PROMPT) from debug.json. The only work done is writing the blog post and spawning fetch-charts.sh. This is the fastest way to iterate on chart fetching in isolation.

Combined with --skip-image-processing or --use-existing-post, the workflow becomes:

# Run the full pipeline once
./trading-to-blog.sh 2026-03-22 screenshots/ibkr.jpg screenshots/binance.jpg

# Tweak charts — edit debug.json or fetch-charts.sh, then re-run without re-processing images
./trading-to-blog.sh 2026-03-22 --skip-image-processing

# Or even faster — skip the final model call too, only regenerate post + charts
./trading-to-blog.sh 2026-03-22 --use-existing-post

This makes fetch-charts.sh the primary loop during chart iteration, with the parent script only handling post assembly.


Conclusion
#

This session demonstrated a practical approach to handling AI-generated content that may contain subtle errors. By implementing intelligent fallback logic, we created a robust solution that handles common issues (wrong exchange names) without adding significant complexity or latency.

The key takeaways:

  1. Always validate assumptions about AI-generated structured data
  2. Build fallback mechanisms for known failure modes
  3. Use API documentation to understand available validation endpoints
  4. Design for graceful degradation rather than hard failures
  5. Use file descriptor 3 (instead of stdout) for structured data output in reusable scripts — dup stdout to fd 3 only when fd 3 isn’t already open, so the script works both standalone and as a subprocess
  6. Named pipes (FIFOs) let a background subprocess stream structured data to a parent shell without subshell pitfalls
  7. Decouple expensive operations (API calls) from cheap ones (post assembly) — add skip flags so you can iterate on the cheap parts without re-running the expensive ones

The script is now available at scripts/fetch-charts.sh and can be used independently or integrated into other workflows.