TLDR #
- Extracted chart image fetching from the main trading-to-blog script into a standalone
fetch-charts.shscript - 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/listand/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.shas a subprocess with a FIFO and fd 3 to separate chart markdown output from log output - Added
--skip-image-processingand--use-existing-postflags 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
fiThis 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:
- Week ending date (required) - Used to locate the debug.json file (
YYYY.MM.DD-trading-debug.json) - Symbol filter (optional) - If provided, only fetches charts for that specific symbol
- –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 --debugScript 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 postcharts: 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:
- RACE (Ferrari) trades on NYSE, not NASDAQ
- TWLO (Twilio) trades on NYSE, not NASDAQ
- 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"
fiThe 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:RACEwas corrected toNYSE:RACE(fallback to NYSE)NASDAQ:TWLOwas corrected toNYSE:TWLO(fallback to NYSE)NYSE:UBERandBINANCE:ETHUSDTworked 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:
- Pre-validating all symbols (adds latency)
- Maintaining a symbol database (maintenance overhead)
- Re-running the model (expensive)
The Final Script #
The complete fetch-charts.sh script includes:
- Argument parsing - Week date and optional symbol filter
- Debug file loading - Reads from
content/trading/YYYY.MM.DD-trading-debug.json - Chart extraction - Parses the
chartsarray from model output - Symbol filtering - Optionally filter to a specific symbol
- Exchange fallback - Tries alternative exchanges on 422 errors
- Proper error handling - Logs warnings, continues on failures
- 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_warnmessages — 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
fiThis 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"
fiKey points:
mkfifocreates 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, sowait $CHART_FIFO_PIDcorrectly captures its exit code - The
while readloop runs in the main shell (not a subshell) because the pipe is consumed by thewhileitself — bash’swhile ... < fifodoesn’t spawn a subshell for the left side PIPESTATUSisn’t needed here;wait $CHART_FIFO_PIDgives 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 "" >&3The 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 infetch-charts.shCHARTS_JSON— the model output analysis is now entirely infetch-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 callsfetch-charts.shand 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 fromdebug.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) fromdebug.json. The only work done is writing the blog post and spawningfetch-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-postThis 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:
- Always validate assumptions about AI-generated structured data
- Build fallback mechanisms for known failure modes
- Use API documentation to understand available validation endpoints
- Design for graceful degradation rather than hard failures
- 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
- Named pipes (FIFOs) let a background subprocess stream structured data to a parent shell without subshell pitfalls
- 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.