Skip to content
Closed
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
61 changes: 61 additions & 0 deletions .github/workflows/work12-24-01-schd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: WORK12-24-01 SCHD real source collection

on:
push:
branches: [work12-24-01-schd-actions-20261008]
paths:
- 'work12_24_01/**'
- '.github/workflows/work12-24-01-schd.yml'
workflow_dispatch:
pull_request:
paths:
- 'work12_24_01/**'
- '.github/workflows/work12-24-01-schd.yml'

permissions:
contents: write

jobs:
schd:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
ref: work12-24-01-schd-actions-20261008
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install required Python dependencies
run: python -m pip install --disable-pip-version-check 'yfinance>=0.2.60,<2' 'pandas>=2.2,<3' 'pandas_market_calendars>=4.4,<6' 'requests>=2.31,<3'
- name: Download issuer NAV and Yahoo candidate
id: capture
continue-on-error: true
run: python work12_24_01/fetch_schd.py
- name: Capture output artifact and audit regardless of network result
if: always()
uses: actions/upload-artifact@v4
with:
name: WORK12-24-01-SCHD-raw-nav-and-market-candidate
path: work12_24_01/output/
if-no-files-found: error
retention-days: 30
- name: Persist real fetched data in this isolated public branch
if: always()
run: |
if [ ! -f work12_24_01/output/SOURCE_AUDIT.json ]; then
echo 'No audit file; nothing to persist'
exit 1
fi
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add work12_24_01/output/
if ! git diff --cached --quiet; then
git commit -m 'Archive fetched SCHD third-party dataset and provenance audit'
git push origin HEAD:work12-24-01-schd-actions-20261008
fi
- name: Enforce official raw NAV presence
if: steps.capture.outcome == 'failure'
run: |
echo 'Official NAV capture failed; Yahoo alone cannot close WORK12-24-01.'
exit 1
18 changes: 18 additions & 0 deletions work12_24_01/PROJECT_RULE_GITHUB_YAHOO_FINANCE_FETCH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# 🏞️ 活水 AI 理財教練|Yahoo Finance 真實資料擷取固定執行規則

使用者原文(2026/10/08;保留原句,不改字):

> 記住,照抄,記在專案,以後抓資料要寫程式放在 GitHub 實際連線 Yahoo Finance 下載的資料。記住記住記住

## 執行要求
- 之後凡需 Yahoo Finance 股票/ETF 歷史資料,先編寫 Python / yfinance 擷取程式,提交至 GitHub,由 GitHub Actions 實際連線、實際執行下載。
- 保留程式、工作流程、執行紀錄、下載 CSV、SHA256、日期範圍、筆數、價格口徑、配息、拆股、交易日檢查與來源稽核;不准用模擬數據、AI 猜測或人工編造資料代替實際下載。
- Yahoo Finance 資料應標示「第三方市場行情」,不可冒充 Schwab 基金官方 NAV、官方配息來源或已核准 Canonical DB;正式來源驗收、匯入權限與治理 Gate 仍須各自完成。
- 這個 GitHub 用途是獨立的資料抓取/收集工具,不表示把 Family 系統正式部署到 GitHub,亦不改變既有 WBS、SOT 與 Release Lock。

## 已實際成功案例
- WORK12-24-01 / SCHD;GitHub Actions 下載 3,762 筆 2011-10-20~2026-10-07 的 Yahoo 歷史股價,含 Adj Close、股利和拆股。
- 公開原始抓取紀錄:https://github.com/emmanuelwithme/OpenDataStockAnalysis/actions/runs/37751852796
- 目前仍在獨立分支,不併入主線或 Canonical DB。

此檔是專案執行偏好紀錄,並非 Family 正式 WBS/母規則的治理變更。
20 changes: 20 additions & 0 deletions work12_24_01/WORK12-24-01_OWNER_APPROVED_CLOSEOUT_v1.2.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# WORK12-24-01|正式結案紀錄 v1.2.0

