Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ version in lockstep (enforced by `scripts/release/version_sync_check.py`).

## [Unreleased]

### Added

- **Gold factor model, computed by argon.** A port of the signal-lab gold study: a rolling 252-day fit of gold's daily move (XAUUSD) on the 10-year real yield (DFII10), a synthetic ICE dollar index and a safe-haven spread, with coefficients lagged two rows. It produces the 95% intervals, the 63-day explained share and its label, and each day's move split into real-yield, dollar, safe-haven, last-year's-pace and unexplained parts. It is a historical description, not a forecast: the study's direction test failed. Engine `macro/gold_factor.py`. Results go to `gold_factor_daily` (migration 162). The new `gold_factor_compute` job (17:40 ET Mon–Fri, massive-0) fetches the last month of IB 1-day MIDPOINT closes through xenon `POST /historical/bars`, stores them in `macro_series_daily` (source `IB`), then recomputes and upserts the whole history. The IB fetch runs only when `XENON_QUERY_API_KEY` is set, and needs xenon PR #174 deployed. `GET /api/gold/factor` serves daily points and yearly sums. The Gold tab opens with a "What moved gold" band of six charts. `scripts/backfill/gold_factor_seed.py` seeds the history: IB closes through xenon in overlapping 6-year windows (one call of 17 years or more outruns IB's 60 s timeout), plus FRED DFII10/DGS10 from 2010 run through the real FRED job. `scripts/research/gold_factor_parity.py` checks the port: from the study's own IB closes, argon matches the study exactly on inputs, coefficients, intervals, parts and labels; with `--xenon`, the same run on closes pulled through xenon matches it exactly too, on all 4079 rows (`docs/research/2026-10-10-gold-factor-parity.json`). `statsmodels` (already installed via `arch`) is now declared.

## [0.13.29] — 2026-10-05


