File size: 8,432 Bytes
d6c7413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113b714
 
 
 
 
 
d6c7413
 
 
 
 
33ea2ed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d6c7413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33ea2ed
d6c7413
33ea2ed
 
 
 
 
d6c7413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33ea2ed
d6c7413
 
33ea2ed
d6c7413
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
"""Keep the files that exist in BOTH projects byte-identical.

    python huggingface_space/sync_shared.py            # report drift, change nothing
    python huggingface_space/sync_shared.py --push     # repo root  ->  huggingface_space
    python huggingface_space/sync_shared.py --pull     # huggingface_space -> repo root

Run it before every upload. It exits non-zero when the folder is not deployable, so it
works as a pre-upload gate rather than something you have to remember to read.

The Space and the live lab are deliberately separate deployments: the Space installs from
its own minimal requirements.txt and must not import the lab, and nothing done to make a
demo look good may reach back and perturb a frozen forward test. So the overlap is held as
COPIES, not imports or symlinks.

Copies drift. That is the entire cost of this arrangement, and the only defence is a tool
that notices. Run this before uploading to Hugging Face.

Direction is never inferred. `--push` and `--pull` are explicit because guessing which
side is authoritative is how one gets silently overwritten.
"""
from __future__ import annotations

import argparse
import filecmp
import hashlib
import shutil
import sys
from pathlib import Path

SPACE = Path(__file__).resolve().parent
ROOT = SPACE.parent

# (path relative to repo root, path relative to huggingface_space, git-tracked in Space?)
#
# trained_models is GIT_IGNORED on the Space side: the same 19 MB of weights is already
# tracked at the repo root, and git keeps blobs forever, so committing them twice would
# double them in every clone permanently. The Space copy is a build artifact this script
# regenerates -- which only works if something refuses to call the folder deployable when
# it is missing. That is what UPLOAD_REQUIRED is for.
SHARED = [
    ("local_data", "local_data", True),
    ("trained_models", "trained_models", False),
    ("trade_analysis/live_lab/indicators.py", "trade_analysis/lab_indicators.py", True),
    # The US equity calendar. The Space needs it to say WHICH session a signal is from --
    # without it a Labor Day visitor sees Friday's tape presented as today's. Held as a
    # copy for the same reason as the rest: the Space must build with nothing from the
    # parent repo. It is the file most likely to drift, because holidays get appended to
    # the root copy each year and nothing would otherwise notice the Space's copy aging.
    ("trade_analysis/bulk_download/trading_days.py", "trade_analysis/trading_days.py", True),
]

# Present on disk before uploading, whether or not this repo versions them.
UPLOAD_REQUIRED = ["trained_models", "local_data", "trade_analysis/lab_indicators.py"]

# Files the ROOT keeps but the Space deliberately does NOT ship, so their absence is a
# decision rather than drift. All five fail `torch.load(weights_only=True)` -- they are full
# pickles, not plain tensors -- so tft_model.py could never load them anyway, and they are
# exactly the files Hugging Face flags without a "Safe" badge. Shipping 8 MB of
# unloadable pickles as the only security warnings on a public repo is strictly worse than
# not shipping them.
#
# The root keeps them because trade_analysis/models/tft_backtest.py reads
# tft_AMZN_e200_.pth directly. Do not "fix" this by deleting them there.
SPACE_EXCLUDES = {
    "trained_models": {
        "tft_AMZN_e200_.pth", "tft_MSFT_e200_.pth", "tft_SPY_e200_.pth",
        "tft_TSLA_e200_.pth", "tft_model.pth",
    }
}

# lab_indicators.py carries a provenance banner the lab's own copy must not have, so it is
# compared on the CODE below the banner rather than byte-for-byte.
BANNER_EXEMPT = {"trade_analysis/lab_indicators.py"}


def _digest(p: Path) -> str:
    return hashlib.sha256(p.read_bytes()).hexdigest()[:12]


def _body(p: Path) -> str:
    """Everything after the module docstring, so a differing banner does not read as drift."""
    txt = p.read_text(encoding="utf-8")
    parts = txt.split('"""')
    return '"""'.join(parts[2:]) if len(parts) >= 3 else txt