- 日期:2026/10/08(台灣時間)
- 結案狀態:**CLOSED_OWNER_APPROVED_YAHOO_SOURCE / PASS**
- 使用者明確授權:「趕快結案,用雅虎的Finance可以。」
- **本次最新授權取代此棒原本『必須取得 Schwab 官方 NAV 原始 CSV』的來源驗收要求。** Schwab 網站 HTTP 403 不再阻礙 24-01 來源收集結案。
- 採用:GitHub Actions 真正連線 Yahoo Finance、經 yfinance 擷取的 SCHD 歷史**市場行情**。
- 正式資料:**3,762 筆**,2011/10/20~2026/10/07;Date、Open、High、Low、Close、Adj Close、Volume、Dividends、Stock Splits。
- 來源實際下載執行:https://github.com/emmanuelwithme/OpenDataStockAnalysis/actions/runs/37751852796
- 分支實際保存 CSV:[實際擷取 CSV](https://github.com/emmanuelwithme/OpenDataStockAnalysis/blob/work12-24-01-schd-actions-20261008/work12_24_01/output/SCHD_YAHOO_MARKET_OHLCV_ADJCLOSE_DIVIDENDS_SPLITS.csv)
- CSV SHA256:`af113a6c64be2f9f55ce0ca364e7073a68cf3eed7eba6e65b854f4bc1d81e337`
- 驗收 QA:3,762 NYSE 交易日完整(無漏日/假交易日),無重複日期、空值及 OHLC 不合理結構,**60 筆配息、2024/10/11 1 拆 3**。
- 資料定義:**Yahoo Finance MARKET_OHLCV,不是 Schwab 官方 NAV**;Close 已拆股調整,Adj Close 另含配息調整。正式績效研究不得重複計算配息或拆股。
- 來源審查與檔案封裝已完成,ZIP 解壓 SHA256 檢查 PASS。
- **只結案 WORK12-24-01**。未直接更新上游正式母程式/WBS,不宣告整個 WORK12-24 完成。
- 工程邊界:`canonical_written=false`、`requires_bline_authorized_import=true`、`PAUSED_WAITING_BLINE_IMPORT`、Active Universe 凍結不動。
- 後續 24-02 / 24-25 的 Release 依 W1 小 PM/Family 治理;不以本棒結案為自行放行理由。
- 結案包:`WORK12-24-01_OWNER_APPROVED_YAHOO_CLOSEOUT_v1.2.0_2026-10-08.zip`,本次對話交付,包含前版 checkpoint、Yahoo 真實行情、原始 Actions ZIP、QA、來源紀錄與重解壓驗證程式。

**以此正式決定覆蓋以前 24-01 因 Schwab 官方 NAV 403 而無法結案的舊判斷,但保留原有審計證據。**
151 changes: 151 additions & 0 deletions work12_24_01/fetch_schd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""WORK12-24-01: preserve real Schwab NAV bytes and independent Yahoo market-price candidate."""
import hashlib
import json
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from zoneinfo import ZoneInfo

import pandas as pd
import pandas_market_calendars as mcal
import requests
import yfinance as yf
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

NAV_URL = "https://www.schwabassetmanagement.com/sites/g/files/eyrktu361/files/product_files/SCHD/SCHD_NAV_History.CSV"
OUT = Path("work12_24_01/output")
OUT.mkdir(parents=True, exist_ok=True)
report = {
"work": "WORK12-24-01",
"ticker": "SCHD",
"retrieved_at_utc": datetime.now(timezone.utc).isoformat(),
"canonical_written": False,
"requires_bline_authorized_import": True,
"official_nav": {"url": NAV_URL, "status": "BLOCKED", "source_version": None},
"yahoo_market_data": {"status": "BLOCKED", "source_version": None},
"notes": [
"Schwab NAV and Yahoo market prices are different data series.",
"A Yahoo CSV exported by yfinance is derived export bytes, NOT issuer raw-source bytes.",
"No synthetic records; no forward-fill; no canonical or active-universe admission.",
"Yahoo OHLC with auto_adjust=False remains split-adjusted; Adj Close incorporates dividend adjustments.",
],
}


def sha256(data):
return hashlib.sha256(data).hexdigest()


def official_nav():
s = requests.Session()
retry = Retry(total=3, connect=3, read=3, backoff_factor=2,
status_forcelist=[429, 500, 502, 503, 504])
s.mount("https://", HTTPAdapter(max_retries=retry))
response = s.get(NAV_URL, timeout=(15, 60),
headers={"User-Agent": "Mozilla/5.0 (compatible; SCHD-research-source-check/1.0)"})
response.raise_for_status()
raw = response.content
probe = raw[:4000].lstrip().lower()
if len(raw) < 4000 or raw.count(b"\n") < 30 or probe.startswith(
(b"<!doctype", b"<html", b"<?xml")
):
raise ValueError("Official NAV response is too small or resembles HTML/XML, not an NAV CSV")
# Keep the *exact* response bytes: no Unicode transcoding, spreadsheet round-trip or reformatting.
target = OUT / "SCHD_SCHWAB_NAV_History_RAW.CSV"
target.write_bytes(raw)
report["official_nav"].update({
"status": "RAW_SOURCE_CAPTURED_NEEDS_ROW_VALIDATION",
"raw_file": target.name,
"bytes": len(raw),
"sha256": sha256(raw),
"http_status": response.status_code,
"content_type": response.headers.get("Content-Type"),
"etag": response.headers.get("ETag"),
"last_modified": response.headers.get("Last-Modified"),
})


def yahoo_candidate():
eastern = datetime.now(ZoneInfo("America/New_York"))
# 'end' is exclusive. Never mix an incomplete live session into the final history.
last_full_date = eastern.date() - timedelta(days=1)
end_exclusive = last_full_date + timedelta(days=1)
hist = yf.Ticker("SCHD").history(
start="2011-10-20", end=end_exclusive.isoformat(),
interval="1d", auto_adjust=False, actions=True, repair=False
)
if hist.empty:
raise ValueError("Yahoo Finance returned zero rows")
missing_cols = sorted(set(["Open", "High", "Low", "Close", "Adj Close",
"Volume", "Dividends", "Stock Splits"]) - set(hist.columns))
if missing_cols:
raise ValueError(f"Missing required Yahoo columns: {missing_cols}")
frame = hist.reset_index()
if "Date" not in frame.columns:
raise ValueError("Yahoo Date index not found")
frame["Date"] = pd.to_datetime(frame["Date"]).dt.strftime("%Y-%m-%d")
frame = frame[["Date", "Open", "High", "Low", "Close", "Adj Close",
"Volume", "Dividends", "Stock Splits"]].copy()
if frame["Date"].duplicated().any():
raise ValueError("Yahoo duplicated trading dates")
if not frame["Date"].is_monotonic_increasing:
raise ValueError("Yahoo dates not monotonic")
if frame[["Open", "High", "Low", "Close", "Adj Close"]].isna().any().any():
raise ValueError("Yahoo has null price values")
if (frame[["Open", "High", "Low", "Close", "Adj Close"]] <= 0).any().any():
raise ValueError("Yahoo has non-positive price values")
if (frame["High"] < frame[["Open", "Low", "Close"]].max(axis=1) - 1e-6).any():
raise ValueError("Yahoo High < Open, Low or Close")
if (frame["Low"] > frame[["Open", "High", "Close"]].min(axis=1) + 1e-6).any():
raise ValueError("Yahoo Low > Open, High or Close")
if (frame["Volume"] < 0).any():
raise ValueError("Yahoo has negative volume")
nyse = mcal.get_calendar("NYSE")
nyse_days = set(nyse.valid_days(start_date=frame["Date"].iloc[0],
end_date=frame["Date"].iloc[-1]).strftime("%Y-%m-%d"))
extras = sorted(set(frame["Date"]) - nyse_days)
if extras:
raise ValueError("Yahoo returned non-NYSE trading dates: " + str(extras[:10]))
misses = sorted(nyse_days - set(frame["Date"]))
if frame["Date"].iloc[-1] > last_full_date.isoformat():
raise ValueError("Future or incomplete live-session date present")
target = OUT / "SCHD_YAHOO_MARKET_OHLCV_ADJCLOSE_DIVIDENDS_SPLITS.csv"
frame.to_csv(target, index=False, encoding="utf-8-sig", float_format="%.8f")
blob = target.read_bytes()
split_events = frame.loc[frame["Stock Splits"] != 0, ["Date", "Stock Splits"]]
report["yahoo_market_data"].update({
"status": "CANDIDATE_FOR_INDEPENDENT_VERIFICATION",
"file": target.name, "rows": len(frame),
"first_date": frame["Date"].iloc[0], "last_date": frame["Date"].iloc[-1],
"bytes": len(blob), "export_sha256": sha256(blob),
"missing_nyse_sessions": misses[:100],
"missing_nyse_sessions_count": len(misses),
"corporate_action_split_events": split_events.to_dict("records"),
"data_class": "THIRD_PARTY_MARKET_DATA",
"price_basis": "split-adjusted OHLC, non-dividend-adjusted when auto_adjust=False",
})


if __name__ == "__main__":
for label, task in [("official_nav", official_nav),
("yahoo_market_data", yahoo_candidate)]:
try:
task()
except Exception as exc:
report[label]["status"] = "BLOCKED"
report[label]["error"] = f"{type(exc).__name__}: {exc}"
print(f"[{label}] BLOCKED: {exc}", file=sys.stderr)
report["work12_24_01_closeout"] = (
"RAW_SOURCE_AVAILABLE_PENDING_FORMAL_REVIEW"
if report["official_nav"]["status"] == "RAW_SOURCE_CAPTURED_NEEDS_ROW_VALIDATION"
else "BLOCKED_SOURCE_DOWNLOAD"
)
(OUT / "SOURCE_AUDIT.json").write_text(
json.dumps(report, ensure_ascii=False, indent=2, default=str) + "\n",
encoding="utf-8"
)
print(json.dumps(report, ensure_ascii=False, indent=2, default=str))
# No PASS based solely on a third-party market-price export.
sys.exit(0 if report["official_nav"]["status"].startswith("RAW_SOURCE_CAPTURED") else 2)
Loading