Skip to content

Commit 148b14a

Browse files
Pigbibicursoragent
andcommitted
Add daily IBIT BTC platform handoff publish workflow.
Automate fetching BTC daily OHLCV, building the smart DCA handoff index, and uploading to the shared GCS prefix so Firstrade and other platforms can consume fresh market signals without manual VPS intervention. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d08daef commit 148b14a

3 files changed

Lines changed: 428 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
name: Publish Platform Handoffs
2+
3+
on:
4+
schedule:
5+
# Daily after the prior UTC date's BTC daily close is available.
6+
- cron: '15 1 * * *'
7+
workflow_dispatch:
8+
inputs:
9+
execute_publish:
10+
description: Upload platform handoffs to GCS. false only builds a GitHub artifact.
11+
required: true
12+
default: false
13+
type: boolean
14+
as_of:
15+
description: Optional signal as-of date (YYYY-MM-DD).
16+
required: false
17+
type: string
18+
gcs_prefix:
19+
description: Optional GCS prefix override for platform handoffs.
20+
required: false
21+
type: string
22+
23+
concurrency:
24+
group: ${{ github.workflow }}-${{ github.ref_name }}
25+
cancel-in-progress: false
26+
27+
jobs:
28+
publish-ibit-btc-handoff:
29+
runs-on: ubuntu-latest
30+
timeout-minutes: 20
31+
permissions:
32+
contents: read
33+
id-token: write
34+
env:
35+
GCP_PROJECT_ID: ${{ vars.GCP_PROJECT_ID || 'interactivebrokersquant' }}
36+
GCP_WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
37+
GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT: ${{ vars.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
38+
GCS_PREFIX: ${{ inputs.gcs_prefix || vars.MARKET_SIGNAL_GCS_PREFIX || 'gs://qsl-runtime-logs-shared/platform_handoffs' }}
39+
EXECUTE_PUBLISH: ${{ github.event_name == 'schedule' && 'true' || inputs.execute_publish }}
40+
41+
steps:
42+
- name: Checkout
43+
uses: actions/checkout@v4
44+
45+
- name: Set up Python
46+
uses: actions/setup-python@v5
47+
with:
48+
python-version: '3.11'
49+
50+
- name: Install package
51+
run: |
52+
set -euo pipefail
53+
python -m pip install --upgrade pip
54+
python -m pip install -e .
55+
56+
- name: Authenticate to Google Cloud
57+
if: env.EXECUTE_PUBLISH == 'true'
58+
uses: google-github-actions/auth@v3
59+
with:
60+
workload_identity_provider: ${{ env.GCP_WORKLOAD_IDENTITY_PROVIDER }}
61+
service_account: ${{ env.GCP_WORKLOAD_IDENTITY_SERVICE_ACCOUNT }}
62+
63+
- name: Set up gcloud
64+
if: env.EXECUTE_PUBLISH == 'true'
65+
uses: google-github-actions/setup-gcloud@v3
66+
with:
67+
project_id: ${{ env.GCP_PROJECT_ID }}
68+
version: '>= 416.0.0'
69+
70+
- name: Build and publish IBIT BTC platform handoff
71+
id: publish
72+
run: |
73+
set -euo pipefail
74+
args=(
75+
--work-dir data/output
76+
--source-version 0.1.1
77+
--code-commit "${GITHUB_SHA}"
78+
--gcs-prefix "${GCS_PREFIX}"
79+
)
80+
if [ -n "${{ inputs.as_of }}" ]; then
81+
args+=(--as-of "${{ inputs.as_of }}")
82+
fi
83+
if [ "${EXECUTE_PUBLISH}" = "true" ]; then
84+
args+=(--execute)
85+
fi
86+
python scripts/publish_ibit_btc_platform_handoff.py "${args[@]}"
87+
as_of="$(python - <<'PY'
88+
import json
89+
from pathlib import Path
90+
index = json.loads(Path("data/output/platform_handoffs/index.json").read_text(encoding="utf-8"))
91+
print(index["handoffs"][-1]["as_of"])
92+
PY
93+
)"
94+
echo "as_of=${as_of}" >> "$GITHUB_OUTPUT"
95+
96+
- name: Upload generated artifacts
97+
uses: actions/upload-artifact@v4
98+
with:
99+
name: platform-handoffs-ibit-btc-${{ steps.publish.outputs.as_of }}-${{ github.run_id }}
100+
path: data/output/platform_handoffs
101+
if-no-files-found: error
102+
retention-days: 7
103+
104+
- name: Append job summary
105+
run: |
106+
set -euo pipefail
107+
{
108+
echo "## Platform handoff publish"
109+
echo
110+
echo "- consumer: \`us_equity:ibit_smart_dca\`"
111+
echo "- as_of: \`${{ steps.publish.outputs.as_of }}\`"
112+
echo "- gcs_prefix: \`${GCS_PREFIX}\`"
113+
echo "- execute_publish: \`${EXECUTE_PUBLISH}\`"
114+
echo
115+
echo "Generated files:"
116+
find data/output/platform_handoffs -type f | sort | sed 's/^/- /'
117+
} >> "$GITHUB_STEP_SUMMARY"
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
import csv
5+
import json
6+
import subprocess
7+
import sys
8+
import urllib.error
9+
import urllib.request
10+
from datetime import UTC, date, datetime, timedelta
11+
from pathlib import Path
12+
13+
BINANCE_BTCUSDT_DAILY_URL = (
14+
"https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1d&limit=800"
15+
)
16+
DEFAULT_CONSUMER = "us_equity:ibit_smart_dca"
17+
DEFAULT_STRATEGY = "ibit_smart_dca"
18+
DEFAULT_GCS_PREFIX = "gs://qsl-runtime-logs-shared/platform_handoffs"
19+
20+
21+
def default_as_of(*, today: date | None = None) -> str:
22+
"""Return yesterday UTC as the default signal as-of date."""
23+
current = today or datetime.now(UTC).date()
24+
return (current - timedelta(days=1)).isoformat()
25+
26+
27+
def resolve_as_of(*, csv_path: Path, requested: str | None, today: date | None = None) -> str:
28+
"""Pick the requested as-of when present in the CSV, otherwise the latest row."""
29+
rows = _read_csv_dates(csv_path)
30+
if not rows:
31+
raise ValueError(f"no dates found in {csv_path}")
32+
target = requested or default_as_of(today=today)
33+
if target in rows:
34+
return target
35+
return rows[-1]
36+
37+
38+
def fetch_binance_btc_daily_csv(output_path: Path) -> int:
39+
"""Download BTCUSDT daily OHLCV from Binance public API into a local CSV."""
40+
output_path.parent.mkdir(parents=True, exist_ok=True)
41+
try:
42+
with urllib.request.urlopen(BINANCE_BTCUSDT_DAILY_URL, timeout=30) as response:
43+
payload = json.loads(response.read().decode())
44+
except urllib.error.URLError as exc:
45+
raise RuntimeError(f"failed to download Binance BTCUSDT daily klines: {exc}") from exc
46+
47+
if not payload:
48+
raise RuntimeError("Binance BTCUSDT daily klines response was empty")
49+
50+
rows: list[dict[str, object]] = []
51+
for entry in payload:
52+
timestamp_ms = int(entry[0])
53+
day = datetime.fromtimestamp(timestamp_ms / 1000, tz=UTC).date().isoformat()
54+
rows.append(
55+
{
56+
"date": day,
57+
"open": float(entry[1]),
58+
"high": float(entry[2]),
59+
"low": float(entry[3]),
60+
"close": float(entry[4]),
61+
"volume": float(entry[5]),
62+
}
63+
)
64+
65+
with output_path.open("w", newline="", encoding="utf-8") as handle:
66+
writer = csv.DictWriter(
67+
handle,
68+
fieldnames=["date", "open", "high", "low", "close", "volume"],
69+
)
70+
writer.writeheader()
71+
writer.writerows(rows)
72+
73+
return len(rows)
74+
75+
76+
def build_ibit_btc_platform_handoff(
77+
*,
78+
work_dir: Path,
79+
input_csv: Path,
80+
as_of: str,
81+
code_commit: str,
82+
source_version: str,
83+
consumer: str = DEFAULT_CONSUMER,
84+
strategy: str = DEFAULT_STRATEGY,
85+
) -> dict[str, Path]:
86+
"""Build BTC cycle bundle artifacts and the platform handoff index locally."""
87+
from market_signal_sources.cli.build_btc_cycle_bundle import main as build_bundle_main
88+
from market_signal_sources.cli.publish_platform_signal_handoff import (
89+
main as publish_handoff_main,
90+
)
91+
92+
publication_dir = work_dir / "platform_handoffs" / as_of
93+
bundle_dir = publication_dir / "bundle"
94+
index_path = work_dir / "platform_handoffs" / "index.json"
95+
bundle_dir.mkdir(parents=True, exist_ok=True)
96+
generated_at = f"{as_of}T00:15:00Z"
97+
98+
build_exit = build_bundle_main(
99+
[
100+
"--input-csv",
101+
str(input_csv),
102+
"--output-dir",
103+
str(bundle_dir),
104+
"--as-of",
105+
as_of,
106+
"--provider",
107+
"binance_public",
108+
"--provider-dataset",
109+
"btcusdt_daily_klines",
110+
"--source-version",
111+
source_version,
112+
"--code-commit",
113+
code_commit,
114+
"--generated-at",
115+
generated_at,
116+
]
117+
)
118+
if build_exit != 0:
119+
raise RuntimeError(f"build-btc-cycle-bundle failed with exit code {build_exit}")
120+
121+
publish_exit = publish_handoff_main(
122+
[
123+
"--publication-dir",
124+
str(publication_dir),
125+
"--signal-bundle-manifest",
126+
str(bundle_dir / "manifest.json"),
127+
"--consumer",
128+
consumer,
129+
"--strategy",
130+
strategy,
131+
"--index-path",
132+
str(index_path),
133+
"--lookup-as-of",
134+
as_of,
135+
]
136+
)
137+
if publish_exit != 0:
138+
raise RuntimeError(
139+
f"publish-platform-signal-handoff failed with exit code {publish_exit}"
140+
)
141+
142+
return {
143+
"publication_dir": publication_dir,
144+
"index_path": index_path,
145+
"bundle_manifest": bundle_dir / "manifest.json",
146+
}
147+
148+
149+
def upload_platform_handoffs(*, local_root: Path, gcs_prefix: str) -> None:
150+
"""Sync the local platform handoffs tree to GCS."""
151+
normalized = gcs_prefix.rstrip("/")
152+
command = ["gsutil", "-m", "rsync", "-r", str(local_root), normalized]
153+
completed = subprocess.run(command, check=False, capture_output=True, text=True)
154+
if completed.returncode != 0:
155+
message = completed.stderr.strip() or completed.stdout.strip() or "unknown gsutil error"
156+
raise RuntimeError(f"gsutil rsync failed: {message}")
157+
158+
159+
def _read_csv_dates(csv_path: Path) -> list[str]:
160+
with csv_path.open(newline="", encoding="utf-8") as handle:
161+
reader = csv.DictReader(handle)
162+
if "date" not in (reader.fieldnames or []):
163+
raise ValueError(f"{csv_path} is missing a date column")
164+
return [str(row["date"]) for row in reader if row.get("date")]
165+
166+
167+
def main(argv: list[str] | None = None) -> int:
168+
parser = argparse.ArgumentParser(
169+
description=(
170+
"Fetch BTC daily OHLCV, build the IBIT smart DCA platform handoff, "
171+
"and optionally upload it to GCS."
172+
)
173+
)
174+
parser.add_argument(
175+
"--work-dir",
176+
type=Path,
177+
default=Path("data/output"),
178+
help="Local build root for platform handoff artifacts.",
179+
)
180+
parser.add_argument(
181+
"--as-of",
182+
help="Signal as-of date (YYYY-MM-DD). Defaults to yesterday UTC when present in the CSV.",
183+
)
184+
parser.add_argument(
185+
"--input-csv",
186+
type=Path,
187+
help="Optional pre-fetched BTC OHLCV CSV. When omitted, Binance public data is downloaded.",
188+
)
189+
parser.add_argument(
190+
"--gcs-prefix",
191+
default=DEFAULT_GCS_PREFIX,
192+
help="GCS prefix for platform handoffs.",
193+
)
194+
parser.add_argument(
195+
"--execute",
196+
action="store_true",
197+
help="Upload the generated platform handoffs directory to GCS.",
198+
)
199+
parser.add_argument(
200+
"--source-version",
201+
default="0.1.1",
202+
help="MarketSignalSources package version recorded in bundle provenance.",
203+
)
204+
parser.add_argument(
205+
"--code-commit",
206+
help="Git commit SHA recorded in bundle provenance. Defaults to GITHUB_SHA when set.",
207+
)
208+
parser.add_argument(
209+
"--consumer",
210+
default=DEFAULT_CONSUMER,
211+
help="Runtime consumer contract to publish.",
212+
)
213+
parser.add_argument(
214+
"--strategy",
215+
default=DEFAULT_STRATEGY,
216+
help="Platform strategy profile for runtime adapter config.",
217+
)
218+
args = parser.parse_args(argv)
219+
220+
work_dir = args.work_dir.resolve()
221+
work_dir.mkdir(parents=True, exist_ok=True)
222+
input_csv = args.input_csv or (work_dir / "inputs" / "btc_daily.csv")
223+
code_commit = args.code_commit or __import__("os").environ.get("GITHUB_SHA", "0" * 40)
224+
225+
if args.input_csv is None:
226+
row_count = fetch_binance_btc_daily_csv(input_csv)
227+
print(f"downloaded {row_count} BTCUSDT daily rows to {input_csv}")
228+
elif not input_csv.is_file():
229+
print(f"error: input CSV not found: {input_csv}", file=sys.stderr)
230+
return 2
231+
232+
as_of = resolve_as_of(csv_path=input_csv, requested=args.as_of)
233+
print(f"using as_of={as_of}")
234+
235+
artifacts = build_ibit_btc_platform_handoff(
236+
work_dir=work_dir,
237+
input_csv=input_csv,
238+
as_of=as_of,
239+
code_commit=code_commit,
240+
source_version=args.source_version,
241+
consumer=args.consumer,
242+
strategy=args.strategy,
243+
)
244+
print(f"built platform handoff index at {artifacts['index_path']}")
245+
246+
if args.execute:
247+
upload_root = work_dir / "platform_handoffs"
248+
upload_platform_handoffs(local_root=upload_root, gcs_prefix=args.gcs_prefix)
249+
print(f"uploaded {upload_root} to {args.gcs_prefix.rstrip('/')}")
250+
251+
return 0
252+
253+
254+
if __name__ == "__main__":
255+
raise SystemExit(main())

0 commit comments

Comments
 (0)