Expand Down
119 changes: 119 additions & 0 deletions docs/research/2026-10-10-gold-factor-parity.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
{
"study": "/Users/chenxi/projects/signal-lab/.worktrees/research-gold-yield-usd-triad",
"study_commit": "2408775",
"frozen_end": "2026-10-07",
"inputs": {
"rows_argon": 4079,
"rows_study": 4079,
"same_dates": true,
"max_abs_y": 0.0,
"max_abs_r": 0.0,
"max_abs_d": 0.0,
"max_abs_s": 0.0
},
"estimator": {
"01.a": 0.0,
"01.b_r": 0.0,
"01.b_d": 0.0,
"01.b_s": 0.0,
"01.b_r_lo95": 0.0,
"01.b_r_hi95": 0.0,
"01.b_d_lo95": 0.0,
"01.b_d_hi95": 0.0,
"01.b_s_lo95": 0.0,
"01.b_s_hi95": 0.0,
"01.e": 0.0,
"03b.gold_change": 0.0,
"03b.own_trailing_drift": 0.0,
"03b.real_yield_part": 0.0,
"03b.dollar_part": 0.0,
"03b.haven_part": 0.0,
"03b.U_1": 4.440892098500626e-15,
"03b.U_5": 0.0,
"03b.U_21": 0.0,
"01.published_name_mismatches": 0,
"01.raw_name_mismatches": 0,
"thresholds_recomputed": [
0.21839387732373133,
0.4020650422009684
],
"thresholds_constant": [
0.21839387732373133,
0.4020650422009684
]
},
"xenon": {
"dir": "/tmp/xenon-argon-verify",
"legs": {
"XAUUSD": {
"first": "2010-06-09",
"overlap": 4218,
"mismatch_dates": [],
"study_only_dates": []
},
"EURUSD": {
"first": "2005-03-09",
"overlap": 5597,
"mismatch_dates": [
"2005-12-27",
"2006-01-03",
"2006-05-29"
],
"study_only_dates": []
},
"USDJPY": {
"first": "2005-03-09",
"overlap": 5597,
"mismatch_dates": [
"2005-12-27",
"2006-01-03",
"2006-05-29"
],
"study_only_dates": []
},
"GBPUSD": {
"first": "2005-03-09",
"overlap": 5597,
"mismatch_dates": [
"2005-12-27",
"2006-01-03",
"2006-05-29"
],
"study_only_dates": []
},
"USDCAD": {
"first": "2005-03-09",
"overlap": 5597,
"mismatch_dates": [
"2005-12-27",
"2006-01-03",
"2006-05-29"
],
"study_only_dates": []
},
"USDSEK": {
"first": "2006-09-01",
"overlap": 5212,
"mismatch_dates": [],
"study_only_dates": []
},
"USDCHF": {
"first": "2005-03-09",
"overlap": 5597,
"mismatch_dates": [
"2005-12-27",
"2006-01-03",
"2006-05-29"
],
"study_only_dates": []
}
},
"rows": [
4079,
4079
],
"same_dates": true,
"max_abs_diff": 0.0,
"label_mismatches": 0
}
}
3 changes: 2 additions & 1 deletion docs/runbooks/data-gap-dataset-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ Generated from `REGISTRY` in `src/uw_scan/reports/data_gap_registry/` (one sourc
uv run python -c "from uw_scan.reports.data_gap_healer import render_dataset_policy_markdown as r; open('docs/runbooks/data-gap-dataset-policy.md','w').write(r())"
```

**186 datasets** across 11 groups.
**187 datasets** across 11 groups.

## core_watchlist

Expand Down Expand Up @@ -73,6 +73,7 @@ uv run python -c "from uw_scan.reports.data_gap_healer import render_dataset_pol
| etf_flows_daily | freshness_only | none | none | | equity_session | EXTERNAL-PROVIDER BLOCK, not a healer gap: the source requires an interactive auth cookie and exposes no historical API, so there is nothing for an adapter to call. Re-probe if a credential is ever provisioned. | 2026-08-16 |
| etf_holdings_daily | freshness_only | none | none | | equity_session | EXTERNAL-PROVIDER BLOCK, not a healer gap: the source requires an interactive auth cookie and exposes no historical API, so there is nothing for an adapter to call. Re-probe if a credential is ever provisioned. | 2026-08-16 |
| exchange_inventory_daily | freshness_only | external | run_once | gold_lbma | monthly | | |
| gold_factor_daily | freshness_only | db | run_once | gold_factor | equity_session | | |
| gold_posture_daily | freshness_only | db | run_once | gold_posture | equity_session | | |
| macro_series_daily | freshness_only | external | run_once_lookback | macro_fred | daily | | |
| macro_series_monthly | freshness_only | external | run_once_lookback | macro_fred | monthly | | |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,12 @@ forbidden, and `select_sessions` is what forbids it. The trigger was
`2026-08-14` going missing during the 08-11..14 outage with no mechanism able to
reach it.

**Post-plan amendment, 2026-10-10 — scope 59 → 60.** `gold_factor_daily`
(migration 162, the gold factor model) entered the daily scope wired: its
`gold_factor` adapter reruns `gold_factor_compute_job`, which recomputes the whole
history from `macro_series_daily` with no external spend. **33 wired + 27 dated
refusals.**

| task | datasets it makes healable |
|---|---|
| already wired | `option_surface_grid_daily`, `greek_exposure_daily`, `uw_gex_levels_daily`, `uw_volatility_signal_daily`, `uw_short_pressure_daily`, `daily_ohlc`, `vrp_daily`, `stock_analytics_daily`, `realized_volatility_history`, `volatility_stats_history`, `market_tide_sentiment_daily`, `macro_series_daily`, `rates_*`, `gold_posture_daily`, `uw_gold_options_daily` |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies = [
"websockets>=12.0",
"scipy>=1.17.1",
"arch==8.0.0",
"statsmodels>=0.14",
]

[project.scripts]
Expand Down
65 changes: 65 additions & 0 deletions scripts/backfill/gold_factor_seed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""One-time seed of the gold factor model's history, then one compute.

uv run python scripts/backfill/gold_factor_seed.py

1. IB 1-day MIDPOINT closes (XAUUSD + the six dollar-index pairs) through xenon
POST /historical/bars -> macro_series_daily, source 'IB'. One call of 17 Y or more outruns
IB's 60 s timeout, and IB's "6 Y" is not an exact calendar span, so the history comes in
overlapping 6 Y windows plus a final 2 Y to now; repeated dates are identical and the
latest vintage wins on read. XAUUSD starts 2010-06-09 at IB, which bounds the model.
2. FRED DFII10 / DGS10 from 2010 through the real gold_fred_ingest_job (argon's stored FRED
history starts 2021-05).
3. gold_factor_compute_job.

Needs XENON_QUERY_API_URL / XENON_QUERY_API_KEY. Idempotent: inserts are ON CONFLICT DO
NOTHING on (series_id, obs_date, as_of) and the compute upserts. Parity of these xenon closes
with the study: the "xenon" block of docs/research/2026-10-10-gold-factor-parity.json.
"""

from __future__ import annotations

from datetime import date

import psycopg

from uw_scan.config import Settings
from uw_scan.worker.jobs.gold_factor import fetch_ib_legs, gold_factor_compute_job
from uw_scan.worker.jobs.gold_jobs import gold_fred_ingest_job

# (duration, end) per window, oldest first; each 6 Y window overlaps the next by a year.
WINDOWS = [
("6 Y", "20150201-00:00:00"),
("6 Y", "20200201-00:00:00"),
("6 Y", "20250201-00:00:00"),
("2 Y", ""),
]
FRED_START = date(2010, 1, 1)


def main() -> None:
s = Settings.from_env()
if not (s.xenon_query_api_url and s.xenon_query_api_key):
raise SystemExit("XENON_QUERY_API_URL and XENON_QUERY_API_KEY must be set")
dsn = s.db_dsn()
with psycopg.connect(dsn) as conn:
for duration, end in WINDOWS:
failures = fetch_ib_legs(
conn,
base_url=s.xenon_query_api_url,
api_key=s.xenon_query_api_key.get_secret_value(),
duration=duration,
end_date_time=end,
)
print(f"{duration} to {end or 'now'}: {'ok' if not failures else failures}")
if failures:
raise SystemExit("xenon fetch failed; rerun (inserts are idempotent)")
gold_fred_ingest_job(
dsn=dsn,
series_ids=["DFII10", "DGS10"],
lookback_days=(date.today() - FRED_START).days,
)
print(f"gold_factor_daily: {gold_factor_compute_job(dsn=dsn)} rows")


if __name__ == "__main__":
main()
142 changes: 142 additions & 0 deletions scripts/research/gold_factor_parity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Parity of argon's gold factor engine (uw_scan.macro.gold_factor) with the signal-lab study.

uv run python scripts/research/gold_factor_parity.py \
--study ~/projects/signal-lab/.worktrees/research-gold-yield-usd-triad \
[--xenon /tmp/xenon-argon-verify]

Two checks, written to docs/research/2026-10-10-gold-factor-parity.json:
inputs: the study's raw IB 1-day midpoint closes + FRED files -> argon `changes` vs the
study's grid_ext y/r/d/s (rows to the frozen end 2026-10-07)
estimator: grid_ext y/r/d/s -> argon `compute` vs the study's coefficient and daily-parts
tables (01_coef_A_daily, 03b_unexplained_daily)
xenon: (with --xenon DIR) the same estimator run on closes pulled through xenon
POST /historical/bars, one <LEG>.json bar list per price leg, in place of the
study's IB files; outputs compared column by column to the frozen end
"""

from __future__ import annotations

import argparse
import json
import subprocess
from pathlib import Path

import numpy as np
import pandas as pd

from uw_scan.macro import gold_factor as gf

RUN = "research/runs/2026-10-09-gold-regime-direction"
SHEET = "research/forward/gold-regime-direction/statesheet"
FROZEN_END = pd.Timestamp("2026-10-07")
OUT = Path("docs/research/2026-10-10-gold-factor-parity.json")


def maxdiff(a: pd.Series, b: pd.Series) -> float:
a, b = a.align(b, join="inner")
m = np.isfinite(a.to_numpy(float)) | np.isfinite(b.to_numpy(float))
return float(np.nanmax(np.abs(a.to_numpy(float)[m] - b.to_numpy(float)[m])))


def load_levels(study: Path) -> pd.DataFrame:
ib, fred = study / RUN / "data/d1/data/ib", study / RUN / "data/lead/fred"
cols = {
leg: pd.read_csv(ib / f"ib_{leg.lower()}_1d_midpoint.csv", parse_dates=["date"]).set_index("date")["close"]
for leg in gf.PRICE_LEGS
}
for sid in ("DFII10", "DGS10"):
cols[sid] = pd.read_csv(fred / f"{sid}.csv", parse_dates=["date"]).set_index("date")["value"].astype(float)
return pd.DataFrame(cols)


def xenon_check(study: Path, xdir: Path) -> dict:
"""Swap the study's IB closes for xenon's, rerun everything, diff the two outputs."""
lv = load_levels(study)
xen, legs = lv.copy(), {}
for leg in gf.PRICE_LEGS:
bars = pd.DataFrame(json.loads((xdir / f"{leg}.json").read_text()))
s = bars.assign(date=pd.to_datetime(bars.date)).drop_duplicates("date", keep="last").set_index("date")["close"]
a, b = lv[leg].loc[:FROZEN_END].dropna().align(s, join="inner")
legs[leg] = {
"first": str(s.index[0].date()),
"overlap": len(a),
"mismatch_dates": [str(d.date()) for d in a.index[(b / a - 1).abs() > 1e-6]],
"study_only_dates": [str(d.date()) for d in lv[leg].loc[:FROZEN_END].dropna().index.difference(s.index)],
}
xen[leg] = s

def run(levels: pd.DataFrame) -> pd.DataFrame:
chg = gf.changes(levels.loc[levels.dropna().index[0] : FROZEN_END])
return gf.compute(chg[["y", "r", "d", "s"]])

mine, theirs = run(lv), run(xen)
num = [c for c in mine.columns if mine[c].dtype.kind == "f"]
return {
"legs": legs,
"rows": [len(mine), len(theirs)],
"same_dates": bool(mine.index.equals(theirs.index)),
"max_abs_diff": max(maxdiff(mine[c], theirs[c]) for c in num),
"label_mismatches": int(sum((mine[c].fillna("U") != theirs[c].fillna("U")).sum() for c in ("raw_label", "published_label"))),
}


def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--study", type=Path, required=True)
ap.add_argument("--xenon", type=Path, help="dir of <LEG>.json xenon bar lists")
args = ap.parse_args()
study = args.study.expanduser()
ext = pd.read_parquet(study / RUN / "panel/data/grid_ext.parquet")
c01 = pd.read_parquet(study / SHEET / "01_coef_A_daily.parquet")
c3b = pd.read_parquet(study / SHEET / "03b_unexplained_daily.parquet")

# inputs: the study builds its window from the first complete date to the frozen end
lv = load_levels(study)
first = lv.dropna().index[0]
mine = gf.changes(lv.loc[first:FROZEN_END])
inputs = {
"rows_argon": len(mine),
"rows_study": len(ext),
"same_dates": bool(mine.index.equals(ext.index)),
**{f"max_abs_{c}": maxdiff(mine[c], ext[c]) for c in ("y", "r", "d", "s")},
}

out = gf.compute(ext[["y", "r", "d", "s"]])
pairs = {c: c for c in ("a", "b_r", "b_d", "b_s", "b_r_lo95", "b_r_hi95", "b_d_lo95", "b_d_hi95", "b_s_lo95", "b_s_hi95")}
pairs["e"] = "explained_share"
est = {f"01.{k}": maxdiff(out[v], c01[k]) for k, v in pairs.items()}
for k, v in {
"gold_change": "gold_change",
"own_trailing_drift": "own_trailing_drift",
"real_yield_part": "real_yield_part",
"dollar_part": "dollar_part",
"haven_part": "haven_part",
"U_1": "unexplained_1",
"U_5": "unexplained_5",
"U_21": "unexplained_21",
}.items():
est[f"03b.{k}"] = maxdiff(out[v], c3b[k])
pub = out["published_label"].map(gf.STUDY_NAME).reindex(c01.index)
raw = out["raw_label"].map(gf.STUDY_NAME).reindex(c01.index)
est["01.published_name_mismatches"] = int((pub.fillna("UNDEF") != c01["published_name"]).sum())
est["01.raw_name_mismatches"] = int((raw.fillna("UNDEF") != c01["raw_name"]).sum())
e = out.loc[:"2021-12-31", "explained_share"].dropna()
est["thresholds_recomputed"] = np.percentile(e, [33, 67]).tolist()
est["thresholds_constant"] = [gf.E_LO, gf.E_HI]

commit = subprocess.run(
["git", "-C", str(study), "rev-parse", "--short", "HEAD"], capture_output=True, text=True
).stdout.strip()
res = {"study": str(study), "study_commit": commit, "frozen_end": str(FROZEN_END.date()), "inputs": inputs, "estimator": est}
if args.xenon:
res["xenon"] = {"dir": str(args.xenon), **xenon_check(study, args.xenon)}
elif OUT.exists(): # keep a previous xenon block when rerunning without the bars
prev = json.loads(OUT.read_text()).get("xenon")
if prev:
res["xenon"] = prev
OUT.write_text(json.dumps(res, indent=1) + "\n")
print(json.dumps(res, indent=1))


if __name__ == "__main__":
main()
Loading
Loading