Ctrl K
ComunidadGuías…

tools/headline_numbers.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python
"""Print the app's headline numbers, using the app's own query code.

Run it against the same database before and after a change and diff the two —
that is the check any change to `queries.py`, `fyreport.py`, `charts_build.py`
or `exports.py` has to pass.

    uv run python tools/headline_numbers.py > before.json
    # ...make the change...
    uv run python tools/headline_numbers.py > after.json
    diff <(jq -S . before.json) <(jq -S . after.json)

Two things make it trickier than it looks:

  * **Compare with the price feed OFF** (`APP_PRICE_FEED__ENABLED=false`).
    Otherwise the second run fetches the day's closes, every price moves, and
    it reads exactly like a calculation change. That produced 95 false
    differences the first time.
  * The dashboard's path is `cached_holdings` -> `split_positions` -> `totals`.
    Any other route measures something the page does not show.

The output is whatever the dataclasses hold — fields are discovered, never
named — so a renamed field shows up as a rename rather than as a number that
changed.

The numbers are real holdings, so send the output somewhere untracked and
delete it afterwards; it is never something to commit.
"""
import json
import sys
from dataclasses import fields, is_dataclass
from pathlib import Path

# `python tools/headline_numbers.py` puts tools/ on the path, not the root that
# `app` and `appcore` sit in, so importing them needs this. Run it from anywhere
# the repository is checked out; to run it against a container instead, mount
# or copy the file into the application root rather than into /tmp.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))

from sqlalchemy import select  # noqa: E402

from app import clock, queries, tenancy  # noqa: E402
from app.models import Portfolio  # noqa: E402
from app.settings import PortfolioSettings  # noqa: E402
from appcore import load_config, make_session_factory  # noqa: E402


def plain(value):
    """Anything comparable, as a string — Decimals and dates included."""
    if value is None or isinstance(value, (bool, int, str)):
        return value
    if isinstance(value, (list, tuple, set)):
        return sorted(plain(v) for v in value)
    return str(value)


def dump(obj):
    if is_dataclass(obj):
        return {f.name: plain(getattr(obj, f.name, None)) for f in fields(obj)}
    return plain(obj)


settings = load_config(PortfolioSettings)
clock.configure(settings.timezone)
factory = make_session_factory(settings.database)
tenancy.install(factory)

out = {}
with factory() as s:
    for pf in s.scalars(select(Portfolio).order_by(Portfolio.id)).all():
        tenancy.bind(s, pf.id, None)
        # Exactly the dashboard route's path; any other would measure
        # something the page does not show.
        holdings = queries.cached_holdings(s)
        open_positions, closed = queries.split_positions(holdings)
        entry = {
            "open": len(open_positions),
            "closed": len(closed),
            "totals": dump(queries.totals(open_positions)),
            "per_holding": {},
        }
        for h in open_positions:
            entry["per_holding"][h.instrument.ticker] = dump(h)
            entry["per_holding"][h.instrument.ticker].pop("instrument", None)
            entry["per_holding"][h.instrument.ticker].pop("trades", None)
        out[pf.name] = entry
print(json.dumps(out, indent=2, sort_keys=True, default=str))