mirror of
https://github.com/0xrsydn/idx-cli.git
synced 2026-11-07 09:45:36 +00:00
`idx stocks flow SYMBOL` shows what foreign investors bought and sold of a stock, session by session, from IDX's own daily trading summary: foreign buy, sell and net volume, net value at the session's average price, and the share of value traded outside the regular board, with range totals. Latest 10 sessions by default (--days, at most 60) or --from/--to. The MCP server exposes it as get_foreign_flow. - api/idx: pure parser for GetStockSummary (rows dated another session are dropped) and a provider over curl-impersonate's profile fallback with a 30 s limit; Cloudflare passes only some fingerprints. - flow: each session is cached whole by date, so other stocks' flows for the same sessions need no request. Weekends are never requested. - Idx::idx_code is shared by broker and flow for the .JK-only check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V3syn2fN2LNo5pvTSqc6eh
254 lines
10 KiB
Bash
Executable file
254 lines
10 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 "*)
|
|
echo '(.days | length > 0) and all(.days[]; (.date | type == "string") and (.foreign_buy | type == "number") and (.foreign_sell | type == "number")) and (.foreign_net | 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 (IPOT) and foreign flow (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"
|
|
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
|