def compare(root_rel: str, space_rel: str) -> tuple[str, list[str]]:
    a, b = ROOT / root_rel, SPACE / space_rel
    if not a.exists():
        return "MISSING_ROOT", []
    if not b.exists():
        return "MISSING_SPACE", []

    if a.is_dir():
        excl = SPACE_EXCLUDES.get(space_rel, set())
        cmp = filecmp.dircmp(str(a), str(b))
        # left_only minus the deliberate exclusions: a file the root has and the Space
        # intentionally omits is not drift.
        diffs = (list(cmp.diff_files)
                 + [f for f in cmp.left_only if f not in excl]
                 + list(cmp.right_only))
        # dircmp is shallow by default: same size + mtime counts as equal. Re-check the
        # ones it passed by content, because a same-size edit is exactly the drift that
        # matters and exactly the one it would miss.
        for name in cmp.common_files:
            if name in diffs:
                continue
            if _digest(a / name) != _digest(b / name):
                diffs.append(name)
        return ("OK" if not diffs else "DRIFT"), sorted(diffs)

    if space_rel in BANNER_EXEMPT:
        return ("OK" if _body(a) == _body(b) else "DRIFT"), []
    return ("OK" if _digest(a) == _digest(b) else "DRIFT"), []


def copy(root_rel: str, space_rel: str, push: bool) -> None:
    a, b = ROOT / root_rel, SPACE / space_rel
    src, dst = (a, b) if push else (b, a)
    if space_rel in BANNER_EXEMPT:
        print(f"    SKIP {space_rel} (banner differs by design; re-copy by hand)")
        return
    if src.is_dir():
        excl = SPACE_EXCLUDES.get(space_rel, set()) if push else set()
        dst.mkdir(parents=True, exist_ok=True)
        for f in src.iterdir():
            if f.is_file() and f.name not in excl:
                shutil.copy2(f, dst / f.name)
    else:
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)
    print(f"    copied {src.relative_to(ROOT)} -> {dst.relative_to(ROOT)}")


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    g = ap.add_mutually_exclusive_group()
    g.add_argument("--push", action="store_true", help="repo root -> huggingface_space")
    g.add_argument("--pull", action="store_true", help="huggingface_space -> repo root")
    args = ap.parse_args()

    mode = "PUSH" if args.push else "PULL" if args.pull else "CHECK"
    print("=" * 82)
    print(f"SHARED-FILE SYNC  [{mode}]   root={ROOT}")
    print("=" * 82)

    drift = 0
    for root_rel, space_rel, tracked in SHARED:
        status, files = compare(root_rel, space_rel)
        label = root_rel if root_rel == space_rel else f"{root_rel}  <->  {space_rel}"
        if not tracked:
            label += "   [git-ignored here; regenerated, not committed]"
        print(f"  [{status:<13}] {label}")
        for f in files[:10]:
            print(f"        - {f}")
        if len(files) > 10:
            print(f"        ... and {len(files) - 10} more")
        if status != "OK":
            drift += 1
            if args.push or args.pull:
                copy(root_rel, space_rel, args.push)

    # Deployability is a separate question from drift. A fresh clone has no
    # trained_models/ on the Space side at all -- correctly, it is git-ignored -- and
    # that is not "drift", it is "not built yet". Uploading in that state would ship a
    # Space whose TFT silently degrades to _default_prediction() for every symbol, which
    # is precisely the class of quiet fallback this project already got burned by once.
    print("\n  UPLOAD READINESS")
    missing = [p for p in UPLOAD_REQUIRED if not (SPACE / p).exists()]
    for p in UPLOAD_REQUIRED:
        here = SPACE / p
        n = len(list(here.iterdir())) if here.is_dir() else (1 if here.exists() else 0)
        print(f"    [{'OK     ' if here.exists() else 'MISSING'}] {p}"
              + (f"  ({n} files)" if here.is_dir() else ""))
    if missing:
        print("    -> NOT deployable. Run:  python huggingface_space/sync_shared.py --push")

    if mode == "CHECK":
        print(f"\n  {drift} of {len(SHARED)} shared paths differ."
              + ("  Run with --push or --pull." if drift else "  No drift."))
    else:
        print(f"\n  {mode} complete; re-run without a flag to confirm.")
    return 1 if ((mode == "CHECK" and drift) or missing) else 0


if __name__ == "__main__":
    sys.exit(main())