cd6874b43f
The price files carry no facts for interpreting the bars (pip size, digit count, contract size, quote currency); those are known only to the broker via cTrader's Symbol object. Produce a JSON sidecar per symbol next to the price files so a downstream reader does not have to hardcode (and drift on) its own per-symbol table. Scope is the price-interpretation geometry only — digits, pipSize, tickSize, lotSize, baseAsset, quoteAsset — facts intrinsic to the price series and stable across its whole history. Costs, swaps, volume limits, trading status and the deposit-currency pip/tick values are intentionally left out: the cBot only ever sees the current contract, so those are a point-in-time snapshot of today's trading conditions and would misrepresent a multi-year series (the deposit-currency values are account-dependent too). - cBot: a MetaOnly mode (--MetaOnly --MetaFile) serializes the geometry to a flat JSON. Numbers use InvariantCulture; a non-finite value becomes null; keys mirror the cTrader property names. - runner: write_meta runs once per symbol (mode-independent), validates the JSON, and atomically promotes the sidecar. Best-effort — a metadata failure is logged and never aborts the export, and a pre-existing sidecar is kept on failure. A freshness guard (META_MAX_AGE_DAYS, default 7) keeps scheduled runs short; EXPORT_META=0 disables it. - The file is a raw serialization of what cTrader delivers; a reader derives its own shape (contract_size, per-lot pip value) from the raw fields. See docs/symbol-metadata.md. - Add a source-level regression test for the freshness guard. Verified end-to-end against the live broker for all 30 configured symbols (FX, indices, metals, crypto, commodities, equities); the price corpus was untouched.
407 lines
19 KiB
Bash
Executable File
407 lines
19 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
#
|
||
# Headless market-data export driver for the MS_SimpleExport cBot (hardened).
|
||
#
|
||
# Runs on THIS host and spawns one cTrader-console container per backtest window.
|
||
# Deployment-specific config lives in runtime/export.env (gitignored).
|
||
#
|
||
# Hardening over the original Unraid script (see docs/export-runner-quirks.md):
|
||
# - Per-run timeout + 3-way classification (success / clean-empty / transient-fail)
|
||
# read from the log stream; a transient failure is retried, then HARD-ABORTS the
|
||
# whole run (never silently skipped) so gaps cannot form unnoticed.
|
||
# - Gap-aware resume: every expected month from the symbol's first data month to now
|
||
# is checked; only MISSING months and the trailing edge (latest existing + current)
|
||
# are (re-)scraped. Known-good middle files are never overwritten.
|
||
# - Current (incomplete) month end-date is clamped to yesterday, so partial current
|
||
# month data is captured instead of being rejected for a future end date.
|
||
# - Clean "No historical data" is treated as legitimately-empty -> skipped, no file
|
||
# written (no empty-file pollution). Empty/corrupt ZIPs are caught before mv.
|
||
# - No --rm: the container is kept until logs are drained, then removed explicitly;
|
||
# a trap removes any in-flight container on interrupt (no orphans).
|
||
#
|
||
# Required: runtime/export.env defining at least HOST_DATA_PATH, CTID, ACCOUNT.
|
||
|
||
set -uo pipefail
|
||
|
||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
|
||
# --- DEFAULTS (overridable via runtime/export.env or the environment) ---
|
||
CONTAINER_DATA_PATH="/mnt/Data"
|
||
BOT_FILE="MS_SimpleExport.algo"
|
||
PWD_FILE="/mnt/Robots/password.txt" # container-internal path (console reads it)
|
||
IMAGE="ghcr.io/spotware/ctrader-console:latest"
|
||
HOST_ROBOTS_PATH="$REPO_ROOT/runtime/Robots" # holds MS_SimpleExport.algo + password.txt
|
||
SYMBOL_FILE="${SYMBOL_FILE:-$REPO_ROOT/runtime/ScrapeSymbols.txt}" # overridable for a scoped run/test
|
||
RETRIES="${RETRIES:-3}" # attempts before a transient failure aborts
|
||
RUN_TIMEOUT="${RUN_TIMEOUT:-600}" # seconds per backtest before it counts as a hang
|
||
DISCOVER_MIN_YEAR="${DISCOVER_MIN_YEAR:-2005}" # earliest year the first-data probe scans
|
||
EXPORT_META="${EXPORT_META:-1}" # 1 = also produce <symbol>.meta.json sidecars
|
||
META_MAX_AGE_DAYS="${META_MAX_AGE_DAYS:-7}" # refresh a sidecar at most this often (0 = every run)
|
||
|
||
# --- DEPLOYMENT CONFIG (secrets/IDs/paths) ---
|
||
ENV_FILE="${ENV_FILE:-$REPO_ROOT/runtime/export.env}"
|
||
if [[ ! -f "$ENV_FILE" ]]; then
|
||
echo "❌ Missing $ENV_FILE — copy scripts/export.env.example and fill it in." >&2
|
||
exit 1
|
||
fi
|
||
# shellcheck disable=SC1090
|
||
source "$ENV_FILE"
|
||
|
||
: "${HOST_DATA_PATH:?HOST_DATA_PATH must be set in runtime/export.env}"
|
||
: "${CTID:?CTID must be set in runtime/export.env}"
|
||
: "${ACCOUNT:?ACCOUNT must be set in runtime/export.env}"
|
||
|
||
CURRENT_YEAR=$(date +%Y)
|
||
CURRENT_MONTH=$((10#$(date +%m)))
|
||
|
||
# Track an in-flight container so the trap can reap it on Ctrl-C / kill (no orphans).
|
||
CURRENT_CONTAINER=""
|
||
cleanup() { [[ -n "$CURRENT_CONTAINER" ]] && docker rm -f "$CURRENT_CONTAINER" >/dev/null 2>&1; }
|
||
trap cleanup EXIT INT TERM
|
||
|
||
# Month arithmetic as a single 0-based index (year*12 + month-1) for easy comparison.
|
||
midx() { echo $(( $1 * 12 + $2 - 1 )); }
|
||
idx_year() { echo $(( $1 / 12 )); }
|
||
idx_month() { echo $(( $1 % 12 + 1 )); }
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# run_one: execute ONE backtest window and classify the outcome from the logs.
|
||
# returns 0 = success (DATA_FOUND / EXPORT_SUCCESS)
|
||
# 3 = clean empty ("No historical data for the specified period")
|
||
# 2 = transient failure (timeout, crash, login error, no terminator)
|
||
# args: S_DATE E_DATE SYMBOL MODE PROBING OUT_FILE
|
||
# ---------------------------------------------------------------------------
|
||
run_one() {
|
||
local S_DATE=$1 E_DATE=$2 SYMBOL=$3 MODE=$4 PROBING=$5 OUT_FILE=$6
|
||
local C_NAME="ctrader-${SYMBOL//[^A-Za-z0-9]/-}-$(date +%s%N)"
|
||
local DATA_MODE="m1"; [[ "$MODE" == "Tick" ]] && DATA_MODE="ticks"
|
||
|
||
CURRENT_CONTAINER="$C_NAME"
|
||
# NOTE: no --rm — the console lingers after the backtest; we drain logs then remove it.
|
||
docker run -d --name "$C_NAME" --init \
|
||
-v "$HOST_ROBOTS_PATH":"/mnt/Robots" \
|
||
-v "$HOST_DATA_PATH":"$CONTAINER_DATA_PATH" \
|
||
-e TZ="Europe/Berlin" \
|
||
"$IMAGE" \
|
||
backtest "/mnt/Robots/$BOT_FILE" \
|
||
--start="$S_DATE" --end="$E_DATE" --symbol="$SYMBOL" \
|
||
--ctid="$CTID" --pwd-file="$PWD_FILE" --account="$ACCOUNT" \
|
||
--period="M1" --data-mode="$DATA_MODE" \
|
||
--Mode="$MODE" --IsProbing="$PROBING" \
|
||
--OutputFile="${CONTAINER_DATA_PATH}/${OUT_FILE}" --full-access > /dev/null 2>&1 || {
|
||
echo " ⚠️ docker run failed to start container"; CURRENT_CONTAINER=""; return 2; }
|
||
|
||
local saw_success=0 saw_empty=0 saw_error=0 saw_term=0
|
||
local last_progress=0 now
|
||
while IFS= read -r line; do
|
||
if [[ "$line" == *"Progress"* ]]; then
|
||
now=$(date +%s)
|
||
if (( now - last_progress >= 5 )); then echo " [BOT] $line"; last_progress=$now; fi
|
||
else
|
||
echo " [BOT] $line"
|
||
fi
|
||
case "$line" in
|
||
*DATA_FOUND*|*EXPORT_SUCCESS*) saw_success=1; saw_term=1; break ;;
|
||
*"No historical data for the specified period"*) saw_empty=1 ;;
|
||
*"ERROR:"*) saw_error=1 ;; # the bot prints Print("ERROR: ...") on an OnStop exception
|
||
esac
|
||
[[ "$line" == "}" ]] && { saw_term=1; break; } # end-of-result terminator (always printed)
|
||
done < <(timeout "$RUN_TIMEOUT" docker logs -f "$C_NAME" 2>&1)
|
||
|
||
docker rm -f "$C_NAME" >/dev/null 2>&1
|
||
CURRENT_CONTAINER=""
|
||
|
||
if (( saw_success )); then return 0 # DATA_FOUND / EXPORT_SUCCESS
|
||
elif (( saw_error )); then return 2 # bot raised an exception (e.g. write failure) -> retry/abort
|
||
elif (( saw_empty )); then return 3 # console: "No historical data for the specified period"
|
||
elif (( saw_term )); then return 3 # backtest reached its `}` summary but exported nothing -> data hole / empty month
|
||
else return 2 # no terminator: timeout / crash / dead stream -> transient failure
|
||
fi
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# run_bot: run_one with retry. Clean-empty is NOT retried. A transient failure
|
||
# that survives all retries returns 2 (the caller decides to abort).
|
||
# ---------------------------------------------------------------------------
|
||
run_bot() {
|
||
local attempt rc
|
||
for ((attempt=1; attempt<=RETRIES; attempt++)); do
|
||
run_one "$@"; rc=$?
|
||
case $rc in
|
||
0) return 0 ;;
|
||
3) return 3 ;;
|
||
2) echo " ⚠️ attempt $attempt/$RETRIES failed (transient) for [$3 $1..$2]"
|
||
(( attempt < RETRIES )) && sleep $(( attempt * 5 )) ;;
|
||
esac
|
||
done
|
||
return 2
|
||
}
|
||
|
||
# validate_export FILE -> echoes "ok" | "empty" | "bad"
|
||
# "bad" catches a truncated/corrupt ZIP (e.g. a crash mid-write); "empty" is a valid
|
||
# ZIP whose entry has zero bytes (a ran-but-no-bars month we do not want to keep).
|
||
validate_export() {
|
||
python3 - "$1" <<'PY'
|
||
import sys, zipfile
|
||
try:
|
||
z = zipfile.ZipFile(sys.argv[1])
|
||
if z.testzip() is not None: print("bad"); sys.exit()
|
||
infos = z.infolist()
|
||
if not infos: print("bad"); sys.exit()
|
||
print("empty" if sum(i.file_size for i in infos) == 0 else "ok")
|
||
except Exception:
|
||
print("bad")
|
||
PY
|
||
}
|
||
|
||
# month_end_or_clamp Y M -> echoes dd/mm/yyyy ; the current (incomplete) month is
|
||
# clamped to yesterday. Echoes empty string if the current month has no complete day
|
||
# yet (today is the 1st) so the caller can skip it.
|
||
month_end_or_clamp() {
|
||
local y=$1 m=$2 MM; MM=$(printf "%02d" "$m")
|
||
if (( y == CURRENT_YEAR && m == CURRENT_MONTH )); then
|
||
local yest first; yest="$(date -d 'yesterday' +%Y-%m-%d)"; first="$y-$MM-01"
|
||
[[ "$yest" < "$first" ]] && { echo ""; return; } # today is the 1st: nothing complete yet
|
||
date -d "$yest" +%d/%m/%Y
|
||
else
|
||
date -d "$y-$MM-01 +1 month -1 day" +%d/%m/%Y
|
||
fi
|
||
}
|
||
|
||
# scrape_month SYMBOL MODE EXT YEAR MONTH
|
||
# Scrapes one month into a .tmp, validates, and renames to the final file. Aborts the
|
||
# whole run on a transient failure (retries exhausted) or a corrupt artifact.
|
||
scrape_month() {
|
||
local S=$1 MODE=$2 EXT=$3 y=$4 m=$5
|
||
local MM; MM=$(printf "%02d" "$m")
|
||
local FINAL="${S}_${y}_${MM}.${EXT}"
|
||
local TEMP="${FINAL}.tmp"
|
||
local S_DATE="01/${MM}/${y}" E_DATE
|
||
E_DATE="$(month_end_or_clamp "$y" "$m")"
|
||
[[ -z "$E_DATE" ]] && { echo " ↪︎ $y-$MM just started — nothing complete yet, skipping"; return 0; }
|
||
|
||
echo "📦 Scraping $y-$MM ($S_DATE → $E_DATE)..."
|
||
run_bot "$S_DATE" "$E_DATE" "$S" "$MODE" "false" "$TEMP"
|
||
local rc=$?
|
||
case $rc in
|
||
0) if [[ ! -f "$HOST_DATA_PATH/$TEMP" ]]; then
|
||
echo "❌ ABORT: bot reported success but $TEMP not found ($S $y-$MM)"; exit 1
|
||
fi
|
||
case "$(validate_export "$HOST_DATA_PATH/$TEMP")" in
|
||
ok) if mv "$HOST_DATA_PATH/$TEMP" "$HOST_DATA_PATH/$FINAL"; then
|
||
echo " ✅ Saved: $FINAL"; SAVED=$((SAVED+1))
|
||
else echo "❌ ABORT: rename $TEMP → $FINAL failed"; exit 1; fi ;;
|
||
empty) echo " ◦ empty result for $y-$MM — not saved"; rm -f "$HOST_DATA_PATH/$TEMP"; EMPTY=$((EMPTY+1)) ;;
|
||
bad) echo "❌ ABORT: corrupt/truncated ZIP for $S $y-$MM (likely a cut-off run)"; exit 1 ;;
|
||
esac ;;
|
||
3) echo " ◦ no data (clean) for $y-$MM — skipped"; rm -f "$HOST_DATA_PATH/$TEMP" 2>/dev/null; EMPTY=$((EMPTY+1)) ;;
|
||
2) echo "❌ ABORT: $S $y-$MM failed after $RETRIES retries (transient). Stopping so the gap is not lost."; exit 1 ;;
|
||
esac
|
||
}
|
||
|
||
# probe_run SYMBOL MODE S_DATE E_DATE -> 0 if data present, 1 if clean-empty (aborts on transient)
|
||
probe_run() {
|
||
run_bot "$3" "$4" "$1" "$2" "true" "probe.tmp"
|
||
case $? in
|
||
0) return 0 ;;
|
||
3) return 1 ;;
|
||
2) echo "❌ ABORT: probe failed after $RETRIES retries for $1 ($3..$4)"; exit 1 ;;
|
||
esac
|
||
}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# write_meta SYMBOL — produce the per-symbol contract-metadata sidecar
|
||
# (<SYMBOL>.meta.json), placed next to the price files. The cBot's MetaOnly mode
|
||
# is the ONLY authoritative source for these values: it reads the broker's Symbol
|
||
# object and serializes it verbatim; this driver merely places the file it writes.
|
||
#
|
||
# BEST-EFFORT BY DESIGN: a metadata failure is logged (⚠️) and NEVER aborts the
|
||
# export run — the sidecar is auxiliary, and any pre-existing one is left untouched
|
||
# (we only ever overwrite on a validated success). Refreshed at most once per
|
||
# META_MAX_AGE_DAYS so scheduled runs stay short.
|
||
# ---------------------------------------------------------------------------
|
||
write_meta() {
|
||
local S=$1
|
||
local FINAL="${S}.meta.json"
|
||
local TEMP="${S}.meta.json.tmp"
|
||
|
||
# Freshness guard: skip if a recent sidecar already exists (mtime within the window).
|
||
if [[ "$META_MAX_AGE_DAYS" -gt 0 && -f "$HOST_DATA_PATH/$FINAL" \
|
||
&& -n "$(find "$HOST_DATA_PATH/$FINAL" -mtime "-$META_MAX_AGE_DAYS" 2>/dev/null)" ]]; then
|
||
echo "🏷 Metadata: $S — fresh (<${META_MAX_AGE_DAYS}d), skipping"
|
||
META_SKIP=$((META_SKIP+1)); return 0
|
||
fi
|
||
|
||
local C_NAME="ctrader-meta-${S//[^A-Za-z0-9]/-}-$(date +%s%N)"
|
||
local s_date e_date
|
||
s_date="$(date -d '7 days ago' +%d/%m/%Y)" # tiny window; MetaOnly stops in OnStart anyway
|
||
e_date="$(date -d 'yesterday' +%d/%m/%Y)"
|
||
|
||
echo "🏷 Metadata: $S (MetaOnly)"
|
||
CURRENT_CONTAINER="$C_NAME"
|
||
docker run -d --name "$C_NAME" --init \
|
||
-v "$HOST_ROBOTS_PATH":"/mnt/Robots" \
|
||
-v "$HOST_DATA_PATH":"$CONTAINER_DATA_PATH" \
|
||
-e TZ="Europe/Berlin" \
|
||
"$IMAGE" \
|
||
backtest "/mnt/Robots/$BOT_FILE" \
|
||
--start="$s_date" --end="$e_date" --symbol="$S" \
|
||
--ctid="$CTID" --pwd-file="$PWD_FILE" --account="$ACCOUNT" \
|
||
--period="M1" --data-mode="m1" \
|
||
--Mode="M1" --IsProbing="false" --MetaOnly="true" \
|
||
--MetaFile="${CONTAINER_DATA_PATH}/${TEMP}" --full-access > /dev/null 2>&1 || {
|
||
echo " ⚠️ meta: docker run failed to start container for $S"
|
||
CURRENT_CONTAINER=""; META_FAIL=$((META_FAIL+1)); return 0; }
|
||
|
||
while IFS= read -r line; do
|
||
case "$line" in
|
||
*META_SUCCESS*) break ;;
|
||
*META_ERROR*) echo " ⚠️ meta: bot reported${line#*META_ERROR}"; break ;;
|
||
"}") break ;;
|
||
esac
|
||
done < <(timeout "$RUN_TIMEOUT" docker logs -f "$C_NAME" 2>&1)
|
||
|
||
docker rm -f "$C_NAME" >/dev/null 2>&1
|
||
CURRENT_CONTAINER=""
|
||
|
||
# Promote only a syntactically valid JSON; on any failure keep the existing sidecar.
|
||
if [[ -f "$HOST_DATA_PATH/$TEMP" ]] \
|
||
&& python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$HOST_DATA_PATH/$TEMP" 2>/dev/null; then
|
||
if mv -f "$HOST_DATA_PATH/$TEMP" "$HOST_DATA_PATH/$FINAL"; then
|
||
echo " ✅ meta saved: $FINAL"; META_OK=$((META_OK+1))
|
||
else
|
||
echo " ⚠️ meta: rename $TEMP → $FINAL failed for $S"; META_FAIL=$((META_FAIL+1))
|
||
fi
|
||
else
|
||
echo " ⚠️ meta: no valid metadata produced for $S (existing $FINAL, if any, kept)"
|
||
rm -f "$HOST_DATA_PATH/$TEMP" 2>/dev/null
|
||
META_FAIL=$((META_FAIL+1))
|
||
fi
|
||
}
|
||
|
||
# discover_start SYMBOL MODE -> echoes "YEAR MONTH" of the first month with data, or
|
||
# nothing if the symbol has no data at all. Yearly probe uses December (catches data
|
||
# that started any time that year); monthly probe uses the FULL month (catches data
|
||
# starting after the 10th — the old probe only tested days 01–10).
|
||
#
|
||
# CONTRACT: this function's STDOUT carries ONLY the result line; the caller reads it via
|
||
# command substitution. probe_run -> run_one stream the cBot's "[BOT] …" lines on stdout,
|
||
# so every probe call here MUST be redirected to stderr (`1>&2`) — otherwise that progress
|
||
# noise pollutes the captured value and start_idx is parsed from garbage (see the AUDUSD
|
||
# year-0000 abort, docs/export-runner-quirks.md). All diagnostics here go to stderr too.
|
||
discover_start() {
|
||
local S=$1 MODE=$2 y mm MM edate found_year=""
|
||
echo "🌑 Discovery: locating first data month for $S ($MODE)..." >&2
|
||
for ((y=DISCOVER_MIN_YEAR; y<CURRENT_YEAR; y++)); do
|
||
echo " 🧪 year $y" >&2
|
||
if probe_run "$S" "$MODE" "01/12/$y" "31/12/$y" 1>&2; then found_year=$y; break; fi
|
||
done
|
||
if [[ -z "$found_year" ]]; then found_year=$CURRENT_YEAR; fi # brand-new this year → check months below
|
||
for ((mm=1; mm<=12; mm++)); do
|
||
(( found_year == CURRENT_YEAR && mm > CURRENT_MONTH )) && break
|
||
MM=$(printf "%02d" "$mm")
|
||
edate="$(month_end_or_clamp "$found_year" "$mm")"
|
||
[[ -z "$edate" ]] && break
|
||
echo " 🧪 month $found_year-$MM" >&2
|
||
if probe_run "$S" "$MODE" "01/$MM/$found_year" "$edate" 1>&2; then echo "$found_year $mm"; return 0; fi
|
||
done
|
||
return 1 # no data anywhere
|
||
}
|
||
|
||
# scan_existing SYMBOL EXT -> echoes "MIN_IDX MAX_IDX" of existing month files, or nothing.
|
||
scan_existing() {
|
||
local S=$1 EXT=$2 f base y m i min="" max=""
|
||
shopt -s nullglob
|
||
for f in "$HOST_DATA_PATH/${S}"_*_*."$EXT"; do
|
||
base=$(basename "$f")
|
||
if [[ "$base" =~ ^${S}_([0-9]{4})_([0-9]{2})\.${EXT}$ ]]; then
|
||
y=${BASH_REMATCH[1]}; m=$((10#${BASH_REMATCH[2]}))
|
||
i=$(midx "$y" "$m")
|
||
if [[ -z "$min" ]] || (( i < min )); then min=$i; fi
|
||
if [[ -z "$max" ]] || (( i > max )); then max=$i; fi
|
||
fi
|
||
done
|
||
shopt -u nullglob
|
||
[[ -n "$min" ]] && echo "$min $max"
|
||
}
|
||
|
||
# --- SYMBOL PROCESSOR (gap-aware) ---
|
||
process_symbol() {
|
||
local S=$1 MODE=$2
|
||
local EXT; EXT=$(echo "$MODE" | tr '[:upper:]' '[:lower:]')
|
||
local now_idx; now_idx=$(midx "$CURRENT_YEAR" "$CURRENT_MONTH")
|
||
|
||
local start_idx latest_idx
|
||
local existing; existing=$(scan_existing "$S" "$EXT")
|
||
if [[ -n "$existing" ]]; then
|
||
start_idx=${existing% *}; latest_idx=${existing#* }
|
||
echo "🔄 Resume: $S has data $(idx_year "$start_idx")-$(printf '%02d' "$(idx_month "$start_idx")") .. $(idx_year "$latest_idx")-$(printf '%02d' "$(idx_month "$latest_idx")")"
|
||
else
|
||
local sd; sd=$(discover_start "$S" "$MODE") || { echo " ❌ No data at all for $S — skipping symbol."; return; }
|
||
start_idx=$(midx ${sd% *} ${sd#* }); latest_idx=-1 # nothing on disk yet
|
||
echo " ✨ First data: $(echo "$sd" | awk '{printf "%d-%02d", $1, $2}')"
|
||
fi
|
||
|
||
# Walk every expected month from the first data month to the current month.
|
||
# Scrape a month iff: it is missing, OR it is the trailing edge (latest existing
|
||
# month — may be a partial current-month from a previous run) OR the current month.
|
||
# Known-good middle months are trusted and never overwritten.
|
||
local i y m MM FINAL
|
||
for ((i=start_idx; i<=now_idx; i++)); do
|
||
y=$(idx_year "$i"); m=$(idx_month "$i"); MM=$(printf "%02d" "$m")
|
||
FINAL="${S}_${y}_${MM}.${EXT}"
|
||
if [[ -f "$HOST_DATA_PATH/$FINAL" ]] && (( i != latest_idx && i != now_idx )); then
|
||
continue # trusted middle file
|
||
fi
|
||
scrape_month "$S" "$MODE" "$EXT" "$y" "$m"
|
||
done
|
||
}
|
||
|
||
# --- EXECUTION (skipped when the script is sourced, e.g. for unit tests) ---
|
||
main() {
|
||
SAVED=0; EMPTY=0; META_OK=0; META_FAIL=0; META_SKIP=0
|
||
|
||
# Clean up stale temp files at start (only *.tmp — never touches real month files).
|
||
[[ -d "$HOST_DATA_PATH" ]] && rm -f "$HOST_DATA_PATH"/*.tmp 2>/dev/null
|
||
|
||
echo "🏁 Starting data export task: $(date)"
|
||
|
||
if [[ ! -f "$SYMBOL_FILE" ]]; then
|
||
echo "❌ Error: symbol file $SYMBOL_FILE not found!"; exit 1
|
||
fi
|
||
|
||
SYMBOLS_DONE=0
|
||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||
line="${line//$'\r'/}" # strip CR (Windows line endings)
|
||
line="$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')" # trim
|
||
[[ -z "$line" || "$line" =~ ^# ]] && continue
|
||
|
||
read -r S MODES <<< "$line"
|
||
[[ -z "$MODES" ]] && MODES="M1"
|
||
|
||
# Per-symbol contract metadata sidecar (mode-independent — produced once per symbol).
|
||
[[ "$EXPORT_META" == "1" ]] && write_meta "$S"
|
||
|
||
for MODE in $MODES; do
|
||
if [[ "$MODE" == "M1" || "$MODE" == "Tick" ]]; then
|
||
echo "------------------------------------------"
|
||
echo "🔍 Processing: $S (mode: $MODE)"
|
||
process_symbol "$S" "$MODE"
|
||
SYMBOLS_DONE=$((SYMBOLS_DONE+1))
|
||
else
|
||
echo "⚠️ Skipping unknown mode: '$MODE' for symbol '$S'"
|
||
fi
|
||
done
|
||
done < "$SYMBOL_FILE"
|
||
|
||
echo "------------------------------------------"
|
||
echo "🏁 Done: $(date) — $SYMBOLS_DONE jobs, $SAVED month(s) saved, $EMPTY empty/skipped."
|
||
[[ "$EXPORT_META" == "1" ]] && echo "🏷 Metadata: $META_OK written, $META_SKIP fresh-skipped, $META_FAIL failed."
|
||
}
|
||
|
||
# Run main only when executed directly; `source`-ing the script (for tests) defines the
|
||
# functions without running the scrape.
|
||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||
main "$@"
|
||
fi
|