mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
`idx stocks flow` now reads IPOT by default, which needs nothing installed: per session, the all-investor broker summary gives the value traded and the foreign one gives foreign buying (its footer), the foreign net and selling (buying less the net), across all boards, through the broker summary cache. IPOT's foreign net matched IDX's estimate within 0.3 B on BBCA 2026-10-12 to 16. `--source idx` (and `source` on get_foreign_flow) keeps IDX's trading summary: exact share counts, close and the non-regular share, behind curl-impersonate and Cloudflare. - ForeignFlowDay gains foreign_buy_value, foreign_sell_value and foreign_share_bps for both sources (estimated at the session average for IDX); ForeignFlow gains source, the value totals and rounded. - The table lays out each source's figures; the live smoke runs both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
257 lines
11 KiB
Bash
Executable file
257 lines
11 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# Live provider drift check: runs shipped commands against real MSN, Yahoo,
|
|
# and IDX endpoints and validates the JSON output shape. Deterministic CLI
|
|
# behavior is covered by tests/cli.rs and tests/golden.rs; this answers "do the
|
|
# upstreams still look the way our parsers expect?"
|
|
#
|
|
# With --compare <old-idx>, every case also runs the old binary back to back
|
|
# and fails on any difference in exit code or JSON structure, and on any value
|
|
# difference for slow-moving data (see "Live A/B check" in docs/DESIGN.md).
|
|
|
|
set -u
|
|
set -o pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
SYMBOL="BBCA"
|
|
BIN_PATH=""
|
|
COMPARE_BIN=""
|
|
DRY_RUN=0
|
|
declare -a GROUPS_SELECTED=()
|
|
declare -a FAILURES=()
|
|
TOTAL=0
|
|
PASSED=0
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
Usage: scripts/live-smoke.sh [options]
|
|
|
|
Options:
|
|
--group <stocks|ownership> Restrict to a group. Repeatable. Default: both
|
|
--symbol <ticker> Primary ticker. Default: BBCA
|
|
--bin <path> Use an existing idx binary (skips cargo build)
|
|
--compare <old-idx> Also run an older idx binary and diff the results
|
|
--dry-run Print cases without running them
|
|
--help Show this help
|
|
EOF
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--group) GROUPS_SELECTED+=("${2:-}"); shift 2 ;;
|
|
--symbol) SYMBOL="${2:-}"; shift 2 ;;
|
|
--bin) BIN_PATH="${2:-}"; shift 2 ;;
|
|
--compare) COMPARE_BIN="${2:-}"; shift 2 ;;
|
|
--dry-run) DRY_RUN=1; shift ;;
|
|
--help|-h) usage; exit 0 ;;
|
|
*) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
[[ ${#GROUPS_SELECTED[@]} -eq 0 ]] && GROUPS_SELECTED=(stocks ownership)
|
|
|
|
for group in "${GROUPS_SELECTED[@]}"; do
|
|
case "$group" in
|
|
stocks|ownership) ;;
|
|
*) echo "invalid --group '$group' (expected stocks or ownership)" >&2; exit 2 ;;
|
|
esac
|
|
done
|
|
|
|
selected() {
|
|
local group
|
|
for group in "${GROUPS_SELECTED[@]}"; do
|
|
[[ "$group" == "$1" ]] && return 0
|
|
done
|
|
return 1
|
|
}
|
|
|
|
WORKDIR="$ROOT_DIR/tmp/live-smoke/$(date +%Y%m%d-%H%M%S)"
|
|
LOG_DIR="$WORKDIR/logs"
|
|
COMMON_ENV=("IDX_PROVIDER=msn" "IDX_HISTORY_PROVIDER=auto" "IDX_NO_COLOR=1")
|
|
|
|
# Each binary gets its own config, cache, and data so both really fetch.
|
|
side_env() {
|
|
local home="$WORKDIR/$1"
|
|
printf '%s\n' "XDG_CONFIG_HOME=$home/config" "XDG_CACHE_HOME=$home/cache" \
|
|
"XDG_DATA_HOME=$home/data" "${COMMON_ENV[@]}"
|
|
}
|
|
|
|
# Values of these commands should not move between two back-to-back runs.
|
|
stable_values() {
|
|
case "$1" in
|
|
"stocks profile "*|"stocks financials "*|"stocks earnings "*|"ownership ticker "*|"ownership releases") return 0 ;;
|
|
*) return 1 ;;
|
|
esac
|
|
}
|
|
|
|
# Key paths (array indices collapsed) that disappeared or appeared, and keys
|
|
# whose non-null type changed. Empty lists mean the structure matches.
|
|
# shellcheck disable=SC2016
|
|
SHAPE_DIFF='
|
|
def norm: map(if type == "number" then "*" else . end) | join(".");
|
|
def keyset: [paths | norm] | unique;
|
|
def types: [paths(scalars) as $p | select(getpath($p) != null)
|
|
| {k: ($p | norm), t: (getpath($p) | type)}]
|
|
| group_by(.k) | map({key: .[0].k, value: (map(.t) | unique)}) | from_entries;
|
|
($old[0] | keyset) as $ko | ($new[0] | keyset) as $kn
|
|
| ($old[0] | types) as $to | ($new[0] | types) as $tn
|
|
| {missing: ($ko - $kn), added: ($kn - $ko),
|
|
type_changed: [$to | keys[] as $k | select($tn[$k] != null and $tn[$k] != $to[$k]) | $k]}'
|
|
|
|
# jq filter describing the JSON shape each command must keep.
|
|
contract() {
|
|
case "$1" in
|
|
"stocks quote "*|"stocks screen"*)
|
|
echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.price | type == "number"))' ;;
|
|
"stocks history "*)
|
|
echo 'type == "array" and length > 0 and all(.[]; (.date | type == "string") and (.close | type == "number"))' ;;
|
|
"stocks technical "*)
|
|
echo '(.current_price | type == "number") and (.signals.overall | type == "string")' ;;
|
|
"stocks growth "*|"stocks valuation "*|"stocks risk "*)
|
|
echo '.overall_signal | type == "string"' ;;
|
|
"stocks fundamental "*)
|
|
echo '(.growth.overall_signal | type == "string") and (.valuation.overall_signal | type == "string") and (.risk.overall_signal | type == "string")' ;;
|
|
"stocks compare "*)
|
|
echo 'type == "array" and length > 0 and all(.[]; (.symbol | type == "string") and (.valuation | type == "object"))' ;;
|
|
"stocks profile "*)
|
|
echo '(.symbol | type == "string") and has("long_name") and (.officers | type == "array")' ;;
|
|
"stocks financials "*)
|
|
echo '(.instrument.symbol | type == "string") and has("income_statement") and has("balance_sheet") and has("cash_flow")' ;;
|
|
"stocks earnings "*)
|
|
echo '(.history | type == "array") and (.forecast | type == "array")' ;;
|
|
"stocks sentiment "*)
|
|
echo '.statistics | type == "array"' ;;
|
|
"stocks insights "*)
|
|
echo '(.highlights | type == "array") and (.risks | type == "array")' ;;
|
|
"stocks news "*)
|
|
echo 'type == "array" and all(.[]; (.title | type == "string") and (.url | type == "string"))' ;;
|
|
"stocks broker "*)
|
|
echo '(.symbol | type == "string") and (.buyers | length > 0) and (.sellers | length > 0) and all(.buyers[]; (.code | type == "string") and (.value | type == "number")) and (.total_value | type == "number")' ;;
|
|
"stocks bandar "*)
|
|
echo 'type == "array" and length > 0 and all(.[]; (.method | type == "string") and (.tiers | length == 4) and (.imbalance_bps | type == "number"))' ;;
|
|
"stocks flow "*"--source idx"*)
|
|
echo '(.source == "idx") and (.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy | type == "number") and (.foreign_sell | type == "number")) and (.foreign_net | type == "number")' ;;
|
|
"stocks flow "*)
|
|
echo '(.source == "ipot") and (.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy_value | type == "number") and (.foreign_sell_value | type == "number")) and (.foreign_net_value | type == "number")' ;;
|
|
"ownership discover"*)
|
|
echo 'type == "array" and length > 0 and (.[0].pdf_url | type == "string")' ;;
|
|
"ownership releases")
|
|
echo 'type == "array" and length == 1' ;;
|
|
*)
|
|
echo 'true' ;;
|
|
esac
|
|
}
|
|
|
|
run_bin() {
|
|
local side="$1" bin="$2" out="$3"
|
|
shift 3
|
|
local -a envs
|
|
mapfile -t envs < <(side_env "$side")
|
|
env "${envs[@]}" "$bin" -o json "$@" >"$out.stdout" 2>"$out.stderr"
|
|
}
|
|
|
|
run_case() {
|
|
local name="$1" cmd="$2" filter log status old_status diff
|
|
local -a args
|
|
TOTAL=$((TOTAL + 1))
|
|
printf '[%02d] %s\n' "$TOTAL" "$name"
|
|
if (( DRY_RUN )); then
|
|
printf ' idx -o json %s\n' "$cmd"
|
|
return
|
|
fi
|
|
|
|
log="$LOG_DIR/$(printf '%02d' "$TOTAL")_$name"
|
|
read -r -a args <<< "$cmd"
|
|
run_bin new "$BIN_PATH" "$log" "${args[@]}"
|
|
status=$?
|
|
if [[ -n "$COMPARE_BIN" ]]; then
|
|
run_bin old "$COMPARE_BIN" "$log.old" "${args[@]}"
|
|
old_status=$?
|
|
if (( status != old_status )); then
|
|
FAILURES+=("$name: exit $status, old binary exited $old_status (see $log.stderr, $log.old.stderr)")
|
|
echo " FAIL exit differs from old binary"
|
|
return
|
|
fi
|
|
fi
|
|
if (( status != 0 )); then
|
|
FAILURES+=("$name: exit $status (see $log.stderr)")
|
|
echo " FAIL exit"
|
|
return
|
|
fi
|
|
filter="$(contract "$cmd")"
|
|
if ! jq -e -s "length == 1 and (.[0] | $filter)" "$log.stdout" >/dev/null 2>"$log.validation"; then
|
|
FAILURES+=("$name: output shape changed (see $log.stdout)")
|
|
echo " FAIL shape"
|
|
return
|
|
fi
|
|
if [[ -n "$COMPARE_BIN" ]]; then
|
|
diff="$(jq -n --slurpfile old "$log.old.stdout" --slurpfile new "$log.stdout" "$SHAPE_DIFF")"
|
|
if [[ "$(jq -c '[.[] | length] | add' <<< "$diff")" != 0 ]]; then
|
|
printf '%s\n' "$diff" >"$log.diff"
|
|
FAILURES+=("$name: JSON structure differs from old binary (see $log.diff)")
|
|
echo " FAIL structure differs"
|
|
return
|
|
fi
|
|
if stable_values "$cmd" && ! cmp -s \
|
|
<(jq -S 'del(.. | .imported_at?)' "$log.old.stdout") \
|
|
<(jq -S 'del(.. | .imported_at?)' "$log.stdout"); then
|
|
FAILURES+=("$name: values differ from old binary (diff $log.old.stdout $log.stdout)")
|
|
echo " FAIL values differ"
|
|
return
|
|
fi
|
|
fi
|
|
PASSED=$((PASSED + 1))
|
|
echo " PASS"
|
|
}
|
|
|
|
# A command the --compare binary may predate runs on the new binary only.
|
|
run_new_case() {
|
|
local COMPARE_BIN=""
|
|
run_case "$@"
|
|
}
|
|
|
|
if (( ! DRY_RUN )); then
|
|
command -v jq >/dev/null 2>&1 || { echo "jq is required; run inside nix develop" >&2; exit 1; }
|
|
mkdir -p "$LOG_DIR"
|
|
if [[ -z "$BIN_PATH" ]]; then
|
|
BIN_PATH="$ROOT_DIR/target/debug/idx"
|
|
(cd "$ROOT_DIR" && cargo build --quiet --bin idx) || { echo "cargo build failed" >&2; exit 1; }
|
|
fi
|
|
if [[ -n "$COMPARE_BIN" && ! -x "$COMPARE_BIN" ]]; then
|
|
echo "--compare binary not found or not executable: $COMPARE_BIN" >&2
|
|
exit 2
|
|
fi
|
|
fi
|
|
|
|
if selected stocks; then
|
|
for cmd in quote technical growth valuation risk fundamental profile financials earnings sentiment insights; do
|
|
run_case "$cmd" "stocks $cmd $SYMBOL"
|
|
done
|
|
run_case history "stocks history $SYMBOL --period 3mo"
|
|
run_case news "stocks news $SYMBOL --limit 5"
|
|
run_case screen "stocks screen --filter top-performers --limit 10"
|
|
run_case compare "stocks compare $SYMBOL,BBRI"
|
|
# BUMI/ADRO/AIMS return non-finite MSN key ratios (Infinity/NaN).
|
|
run_case compare-nonfinite "stocks compare BUMI,ADRO,AIMS"
|
|
# Broker summaries and foreign flow (IPOT; --source idx via curl-impersonate).
|
|
run_new_case broker "stocks broker $SYMBOL"
|
|
run_new_case bandar "stocks bandar $SYMBOL,BBRI,TLKM"
|
|
run_new_case flow "stocks flow $SYMBOL --days 3"
|
|
run_new_case flow-idx "stocks flow $SYMBOL --days 3 --source idx"
|
|
fi
|
|
|
|
if selected ownership; then
|
|
run_case discover "ownership discover --limit 1"
|
|
if (( ! DRY_RUN )) && [[ -s "$LOG_DIR/$(printf '%02d' "$TOTAL")_discover.stdout" ]]; then
|
|
url="$(jq -r '.[0].pdf_url' "$LOG_DIR/$(printf '%02d' "$TOTAL")_discover.stdout")"
|
|
run_case import "ownership import --url $url"
|
|
run_case releases "ownership releases"
|
|
run_case ticker "ownership ticker $SYMBOL"
|
|
fi
|
|
fi
|
|
|
|
(( DRY_RUN )) && { echo "dry-run: $TOTAL case(s)"; exit 0; }
|
|
[[ -n "$COMPARE_BIN" ]] && printf '\ncompared against: %s\n' "$COMPARE_BIN"
|
|
printf '\nsummary: %d passed, %d failed\nlogs: %s\n' "$PASSED" "$((TOTAL - PASSED))" "$LOG_DIR"
|
|
if (( ${#FAILURES[@]} > 0 )); then
|
|
printf ' - %s\n' "${FAILURES[@]}"
|
|
exit 1
|
|
fi
|