Skill: boilerrat/modular-market-brief (version 0.1.0)

--- name: modular-market-brief description: Generate modular, data-backed market reports (AM/PM) across global assets. Use for daily market briefs, premarket/aftermarket summaries, cross-asset dashboards, sector/asset trend tables, top movers (gainers/losers) blocks, and a single best-idea wrap-up. Designed to be region-agnostic and configurable (tickers/regions/assets). --- # Modular Market Brief Create a concise but information-dense market report that is **modular** (can include/exclude sections) and **data-backed** (prices/returns/trend state when possible). ## Inputs to ask for (or assume defaults) - **Time window:** AM (since prior close) vs PM (what changed since AM) - **Regions:** e.g., US, Canada, EU, Asia (user chooses) - **Asset blocks:** equities, rates, FX, commodities, crypto - **Core tickers:** indices + user’s preferred ETFs/tickers - **Movers source:** which exchange/market and where to get movers - **Risk appetite:** conservative vs aggressive framing If the user doesn’t specify, default to a broad global dashboard with US indices, USD, oil, gold, BTC/ETH. ## Report structure (recommended) 1) **TL;DR** (3–6 bullets) 2) **Equities** (by region) 3) **Rates** (2Y/10Y + key central bank watch) 4) **FX** (DXY or major pairs; local pair for user) 5) **Commodities** (WTI/Brent, gold, copper; add relevant) 6) **Crypto** (BTC/ETH + anything user cares about) 7) **Top movers** (top gainers/losers for a chosen exchange) 8) **Patterns / trend box** (BUY/SELL/WAIT labels for selected instruments) 9) **One best idea** (cross-asset; include invalidation) ## Data guidance Prefer programmatic price tape when available: - Use **yfinance** for tickers/ETFs/crypto/commodity futures (optional dependency). - If a market needs a dedicated movers list, use a web source (exchange site / finance portal) and then enrich tickers via yfinance. ### Installing yfinance (recommended, but not required) If yfinance isn’t available, the skill can still produce a narrative brief from public sources. For reliable installs on modern Linux distros (PEP 668), prefer a venv: ```bash python3 -m venv ~/.venvs/market-brief ~/.venvs/market-brief/bin/pip install -U pip ~/.venvs/market-brief/bin/pip install yfinance pandas numpy ``` Then run scripts using `~/.venvs/market-brief/bin/python`. ### Trend labeling (simple + explainable) Use MA/RSI-based state labels: - **BUY:** close > MA20 > MA50 and RSI(14) >= 50 - **SELL:** close < MA20 < MA50 and RSI(14) <= 50 - **WAIT:** everything else Always present it as a **pattern** (not a guarantee) and include a one-line rationale. ## Bundled scripts (optional helpers) - `scripts/price_tape.py`: pull prices + returns + MA/RSI for a ticker list (yfinance) - `scripts/movers_yahoo.py`: free Yahoo Finance screeners for top gainers/losers/actives (best-effort) - `scripts/tmx_movers.py`: example movers scraper (TMX Money) you can adapt or swap - `scripts/render_example.md`: a template you can reuse Only run scripts if you actually need structured output; otherwise write the report directly. ## Safety / finance guardrails - Don’t place trades. - Avoid certainty language. Use “pattern / bias / invalidation.” - If the user asks for explicit buy/sell instructions, provide a **conceptual** plan + risks. - Remind about tax/fees only when relevant.

path: scripts/movers_yahoo.py
content: #!/usr/bin/env python3
"""movers_yahoo.py

Fetch top movers (gainers/losers/actives) from Yahoo Finance's public screener endpoints.

This is a *best-effort* free data source. Yahoo may rate-limit or change endpoints.

Examples:
  python movers_yahoo.py --type gainers --count 10
  python movers_yahoo.py --type losers --count 10
  python movers_yahoo.py --type actives --count 10

Output: JSON list of tickers with price + % change + volume + market cap when available.

Note:
- These are typically US-centric lists. For other exchanges, swap movers provider.
- Pair with price_tape.py (yfinance) to enrich history/RSI/MA.
"""

import argparse
import json
import sys
from typing import Any, Dict, List

import requests

SCR_IDS = {
    "gainers": "day_gainers",
    "losers": "day_losers",
    "actives": "most_actives",
}


