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
1 change: 1 addition & 0 deletions .github/workflows/manual-strategy-switch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ jobs:
switch:
name: Build and apply runtime switch
runs-on: ubuntu-latest
timeout-minutes: 15
environment: runtime-strategy-switch
permissions:
contents: read
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,13 @@ on:
push:
pull_request:

permissions:
contents: read

jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
Expand All @@ -30,6 +34,8 @@ jobs:
run: python3 scripts/runtime_settings.py validate
- name: Run unit tests
run: python3 -m unittest discover -s tests -v
- name: Report internal dependency matrix
run: python3 scripts/check_internal_dependency_matrix.py --projects-root .. --json
- name: Validate strategy switch web assets
run: |
set -euo pipefail
Expand Down
1 change: 1 addition & 0 deletions .node-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
152 changes: 152 additions & 0 deletions internal_dependency_matrix.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
{
"schema_version": 1,
"dependencies": [
{
"consumer_repo": "BinancePlatform",
"path": "requirements.txt",
"package": "quant-platform-kit",
"source_repo": "QuantPlatformKit",
"ref": "v0.7.35"
},
{
"consumer_repo": "BinancePlatform",
"path": "requirements.txt",
"package": "crypto-strategies",
"source_repo": "CryptoStrategies",
"ref": "v0.4.8"
},
{
"consumer_repo": "BinancePlatform",
"path": "requirements-lock.txt",
"package": "quant-platform-kit",
"source_repo": "QuantPlatformKit",
"ref": "v0.7.35"
},
{
"consumer_repo": "BinancePlatform",
"path": "requirements-lock.txt",
"package": "crypto-strategies",
"source_repo": "CryptoStrategies",
"ref": "v0.4.8"
},
{
"consumer_repo": "CharlesSchwabPlatform",
"path": "requirements.txt",
"package": "quant-platform-kit",
"source_repo": "QuantPlatformKit",
"ref": "3b6a0a9bedde72773e188041e0dc48516b38aadc"
},
{
"consumer_repo": "CharlesSchwabPlatform",
"path": "requirements.txt",
"package": "us-equity-strategies",
"source_repo": "UsEquityStrategies",
"ref": "8278048366f1cd83e29e0c921e4048e7e25ae227"
},
{
"consumer_repo": "CryptoStrategies",
"path": "pyproject.toml",
"package": "quant-platform-kit",
"source_repo": "QuantPlatformKit",
"ref": "v0.7.35"
},
{
"consumer_repo": "FirstradePlatform",
"path": "pyproject.toml",
"package": "quant-platform-kit",
"source_repo": "QuantPlatformKit",
"ref": "3b6a0a9bedde72773e188041e0dc48516b38aadc"
},
{
"consumer_repo": "FirstradePlatform",
"path": "pyproject.toml",
"package": "us-equity-strategies",
"source_repo": "UsEquityStrategies",
"ref": "8278048366f1cd83e29e0c921e4048e7e25ae227"
},
{
"consumer_repo": "FirstradePlatform",
"path": "requirements.txt",
"package": "quant-platform-kit",
"source_repo": "QuantPlatformKit",
"ref": "3b6a0a9bedde72773e188041e0dc48516b38aadc"
},
{
"consumer_repo": "FirstradePlatform",
"path": "requirements.txt",
"package": "us-equity-strategies",
"source_repo": "UsEquityStrategies",
"ref": "8278048366f1cd83e29e0c921e4048e7e25ae227"
},
{
"consumer_repo": "HkEquityStrategies",
"path": "pyproject.toml",
"package": "quant-platform-kit",
"source_repo": "QuantPlatformKit",
"ref": "023641c88506c732624a7329e48b51b9dbbe3c2a"
},
{
"consumer_repo": "InteractiveBrokersPlatform",
"path": "requirements.txt",
"package": "quant-platform-kit",
"source_repo": "QuantPlatformKit",
"ref": "3b6a0a9bedde72773e188041e0dc48516b38aadc"
},
{
"consumer_repo": "InteractiveBrokersPlatform",
"path": "requirements.txt",
"package": "us-equity-strategies",
"source_repo": "UsEquityStrategies",
"ref": "8278048366f1cd83e29e0c921e4048e7e25ae227"
},
{
"consumer_repo": "InteractiveBrokersPlatform",
"path": "requirements.txt",
"package": "hk-equity-strategies",
"source_repo": "HkEquityStrategies",
"ref": "b690fcfd1e26648840723a5ab8b12c873f038b9b"
},
{
"consumer_repo": "LongBridgePlatform",
"path": "requirements.txt",
"package": "quant-platform-kit",
"source_repo": "QuantPlatformKit",
"ref": "023641c88506c732624a7329e48b51b9dbbe3c2a"
},
{
"consumer_repo": "LongBridgePlatform",
"path": "requirements.txt",
"package": "us-equity-strategies",
"source_repo": "UsEquityStrategies",
"ref": "7d35772d1125b534d0bcca557cb6dbaf28914719"
},
{
"consumer_repo": "LongBridgePlatform",
"path": "requirements.txt",
"package": "hk-equity-strategies",
"source_repo": "HkEquityStrategies",
"ref": "2e0075004239e7ede7ba256763a3441d4ec4ca73"
},
{
"consumer_repo": "UsEquitySnapshotPipelines",
"path": "pyproject.toml",
"package": "quant-strategy-plugins",
"source_repo": "QuantStrategyPlugins",
"ref": "v0.1.4"
},
{
"consumer_repo": "UsEquitySnapshotPipelines",
"path": "pyproject.toml",
"package": "us-equity-strategies",
"source_repo": "UsEquityStrategies",
"ref": "7d35772d1125b534d0bcca557cb6dbaf28914719"
},
{
"consumer_repo": "UsEquityStrategies",
"path": "pyproject.toml",
"package": "quant-platform-kit",
"source_repo": "QuantPlatformKit",
"ref": "023641c88506c732624a7329e48b51b9dbbe3c2a"
}
]
}
167 changes: 167 additions & 0 deletions scripts/check_internal_dependency_matrix.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
#!/usr/bin/env python3
"""Report QuantStrategyLab internal git dependency pin drift."""

