- Fund Lab tab: CEF tax-arb shortlist table (char actual-vs-model, discount, 5y return/vol, scenario hits, tenders, score) built from cef_character.json + cef_annual.json. - fundlab/server_watchdog.sh: relaunches Streamlit on :8599 if the health endpoint stops answering (the old watchdog only covered the overnight screen and had exited). - fundlab/cef_universe_run.py: resumable batch extending per-share verification to all 295 CEFs -> cef_annual_all.json.
76 lines
2.6 KiB
Python
76 lines
2.6 KiB
Python
"""Extend per-share verification to the FULL CEF universe (295 tickers).
|
|
|
|
The 50-fund shortlist run (fundlab.cef_annual) wrote fundlab/cef_annual.json.
|
|
This batch runs the same analyze() over every ticker in
|
|
fundlab/cef_universe.json and writes fundlab/cef_annual_all.json —
|
|
incrementally, so a killed run resumes by re-running this script (cached
|
|
funds are skipped, as are those already in the output file).
|
|
|
|
Usage:
|
|
.venv/bin/python -m fundlab.cef_universe_run # all, resumable
|
|
.venv/bin/python -m fundlab.cef_universe_run --retry-err # redo failed ones
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
from fundlab.cef_annual import (CACHE, analyze, character_score,
|
|
current_nav_discount)
|
|
|
|
HERE = Path(__file__).parent
|
|
UNIVERSE = HERE / "cef_universe.json"
|
|
OUT = HERE / "cef_annual_all.json"
|
|
LOG = HERE / "cef_universe_run.log"
|
|
|
|
|
|
def log(msg: str) -> None:
|
|
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
|
|
print(line, flush=True)
|
|
with LOG.open("a") as f:
|
|
f.write(line + "\n")
|
|
|
|
|
|
def run(retry_err: bool = False) -> dict:
|
|
uni = json.loads(UNIVERSE.read_text())
|
|
prev: dict = {}
|
|
if OUT.exists():
|
|
prev = json.loads(OUT.read_text())
|
|
todo = []
|
|
for s in sorted(uni):
|
|
p = prev.get(s)
|
|
if p is None or (retry_err and "error" in p):
|
|
todo.append(s)
|
|
log(f"universe={len(uni)} done={len(uni) - len(todo)} todo={len(todo)}"
|
|
+ (" [retry-err]" if retry_err else ""))
|
|
res = dict(prev)
|
|
for i, s in enumerate(todo, 1):
|
|
u = uni[s]
|
|
cik = int(u.get("cik", 0))
|
|
try:
|
|
a = analyze(s, cik)
|
|
except Exception as e: # noqa: BLE001 - batch must not die
|
|
a = {"sym": s, "error": f"analyze exception: {e}"}
|
|
a["char_actual"] = (round(character_score(a, u.get("name", "")), 2)
|
|
if "share_div" in a else None)
|
|
if "nav_end" in a:
|
|
d = current_nav_discount(a)
|
|
a["disc_now_approx"] = round(d, 4) if d is not None else None
|
|
res[s] = a
|
|
if "share_div" in a:
|
|
log(f"{i:3}/{len(todo)} {s:7} div {a['share_div']:.0%} "
|
|
f"gain {a['share_gains']:.0%} roc {a['share_roc']:.0%} "
|
|
f"char={a['char_actual']}")
|
|
else:
|
|
log(f"{i:3}/{len(todo)} {s:7} -- {a.get('error', 'no data')}")
|
|
OUT.write_text(json.dumps(res, indent=1, default=str))
|
|
log(f"done: parsed={sum(1 for v in res.values() if 'share_div' in v)} "
|
|
f"of {len(res)}")
|
|
return res
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run(retry_err="--retry-err" in sys.argv)
|