def fetch(scr_id: str, count: int) -> List[Dict[str, Any]]:
    url = "https://query1.finance.yahoo.com/v1/finance/screener/predefined/saved"
    params = {"scrIds": scr_id, "count": count}
    r = requests.get(url, params=params, timeout=15, headers={"User-Agent": "Mozilla/5.0"})
    r.raise_for_status()
    j = r.json()
    quotes = (
        j.get("finance", {})
        .get("result", [{}])[0]
        .get("quotes", [])
    )

    out: List[Dict[str, Any]] = []
    for q in quotes:
        out.append(
            {
                "symbol": q.get("symbol"),
                "shortName": q.get("shortName") or q.get("longName"),
                "regularMarketPrice": q.get("regularMarketPrice"),
                "regularMarketChangePercent": q.get("regularMarketChangePercent"),
                "regularMarketVolume": q.get("regularMarketVolume"),
                "marketCap": q.get("marketCap"),
                "currency": q.get("currency"),
                "exchange": q.get("fullExchangeName") or q.get("exchange"),
            }
        )
    return out


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--type", choices=sorted(SCR_IDS.keys()), required=True)
    ap.add_argument("--count", type=int, default=10)
    args = ap.parse_args()

    scr_id = SCR_IDS[args.type]
    try:
        rows = fetch(scr_id, args.count)
    except Exception as e:
        print(json.dumps({"error": str(e), "hint": "Yahoo endpoint may be rate-limited/changed. Try later or use another movers provider."}))
        sys.exit(1)

    print(json.dumps({"type": args.type, "source": "yahoo", "items": rows}, indent=2))


if __name__ == "__main__":
    main()


path: scripts/render_example.md
content: # Example: AM Market Brief (modular)

## TL;DR
- …

## Equities
- US: ^GSPC, ^IXIC
- EU: …

## Rates
- US 2Y/10Y: …

## FX
- DXY / USDJPY / USDCAD: …

## Commodities
- CL=F, GC=F, HG=F: …

## Crypto
- BTC-USD, ETH-USD: …

## Top movers (Exchange)
- Gainers: …
- Losers: …

## Patterns
(Include a price tape table; MA/RSI pattern labels)

## One best idea
- Bias: …
- Expression: …
- Invalidation: …


path: scripts/tmx_movers.py
content: #!/usr/bin/env python3
"""tmx_movers.py

Example: extract a movers table from TMX Money 'Canadian markets' page.

This is provided as a *pattern* for building a movers provider. For a generic
skill, you can swap this out for any exchange/market source.

Output: JSON list of rows with symbol + change %.

Note: HTML structure can change; keep parsing defensive.
"""

import json
import re
import sys
from dataclasses import dataclass, asdict
from typing import List

import requests
from bs4 import BeautifulSoup


@dataclass
class Mover:
    symbol: str
    company: str
    price: str
    chg_pct: float
    chg_abs: str
    volume: str


def parse_float(s: str) -> float:
    s = s.strip().replace(",", "")
    m = re.search(r"-?\d+(?:\.\d+)?", s)
    return float(m.group(0)) if m else 0.0


def main():
    url = "https://money.tmx.com/en/canadian-markets"
    html = requests.get(url, timeout=15).text
    soup = BeautifulSoup(html, "html.parser")

    # This page is heavily client-rendered; the easiest reliable approach is often
    # a browser automation snapshot. This script is a fallback for environments
    # where the server-rendered table exists.
    table = soup.find("table")
    if not table:
        print(json.dumps({"error": "no_table_found", "hint": "Use browser snapshot/eval to read the table."}))
        return

    rows = table.find_all("tr")
    movers: List[Mover] = []

    for tr in rows[1:]:
        tds = tr.find_all(["td", "th"])
        if len(tds) < 5:
            continue
        symbol = tds[0].get_text(strip=True)
        company = tds[1].get_text(strip=True)
        price = tds[2].get_text(strip=True)
        chg_abs = tds[3].get_text(strip=True)
        chg_pct = parse_float(tds[4].get_text(" ", strip=True))
        volume = tds[8].get_text(strip=True) if len(tds) > 8 else ""
        if symbol:
            movers.append(Mover(symbol, company, price, chg_pct, chg_abs, volume))

    print(json.dumps([asdict(m) for m in movers], indent=2))