from __future__ import annotations

import argparse
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
DEFAULT_MATRIX_PATH = ROOT / "internal_dependency_matrix.json"
DEFAULT_PROJECTS_ROOT = ROOT.parent
DEPENDENCY_PATTERN = re.compile(
r"(?P<package>[A-Za-z0-9_.-]+)\s*@\s*"
r"git\+https://github\.com/QuantStrategyLab/"
r"(?P<source_repo>[A-Za-z0-9_.-]+)\.git@(?P<ref>[A-Za-z0-9_.-]+)"
)


@dataclass(frozen=True)
class DependencyPin:
consumer_repo: str
path: str
package: str
source_repo: str
ref: str

@property
def key(self) -> tuple[str, str, str, str]:
return (self.consumer_repo, self.path, self.package, self.source_repo)

def label(self) -> str:
return f"{self.consumer_repo}/{self.path}:{self.package}->{self.source_repo}"


@dataclass(frozen=True)
class MatrixReport:
checked_files: int
missing_files: list[str]
issues: list[str]

@property
def ok(self) -> bool:
return not self.issues


def load_matrix(path: Path) -> list[DependencyPin]:
payload = json.loads(path.read_text(encoding="utf-8"))
if payload.get("schema_version") != 1:
raise ValueError("internal dependency matrix schema_version must be 1")
dependencies = payload.get("dependencies")
if not isinstance(dependencies, list):
raise ValueError("internal dependency matrix dependencies must be a list")
pins: list[DependencyPin] = []
for index, item in enumerate(dependencies):
if not isinstance(item, dict):
raise ValueError(f"dependencies[{index}] must be an object")
pins.append(
DependencyPin(
consumer_repo=_required_string(item, "consumer_repo", index),
path=_required_string(item, "path", index),
package=_required_string(item, "package", index),
source_repo=_required_string(item, "source_repo", index),
ref=_required_string(item, "ref", index),
)
)
return pins