if __name__ == "__main__":
    main()


path: scripts/price_tape.py
content: #!/usr/bin/env python3
"""price_tape.py

Minimal yfinance-based price tape for a list of tickers.
Outputs JSON or a markdown-friendly table.

Usage:
  python price_tape.py --tickers "^GSPC ^IXIC BTC-USD ETH-USD CL=F GC=F" --period 6mo

Notes:
- yfinance coverage varies by market.
- For Canadian tickers, you may need suffixes like .TO.
"""

import argparse
import json
from dataclasses import asdict, dataclass
from typing import List, Optional

import pandas as pd
import yfinance as yf


def rsi(series: pd.Series, period: int = 14) -> pd.Series:
    delta = series.diff()
    up = delta.clip(lower=0)
    down = -delta.clip(upper=0)
    roll_up = up.ewm(alpha=1 / period, adjust=False).mean()
    roll_down = down.ewm(alpha=1 / period, adjust=False).mean()
    rs = roll_up / roll_down
    return 100 - (100 / (1 + rs))


@dataclass
class TapeRow:
    ticker: str
    last: Optional[float]
    d1_pct: Optional[float]
    w1_pct: Optional[float]
    m1_pct: Optional[float]
    rsi14: Optional[float]
    ma20: Optional[float]
    ma50: Optional[float]
    pattern: str


def compute_row(ticker: str, period: str) -> TapeRow:
    df = yf.download(ticker, period=period, interval="1d", progress=False)
    if df is None or len(df) < 60:
        return TapeRow(ticker, None, None, None, None, None, None, None, "NA")

    # Flatten columns if MultiIndex
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = [c[0] for c in df.columns]

    close = df["Close"].astype(float)
    ma20 = close.rolling(20).mean()
    ma50 = close.rolling(50).mean()
    r = rsi(close, 14)

    last = float(close.iloc[-1])
    d1 = float(close.pct_change().iloc[-1] * 100)
    w1 = float(close.pct_change(5).iloc[-1] * 100)
    m1 = float(close.pct_change(21).iloc[-1] * 100)

    ma20_last = float(ma20.iloc[-1]) if pd.notna(ma20.iloc[-1]) else None
    ma50_last = float(ma50.iloc[-1]) if pd.notna(ma50.iloc[-1]) else None
    r_last = float(r.iloc[-1]) if pd.notna(r.iloc[-1]) else None

    pattern = "WAIT"
    if ma20_last is not None and ma50_last is not None and r_last is not None:
        if last > ma20_last > ma50_last and r_last >= 50:
            pattern = "BUY"
        elif last < ma20_last < ma50_last and r_last <= 50:
            pattern = "SELL"

    return TapeRow(ticker, last, d1, w1, m1, r_last, ma20_last, ma50_last, pattern)


def to_markdown(rows: List[TapeRow]) -> str:
    headers = ["Ticker", "Pattern", "Last", "1D%", "1W%", "1M%", "RSI", "MA20", "MA50"]
    lines = ["| " + " | ".join(headers) + " |", "|" + "---|" * len(headers)]

    def fmt(x, nd=2):
        if x is None:
            return "—"
        return f"{x:.{nd}f}"

    for r in rows:
        lines.append(
            "| "
            + " | ".join(
                [
                    r.ticker,
                    r.pattern,
                    fmt(r.last),
                    fmt(r.d1_pct),
                    fmt(r.w1_pct),
                    fmt(r.m1_pct),
                    fmt(r.rsi14, 1),
                    fmt(r.ma20),
                    fmt(r.ma50),
                ]
            )
            + " |"
        )
    return "\n".join(lines)


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--tickers", required=True, help="Space-separated tickers")
    ap.add_argument("--period", default="6mo")
    ap.add_argument("--format", choices=["json", "md"], default="json")
    args = ap.parse_args()

    tickers = [t.strip() for t in args.tickers.split() if t.strip()]
    rows = [compute_row(t, args.period) for t in tickers]

    if args.format == "md":
        print(to_markdown(rows))
    else:
        print(json.dumps([asdict(r) for r in rows], indent=2))


if __name__ == "__main__":
    main()


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)