def _required_string(item: dict[str, Any], key: str, index: int) -> str:
value = item.get(key)
if not isinstance(value, str) or not value.strip():
raise ValueError(f"dependencies[{index}].{key} must be a non-empty string")
return value.strip()


def parse_dependency_pins(consumer_repo: str, path: str, text: str) -> list[DependencyPin]:
return [
DependencyPin(
consumer_repo=consumer_repo,
path=path,
package=match.group("package"),
source_repo=match.group("source_repo"),
ref=match.group("ref"),
)
for match in DEPENDENCY_PATTERN.finditer(text)
]


def check_matrix(*, matrix_pins: list[DependencyPin], projects_root: Path) -> MatrixReport:
expected_by_file: dict[tuple[str, str], list[DependencyPin]] = {}
for pin in matrix_pins:
expected_by_file.setdefault((pin.consumer_repo, pin.path), []).append(pin)

issues: list[str] = []
missing_files: list[str] = []
checked_files = 0
for (consumer_repo, relative_path), expected_pins in sorted(expected_by_file.items()):
path = projects_root / consumer_repo / relative_path
if not path.exists():
missing_files.append(f"{consumer_repo}/{relative_path}")
continue
checked_files += 1
actual_pins = parse_dependency_pins(consumer_repo, relative_path, path.read_text(encoding="utf-8"))
actual_by_key = {pin.key: pin for pin in actual_pins}
expected_by_key = {pin.key: pin for pin in expected_pins}

for key, expected in sorted(expected_by_key.items()):
actual = actual_by_key.get(key)
if actual is None:
issues.append(f"missing {expected.label()} expected @{expected.ref}")
elif actual.ref != expected.ref:
issues.append(f"ref mismatch {expected.label()}: expected @{expected.ref}, found @{actual.ref}")

for key, actual in sorted(actual_by_key.items()):
if key not in expected_by_key:
issues.append(f"untracked internal dependency {actual.label()} @{actual.ref}")

return MatrixReport(checked_files=checked_files, missing_files=missing_files, issues=issues)


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Report QuantStrategyLab internal dependency pin drift.")
parser.add_argument("--matrix", type=Path, default=DEFAULT_MATRIX_PATH)
parser.add_argument("--projects-root", type=Path, default=DEFAULT_PROJECTS_ROOT)
parser.add_argument("--json", action="store_true", help="Print machine-readable report.")
parser.add_argument("--strict", action="store_true", help="Exit non-zero when drift is detected.")
return parser


def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
report = check_matrix(matrix_pins=load_matrix(args.matrix), projects_root=args.projects_root)
if args.json:
print(
json.dumps(
{
"checked_files": report.checked_files,
"missing_files": report.missing_files,
"issues": report.issues,
"ok": report.ok,
},
ensure_ascii=False,
indent=2,
)
)
else:
print(f"checked_files={report.checked_files}")
if report.missing_files:
print("missing_files:")
for item in report.missing_files:
print(f"- {item}")
if report.issues:
print("issues:")
for issue in report.issues:
print(f"- {issue}")
if report.ok:
print("internal dependency matrix is current")
return 1 if args.strict and not report.ok else 0


if __name__ == "__main__":
raise SystemExit(main())
26 changes: 26 additions & 0 deletions tests/strategy_switch_worker_validation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,32 @@ assert.equal(
await __test.withTimeout(new Promise(() => {}), 1, "fallback"),
"fallback",
);
const timeoutFetchResponse = await __test.fetchWithTimeout(
"https://api.github.test/user",
{ headers: { Accept: "application/json" } },
100,
async (_resource, init) => {
assert.ok(init.signal instanceof AbortSignal);
assert.equal(init.headers.Accept, "application/json");
return new Response('{"ok":true}', { status: 200 });
},
);
assert.equal(timeoutFetchResponse.status, 200);
await assert.rejects(
() => __test.fetchWithTimeout(
"https://api.github.test/slow",
{},
1,
(_resource, init) => new Promise((_resolve, reject) => {
init.signal.addEventListener("abort", () => {
const error = new Error("aborted");
error.name = "AbortError";
reject(error);
});
}),
),
/GitHub request timed out/,
);

function captureError(fn) {
try {
Expand Down
Loading