01 / BOTS

Weather data for bots, including prediction-market research

A bot is a small program that runs by itself. These ones read live weather and county storm-risk grades for the exact places you list. Some post to Discord, Telegram or Slack. One writes a signals file for your own prediction-market model, with Kalshi-style temperature contracts as the worked example. One pays for its own calls in USDC.

All of it is Python you can read in a few minutes. One environment variable, GETFIXN_KEY. Pay as you go from a prepaid balance.

kalshi_weather_signals.py
$ python kalshi_weather_signals.py
New York       temperature_f above 90: now 84.2, margin -5.8, run-so-far 86.0, grade B
Chicago        temperature_f below 32: now 38.5, margin -6.5, run-so-far 36.1, grade A
wrote 2 rows (about $0.1004)

Example output with made-up numbers. It reads data and writes a file. It places no orders.

02 / PREDICTION MARKETS

Bots for prediction markets (Kalshi and similar)

Prediction markets list contracts on plain questions: will the daily high in New York be above 90 F, will it rain in Miami, will Chicago get more than an inch of snow. You buy yes or no, and the contract settles on whatever an official source reports for that day.

People who trade these usually keep their own model, often a spreadsheet or a short script, that turns weather information into their own estimate. That model needs a feed. This is the feed: for each place you pick, the current conditions and a county storm-risk grade, written to a file your model reads.

We do not tell you what to buy, we do not talk to any exchange, and the script never places an order. It reads from our API and writes signals.csv and signals.json. What the numbers mean for a contract, and whether to trade it, is yours to work out.

How a trader would use it

Contract typeWhat you set in locations.jsonWhat the file gives youWhat you still have to do
Daily high above 90 Fop: above, threshold: 90, the city's coordinates and time zoneReading now, margin to 90, highest reading this script saw todayFind out which station and time window settle the contract, and get your own forecast of the high
Daily low below 32 Fop: below, threshold: 32Reading now, margin to 32, lowest reading seen todaySame, for the overnight low
Rain or snow amountA metric that exists in the API's current blockThat field's value if the API sends it, otherwise an empty cell. The script never guesses.Run once, read the current column to see which fields exist, then pick one
Storm days (severe weather around a city)Just the placeForecast grade A to F as a number, active severe alert countDecide whether a storm grade has anything to do with your contract. We make no claim that it does.
kalshi_weather_signals.pyDownload
"""Research signals for weather contracts. Your model, your decisions.

WHAT THIS DOES
  Reads a list of places and, for each place, the contract threshold you care about
  (for example "daily high above 90 F"). Pulls two things from the GetFixn / TrueFixR API:
    1. live conditions at that place (current temperature, whatever else the API returns)
    2. the county storm-risk grade for the next 24 hours (optional, costs more)
  Then writes one row per place and contract to signals.csv and signals.json, so YOUR own
  code or spreadsheet can use it.

WHAT THIS DOES NOT DO
  No order placement. No calls to Kalshi or any exchange. No exchange keys, no wallet.
  It only reads from data.truefixr.com and writes two local files.
  The API does not return a daily-high number. It returns current conditions, an hourly temperature
  forecast (add hours_ahead, 0 to 48, free in the lean response) and a county storm-risk grade. The "running high/low" column is what THIS script has seen across its own
  runs today, so it is only as good as how often you run it. It is not an official reading.

READ THE CONTRACT
  Every market defines its own settlement source (often an official climate report for a
  named station), its own time window and its own rounding. Read those rules for each contract.
  Coordinates here are a point you chose, not necessarily the settlement station. Data can lag
  or differ from the settlement source. That mismatch is your risk.

GetFixn sells data, not trading advice. Nothing here is investment advice. Forecasts can be wrong.
You are responsible for your own strategies and for following the rules of any exchange or market
you use. No claim is made that this data is accurate enough to trade on, or that it helps.
Kalshi is a trademark of its owner. We have no partnership with or endorsement from any exchange.

SETUP
  pip install requests
  export GETFIXN_KEY=tfx_live_...        (never put the key in code)
  python kalshi_weather_signals.py       (reads kalshi_locations.json, needs gf_client.py beside it)

COST per run: places x $0.0002 for live conditions, plus places x $0.05 if GRADES is on.
Environment knobs: LOCATIONS (file), OUT_STEM (default "signals"), STATE (default
kalshi_state.json), GRADES=0 to skip grades, BUDGET_USD (default 1.00, refuse to run above it).
"""
import csv
import json
import os
import sys
from datetime import datetime, timezone
from zoneinfo import ZoneInfo

import gf_client as gf

PRICE_WEATHER = 0.0002  # USD per place, live conditions
PRICE_GRADE = 0.05      # USD per place, county forecast grade
OPS = ("above", "below")
FIELDS = ["ts_utc", "label", "lat", "lon", "local_date", "metric", "op", "threshold",
          "value_now", "margin_now", "met_now", "extreme_so_far", "extreme_margin",
          "observed_at", "age_minutes", "grade", "grade_score", "alert_count",
          "settlement_source", "note", "current", "alerts"]


def load(path):
    """Read and check the locations file. Fails early with a message you can act on."""
    with open(path) as f:
        locs = json.load(f)
    seen = set()
    for l in locs:
        if l.get("label") in seen or not l.get("label"):
            raise ValueError("each place needs a unique label: %r" % l.get("label"))
        seen.add(l["label"])
        if not (-90 <= l["lat"] <= 90 and -180 <= l["lon"] <= 180):
            raise ValueError("%s: lat/lon out of range" % l["label"])
        ZoneInfo(l.get("tz", "UTC"))  # raises if the zone name is wrong
        if not l.get("contracts"):
            raise ValueError("%s: add at least one contract" % l["label"])
        for c in l["contracts"]:
            if c.get("op") not in OPS or not isinstance(c.get("threshold"), (int, float)):
                raise ValueError("%s: contract needs op above|below and a numeric threshold" % l["label"])
            c.setdefault("metric", "temperature_f")
    return locs


def estimate_cost(locs, grades_on):
    return len(locs) * (PRICE_WEATHER + (PRICE_GRADE if grades_on else 0))


def _num(x):
    return x if isinstance(x, (int, float)) and not isinstance(x, bool) else None


def _age_minutes(cur, now):
    """Minutes since the observation, if the API gave a parseable time. Otherwise None."""
    for k in ("observed_at", "observation_time", "timestamp", "time"):
        if cur.get(k):
            try:
                t = datetime.fromisoformat(str(cur[k]).replace("Z", "+00:00"))
                if t.tzinfo is None:
                    t = t.replace(tzinfo=timezone.utc)
                return str(cur[k]), round((now - t).total_seconds() / 60, 1)
            except ValueError:
                return str(cur[k]), None
    return None, None


def _track(state, label, metric, value, local_date):
    """Running high and low for this place, this local calendar day, from our own runs only."""
    s = state.setdefault(label, {}).get(metric)
    if not s or s["date"] != local_date:  # new day (in the place's own time zone): start over
        s = {"date": local_date, "max": value, "min": value}
    elif value is not None:
        s["max"] = value if s["max"] is None else max(s["max"], value)
        s["min"] = value if s["min"] is None else min(s["min"], value)
    state[label][metric] = s
    return s


def build(locs, state, now=None, window="24h", grades_on=True):
    """One API call for conditions (and one for grades). Returns rows, updates state in place."""
    now = now or datetime.now(timezone.utc)
    w = {r["label"]: r for r in gf.weather(locs)}
    g = {r["label"]: r for r in gf.grades(locs, window)} if grades_on else {}
    rows = []
    for l in locs:
        cur = w[l["label"]].get("current") or {}
        alerts = w[l["label"]].get("alerts") or []
        grade = g.get(l["label"], {}).get("grade")
        local_date = now.astimezone(ZoneInfo(l.get("tz", "UTC"))).strftime("%Y-%m-%d")
        obs_at, age = _age_minutes(cur, now)
        for c in l["contracts"]:
            v = _num(cur.get(c["metric"]))  # None if the API has no such field. We never guess.
            s = _track(state, l["label"], c["metric"], v, local_date)
            up = c["op"] == "above"
            ext = s["max"] if up else s["min"]
            sign = 1 if up else -1
            rows.append({
                "ts_utc": now.strftime("%Y-%m-%dT%H:%M:%SZ"), "label": l["label"],
                "lat": l["lat"], "lon": l["lon"], "local_date": local_date,
                "metric": c["metric"], "op": c["op"], "threshold": c["threshold"],
                "value_now": v,
                # margin > 0 means the reading is on the contract's side of the threshold right now
                "margin_now": None if v is None else round(sign * (v - c["threshold"]), 2),
                "met_now": None if v is None else sign * (v - c["threshold"]) > 0,
                "extreme_so_far": ext,
                "extreme_margin": None if ext is None else round(sign * (ext - c["threshold"]), 2),
                "observed_at": obs_at, "age_minutes": age,
                "grade": grade, "grade_score": gf.GRADES.index(grade) if grade and grade in gf.GRADES else None,
                "alert_count": len(alerts),
                "settlement_source": c.get("settlement_source") or l.get("settlement_source"),
                "note": c.get("note"),
                "current": json.dumps(cur), "alerts": json.dumps(alerts)})
    return rows


def write(rows, stem="signals"):
    """signals.json = latest run. signals.csv = every run appended, so you keep a record."""
    with open(stem + ".json", "w") as f:
        json.dump(rows, f, indent=2)
    path = stem + ".csv"
    if os.path.exists(path):
        with open(path, newline="") as f:
            if next(csv.reader(f), None) != FIELDS:
                raise SystemExit("%s exists with different columns (another bot?). Set OUT_STEM." % path)
    new = not os.path.exists(path)
    with open(path, "a", newline="") as f:
        w = csv.DictWriter(f, FIELDS)
        if new:
            w.writeheader()
        w.writerows(rows)


def main():
    grades_on = os.environ.get("GRADES", "1") != "0"
    locs = load(os.environ.get("LOCATIONS", "kalshi_locations.json"))
    cost, cap = estimate_cost(locs, grades_on), float(os.environ.get("BUDGET_USD", "1.00"))
    if cost > cap:
        sys.exit("Estimated cost $%.4f is above BUDGET_USD $%.2f. Raise it on purpose or trim the list." % (cost, cap))
    state_path = os.environ.get("STATE", "kalshi_state.json")
    try:
        with open(state_path) as f:
            state = json.load(f)
    except (OSError, ValueError):
        state = {}
    try:
        rows = build(locs, state, grades_on=grades_on)
    except gf.LowBalance as e:
        sys.exit("PAUSED: %s" % e)
    with open(state_path, "w") as f:
        json.dump(state, f)
    write(rows, os.environ.get("OUT_STEM", "signals"))
    for r in rows:  # short summary for your terminal
        print("%-14s %s %s %s: now %s, margin %s, run-so-far %s, grade %s" % (
            r["label"], r["metric"], r["op"], r["threshold"], r["value_now"],
            r["margin_now"], r["extreme_so_far"], r["grade"]))
    print("wrote %d rows (about $%.4f)" % (len(rows), cost))


if __name__ == "__main__":
    main()

Run it

terminal
pip install requests
export GETFIXN_KEY=tfx_live_...
# needs gf_client.py from the bots download beside it
python test_kalshi_signals.py     # offline, fake HTTP, no key used
python kalshi_weather_signals.py  # reads kalshi_locations.json
example output (made-up numbers)
New York       temperature_f above 90: now 84.2, margin -5.8, run-so-far 86.0, grade B
Chicago        temperature_f below 32: now 38.5, margin -6.5, run-so-far 36.1, grade A
wrote 2 rows (about $0.1004)

Cost per run is places x $0.0002 for conditions, plus places x $0.05 if grades are on (set GRADES=0 to skip them). The script refuses to run if the estimate is over BUDGET_USD (default $1.00). Grades refresh 4 times a day, so grading more often only spends money.

kalshi_locations.json

kalshi_locations.json (examples, edit them)Download
[
  {
    "label": "New York",
    "lat": 40.78, "lon": -73.97,
    "tz": "America/New_York",
    "settlement_source": "EXAMPLE ONLY: copy the source named in YOUR contract's rules",
    "contracts": [
      {"metric": "temperature_f", "op": "above", "threshold": 90, "note": "daily high above 90 F"}
    ]
  },
  {
    "label": "Chicago",
    "lat": 41.79, "lon": -87.75,
    "tz": "America/Chicago",
    "settlement_source": "EXAMPLE ONLY: copy the source named in YOUR contract's rules",
    "contracts": [
      {"metric": "temperature_f", "op": "below", "threshold": 32, "note": "daily low below 32 F"}
    ]
  },
  {
    "label": "Miami",
    "lat": 25.79, "lon": -80.32,
    "tz": "America/New_York",
    "contracts": [
      {"metric": "temperature_f", "op": "above", "threshold": 85, "note": "daily high above 85 F"}
    ]
  }
]

The coordinates are a point you choose. They are not necessarily the station that settles your contract.

Before you rely on any of it

Read the contract rules
Each market names its own settlement source, often an official climate report for a named station, plus the time window and the rounding. Copy that into the settlement_source field so it travels with every row. This is the single most important step.
Our data is not the settlement source
It can be older, newer or measured somewhere else. The age_minutes column shows how old the reading was when the script ran, if the API gave a timestamp. A mismatch with the official figure is your risk.
Time zones and days
Set tz per place. Contracts often use the local calendar day, sometimes in standard time all year. Check your contract, and use a fixed zone such as Etc/GMT+5 if it needs one.
The running high is only what we saw
The script cannot see readings between its runs. Run it more often and it sees more. It will never match an official daily high exactly.
Field names
Only temperature_f is used by our other examples. We have not made a paid call to list every field, so run one place first and look at the current column.
Paper trade first
Log the signals for a few weeks next to what the contracts settled at, and see whether your model beats a coin before any money is involved. Most ideas will not.
Check the venue's bot rules
Some markets restrict automation or limit who can trade by region. That is between you and them.

Kalshi is a trademark of its owner; we have no partnership with or endorsement from any exchange. GetFixn sells data, not trading advice.

The offline test, test_kalshi_signals.py
test_kalshi_signals.pyDownload
"""Offline test for kalshi_weather_signals.py: fake HTTP only. Never touches the real API.
python test_kalshi_signals.py"""
import csv, json, os, tempfile
from datetime import datetime, timezone
os.environ["GETFIXN_KEY"] = "tfx_live_TESTKEY_NOT_REAL"
import gf_client as gf, kalshi_weather_signals as ks

class R:
    def __init__(s, code, data=None): s.status_code, s._d, s.headers, s.text = code, data or {}, {}, json.dumps(data or {})
    def json(s): return s._d

class Fake:
    def __init__(s, script): s.script, s.calls = list(script), []
    def post(s, url, json=None, headers=None, timeout=None):
        s.calls.append((url, json)); return s.script.pop(0)

gf.SLEEP = lambda s: None
LOCS = [
    {"label": "Hot", "lat": 30.0, "lon": -97.0, "tz": "America/Chicago",
     "contracts": [{"metric": "temperature_f", "op": "above", "threshold": 90}]},
    {"label": "Cold", "lat": 45.0, "lon": -93.0, "tz": "America/Chicago",
     "contracts": [{"metric": "temperature_f", "op": "below", "threshold": 32},
                   {"metric": "snow_in", "op": "above", "threshold": 1}]},
]
def WE(a, b, extra=None):
    cb = {"temperature_f": b}; cb.update(extra or {})
    return R(200, {"results": [{"current": {"temperature_f": a, "observed_at": "2026-01-05T11:50:00Z"}, "alerts": []},
                               {"current": cb, "alerts": [{"event": "Winter Storm Watch"}]}]})
GR = R(200, {"results": [{"grade": "B"}, {"grade": "E"}]})
T1 = datetime(2026, 1, 5, 12, 0, tzinfo=timezone.utc)

# above/below maths, margin sign, grade score, observation age, missing metric stays None
gf.HTTP = Fake([WE(93.0, 28.0), GR]); st = {}
rows = ks.build(LOCS, st, now=T1)
hot, cold, snow = rows
assert hot["met_now"] is True and hot["margin_now"] == 3.0 and hot["age_minutes"] == 10.0
assert cold["met_now"] is True and cold["margin_now"] == 4.0   # 28 is below 32 by 4
assert snow["value_now"] is None and snow["met_now"] is None   # API had no snow_in: we do not guess
assert hot["grade"] == "B" and cold["grade_score"] == 4
assert len(gf.HTTP.calls) == 2

# running extremes: keeps the day's high, resets on a new local day
T2 = datetime(2026, 1, 5, 18, 0, tzinfo=timezone.utc)
gf.HTTP = Fake([WE(88.0, 20.0), GR]); rows = ks.build(LOCS, st, now=T2)
assert rows[0]["value_now"] == 88.0 and rows[0]["extreme_so_far"] == 93.0 and rows[0]["extreme_margin"] == 3.0
assert rows[1]["extreme_so_far"] == 20.0
T3 = datetime(2026, 1, 6, 18, 0, tzinfo=timezone.utc)
gf.HTTP = Fake([WE(70.0, 40.0), GR]); rows = ks.build(LOCS, st, now=T3)
assert rows[0]["extreme_so_far"] == 70.0 and rows[0]["local_date"] == "2026-01-06"

# grades off: one call only, grade empty
gf.HTTP = Fake([WE(70.0, 40.0)]); rows = ks.build(LOCS, {}, now=T1, grades_on=False)
assert len(gf.HTTP.calls) == 1 and rows[0]["grade"] is None

# only talks to the GetFixn API, only two known routes, never an exchange
assert all(u.startswith("https://data.truefixr.com/v1/") for u, _ in gf.HTTP.calls)
assert ks.estimate_cost(LOCS, True) == 2 * 0.0502 and ks.estimate_cost(LOCS, False) == 2 * 0.0002

# out of money: 402 raises LowBalance and is not retried
gf.HTTP = Fake([R(402)])
try: ks.build(LOCS, {}, now=T1); assert 0
except gf.LowBalance: assert len(gf.HTTP.calls) == 1

# files: json + appended csv, secrets never inside, a different CSV is refused
d = tempfile.mkdtemp(); os.chdir(d)
gf.HTTP = Fake([WE(93.0, 28.0), GR]); rows = ks.build(LOCS, {}, now=T1)
ks.write(rows); ks.write(rows)
assert len(list(csv.DictReader(open("signals.csv")))) == 6
blob = open("signals.csv").read() + open("signals.json").read(); assert "TESTKEY" not in blob
open("other.csv", "w").write("a,b\n1,2\n")
try: ks.write(rows, "other"); assert 0
except SystemExit: pass

# bad input is rejected up front
for bad in ([{"label": "x", "lat": 95, "lon": 0, "contracts": [{"op": "above", "threshold": 1}]}],
            [{"label": "x", "lat": 1, "lon": 0, "contracts": [{"op": "over", "threshold": 1}]}],
            [{"label": "x", "lat": 1, "lon": 0, "tz": "Nowhere/Land", "contracts": [{"op": "above", "threshold": 1}]}],
            [{"label": "x", "lat": 1, "lon": 0, "contracts": []}]):
    json.dump(bad, open("bad.json", "w"))
    try: ks.load("bad.json"); assert 0
    except (ValueError, KeyError): pass
    except Exception as e:
        assert "ZoneInfo" in type(e).__name__ or "KeyError" in type(e).__name__, e
print("all offline tests passed")

03 / CRYPTO BOTS AND AGENTS

Pay per call in USDC on Base, no signup

If your bot already holds a wallet, it can pay for each API call itself with x402. No account, no key, no card. The bot calls a paid route, the server answers 402 with a price, the bot signs a USDC payment on Base and retries, and the data comes back.

The first payment settles right away. Later small calls add up on a $1.00 tab. We tested this with the official Python x402 v2 client and nothing else. Payments on Base are final, so give the bot its own wallet with a few dollars in it.

The same routes work for AI agents. Agents can also use the MCP server, and you can always buy a normal prepaid key instead.

RoutePrice
Model-Based Weather by Address, per location$0.0002
County forecast status, per location$0.05
Hazard score, per query$0.50

Read the price in each 402 before paying. Discovery file: data.truefixr.com/.well-known/x402.json. Not affiliated with any wallet, chain or exchange. This is data, not advice about crypto or anything else.

04 / WEATHER APPS

A weather app for your phone

A web app that works well on a phone is a page that asks a small server of yours for conditions, county forecast grades and storm history at an address, and shows the answer. People add it to their home screen and use it like an app.

Keep your key on your server. A key placed in a page can be read by anyone who opens it. The starter below keeps the key in the visitor's own browser, which is fine for a tool you use yourself and wrong for something you hand to the public.

05 / THE SHAPE OF A CHAT OR ALERT BOT

Every bot below does the same three things

StepWhat happensCost
1. AskSend a list of places (latitude and longitude), up to 500 per call, to the API.$0.05 per place for a county forecast grade, $0.0002 per place for Model-Based Weather by Address
2. CompareGrades run A (low) to F (extreme). Forecasts show what could happen, not what will. The monitors remember the last grade and only speak up when it changes.Nothing extra
3. TellA chat message, a webhook POST to your own server, a log line, or a CSV your own code reads. A wrong key or an empty balance returns 402, and the bot says so out loud.Nothing extra
gf_client.py (shared by every bot)Download
"""GetFixn client shared by the example bots. pip install requests

Reads your key from the GETFIXN_KEY environment variable. Never hard-code it.
GetFixn sells data, not trading advice. Forecasts can be wrong.
"""
import json
import os
import time

import requests

BASE = os.environ.get("GETFIXN_BASE", "https://data.truefixr.com")
HTTP = requests      # tests swap this for a fake
SLEEP = time.sleep   # tests swap this too
GRADES = "ABCDEF"    # A = low risk ... F = extreme


class LowBalance(Exception):
    """HTTP 402: key missing or wrong, or your prepaid balance ran out."""


def _key():
    key = os.environ.get("GETFIXN_KEY")
    if not key:
        raise SystemExit("Set GETFIXN_KEY first: export GETFIXN_KEY=tfx_live_...")
    return key


def post(path, body, tries=5):
    """POST JSON with the key. Backs off on 429, 5xx and network errors."""
    headers = {"Authorization": "Bearer " + _key()}
    for attempt in range(tries):
        try:
            r = HTTP.post(BASE + path, json=body, headers=headers, timeout=30)
        except requests.RequestException:
            r = None
        if r is not None and r.status_code == 200:
            return r.json()
        if r is not None and r.status_code == 402:
            # Paid route, no valid key or no balance. Retrying will not help.
            raise LowBalance("402 payment required: check GETFIXN_KEY and top up your balance")
        if r is not None and r.status_code in (400, 422):
            raise ValueError("bad request: " + r.text[:200])
        if attempt == tries - 1:
            break
        wait = 2 ** attempt  # 1, 2, 4, 8 seconds
        if r is not None and r.status_code == 429:
            wait = max(wait, int(r.headers.get("Retry-After", 0) or 0))
        SLEEP(wait)
    raise RuntimeError("API unavailable after %d tries" % tries)


def load_locations(path):
    """[{"label": "Austin", "lat": 30.27, "lon": -97.74}, ...]"""
    with open(path) as f:
        return json.load(f)


def pick(locs, text):
    """A saved label ("Austin") or raw "lat,lon" typed by a chat user."""
    for loc in locs:
        if loc["label"].lower() == text.strip().lower():
            return loc
    try:
        lat, lon = (float(x) for x in text.split(","))
    except ValueError:
        raise ValueError("Use a saved name or lat,lon like 30.27,-97.74")
    if not (-90 <= lat <= 90 and -180 <= lon <= 180):
        raise ValueError("lat,lon out of range")
    return {"label": text.strip(), "lat": lat, "lon": lon}


def _batch(path, locs, extra=None):
    out = []
    for i in range(0, len(locs), 500):  # API limit: 500 locations per call
        chunk = locs[i:i + 500]
        body = {"locations": [{"lat": l["lat"], "lon": l["lon"], "label": l["label"]} for l in chunk]}
        body.update(extra or {})
        results = post(path, body).get("results", [])
        if len(results) != len(chunk):
            raise RuntimeError("API returned %d results for %d locations" % (len(results), len(chunk)))
        out += [dict(l, **r) for l, r in zip(chunk, results)]
    return out


def grades(locs, window="24h"):
    """county forecast grade per location. $0.05 per location."""
    return _batch("/v1/portfolio/risk", locs, {"window": window})


def weather(locs):
    """Live weather + severe alerts per location. $0.0002 per location."""
    return _batch("/v1/weather/batch", locs)


def check(locs, state_path="state.json", window="24h", alert_at="D"):
    """Fetch grades, compare with the last run, return changes at or above alert_at."""
    try:
        with open(state_path) as f:
            old = json.load(f)
    except (OSError, ValueError):
        old = {}
    new, changes = {}, []
    for r in grades(locs, window):
        g = r.get("grade")
        new[r["label"]] = g
        if g in GRADES and g != old.get(r["label"]) and GRADES.index(g) >= GRADES.index(alert_at):
            changes.append({"label": r["label"], "lat": r["lat"], "lon": r["lon"],
                            "county": r.get("county"), "state": r.get("state"),
                            "was": old.get(r["label"]), "now": g})
    with open(state_path, "w") as f:
        json.dump(new, f)
    return changes


def line(c):
    return "%s (%s, %s): county forecast grade %s -> %s. A forecast, not a certainty." % (
        c["label"], c.get("county"), c.get("state"), c["was"] or "new", c["now"])

The shared client, in plain English

  • Key from the environment. GETFIXN_KEY is read at run time. It is never in the code and never printed.
  • 402 means stop. The API returns 402 when the key is missing or wrong, or the prepaid balance is empty. Retrying cannot fix that, so the bot raises LowBalance and each bot turns it into a message that tells you to top up.
  • Backoff for everything else. A 429 (too many requests), a 5xx, or a network error waits 1, 2, 4, then 8 seconds and tries again, up to five tries. A 429 with Retry-After waits at least that long.
  • Big lists are split. The API limit is 500 locations per call, so longer lists go out in chunks.
  • Misaligned answers are errors. If the number of results does not match the number of places, the bot stops rather than pairing the wrong grade with the wrong place.

The response fields used here (results[].grade, county, state, current.temperature_f, alerts) come from the API's own published example shapes. We have not made a paid call to confirm them, so run a one-place test before you rely on a bot.

Download all example bots (zip)

06 / COST CALCULATOR

What a day of it costs

Prices are real: $0.05 per county forecast grade per place, $0.0002 per live weather lookup per place. Forecast grades refresh 4 times a day, so checking grades more than 4 times a day only spends money.

PER DAY$0.00
PER 30 DAYS$0.00
$50 BALANCE LASTS0 days

Arithmetic only: places x checks x price. Your hosting costs are separate. No subscriptions: you pay from your prepaid balance (minimum purchase $25).

07 / DISCORD

Discord bot: /risk and /weather

People in your server type /risk Austin and the bot answers with the county forecast grade for that place. It runs all day, but costs nothing while nobody asks.

ArchitectureA Discord usertypes /risk Austinslash commanddiscord_bot.pyyour server, always onPOST /v1/portfolio/riskGetFixn APIdata.truefixr.comgrade A to FDiscord channelreply message
Cost per day

Pay per lookup, idle costs $0. $0.05 per /risk, $0.0002 per /weather.

Example: 20 risk lookups and 100 weather lookups a day = $1.00 + $0.02 = $1.02 a day.

Tip: cache the answer for a few hours in busy servers. The grade only refreshes 4 times a day.

Deploy with

Files: discord_bot.py, gf_client.py, locations.json. Needs a bot token from the Discord developer portal and pip install discord.py requests.

discord_bot.pyDownload
"""Discord slash-command bot: /risk and /weather for a saved place or "lat,lon".
pip install discord.py requests
export DISCORD_TOKEN=... GETFIXN_KEY=tfx_live_...   (a bot token from the Discord developer portal)
Each /risk costs $0.05, each /weather $0.0002. Data only, not advice.
"""
import asyncio
import os

import discord
from discord import app_commands

import gf_client as gf

LOCS = gf.load_locations(os.environ.get("LOCATIONS", "locations.json"))


class Bot(discord.Client):
    def __init__(self):
        super().__init__(intents=discord.Intents.default())
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        await self.tree.sync()


bot = Bot()


async def run(inter, fn, fmt, place):
    await inter.response.defer()  # the API call can take a few seconds
    try:
        loc = gf.pick(LOCS, place)
        r = (await asyncio.to_thread(fn, [loc]))[0]
        await inter.followup.send(fmt(loc, r))
    except gf.LowBalance as e:
        await inter.followup.send("Balance problem: %s" % e)
    except (ValueError, RuntimeError) as e:
        await inter.followup.send("Could not do that: %s" % e)


@bot.tree.command(description="County forecast grade A-F for a saved place or lat,lon ($0.05)")
async def risk(inter: discord.Interaction, place: str):
    await run(inter, gf.grades, lambda l, r: "**%s**: county forecast grade **%s** (%s, %s). A forecast, not a certainty." % (
        l["label"], r.get("grade"), r.get("county"), r.get("state")), place)


@bot.tree.command(description="Model-Based Weather by Address for a saved place or lat,lon ($0.0002)")
async def weather(inter: discord.Interaction, place: str):
    await run(inter, gf.weather, lambda l, r: "**%s**: %s F, %d active alert(s)" % (
        l["label"], (r.get("current") or {}).get("temperature_f"), len(r.get("alerts") or [])), place)


if __name__ == "__main__":
    bot.run(os.environ["DISCORD_TOKEN"])

08 / TELEGRAM

Telegram bot, no extra library

Same idea for Telegram. It uses plain long polling with requests, so there is nothing to host publicly: no domain, no SSL, no webhook URL.

ArchitectureTelegram user/risk Austinmessagetelegram_bot.pygetUpdates long pollPOST /v1/portfolio/riskGetFixn APIdata.truefixr.comgrade A to FTelegram chatsendMessage
Cost per day

Same as Discord: $0.05 per /risk, $0.0002 per /weather, nothing while idle.

Example: 50 risk lookups a day = $2.50 a day.

Anyone who can message your bot spends your balance. Keep it private or add an allow-list of chat ids.

Deploy with

Files: telegram_bot.py, gf_client.py, locations.json. Needs a token from @BotFather.

telegram_bot.pyDownload
"""Telegram bot, long polling, no extra library. pip install requests
export TELEGRAM_TOKEN=...  GETFIXN_KEY=tfx_live_...   (token from @BotFather)
/risk Austin   -> county forecast grade ($0.05)      /weather 30.27,-97.74 -> live weather ($0.0002)
Data only, not advice.
"""
import os
import time

import requests

import gf_client as gf

API = "https://api.telegram.org/bot%s/" % os.environ["TELEGRAM_TOKEN"]
LOCS = gf.load_locations(os.environ.get("LOCATIONS", "locations.json"))


def reply(text, update):
    requests.post(API + "sendMessage", json={"chat_id": update["message"]["chat"]["id"], "text": text}, timeout=20)


def handle(update):
    parts = (update.get("message", {}).get("text") or "").split(None, 1)
    if not parts or not parts[0].startswith("/"):
        return
    cmd, arg = parts[0].split("@")[0], (parts[1] if len(parts) > 1 else "")
    try:
        if cmd == "/risk":
            loc = gf.pick(LOCS, arg)
            r = gf.grades([loc])[0]
            reply("%s: county forecast grade %s (%s, %s). A forecast, not a certainty." % (
                loc["label"], r.get("grade"), r.get("county"), r.get("state")), update)
        elif cmd == "/weather":
            loc = gf.pick(LOCS, arg)
            r = gf.weather([loc])[0]
            reply("%s: %s F, %d active alert(s)" % (
                loc["label"], (r.get("current") or {}).get("temperature_f"), len(r.get("alerts") or [])), update)
        else:
            reply("Try /risk Austin or /weather 30.27,-97.74. Saved places: " + ", ".join(l["label"] for l in LOCS), update)
    except gf.LowBalance as e:
        reply("Balance problem: %s" % e, update)
    except (ValueError, RuntimeError) as e:
        reply("Could not do that: %s" % e, update)


def main():
    offset = None
    while True:
        try:
            r = requests.get(API + "getUpdates", params={"timeout": 30, "offset": offset}, timeout=40).json()
            for u in r.get("result", []):
                offset = u["update_id"] + 1
                handle(u)
        except requests.RequestException:
            time.sleep(5)  # Telegram or the network hiccuped, try again


if __name__ == "__main__":
    main()

09 / SLACK

Slack alert bot

Posts to a Slack channel when a place's county forecast grade moves to D or worse. It uses a Slack Incoming Webhook, so there is no app server to run, just a schedule.

ArchitectureSchedulercron, Actions or Workerevery 6 hoursslack_alert.pycompares with state.jsonPOST /v1/portfolio/riskGetFixn APIdata.truefixr.comgradesSlack channelIncoming Webhook post
Cost per day

Places x runs per day x $0.05.

Example: 4 places, 4 runs a day = $0.80 a day. Once a day = $0.20.

If the balance runs out the bot posts one "paused, please top up" message and exits with code 3.

Deploy with

Files: slack_alert.py, gf_client.py, locations.json

slack_alert.pyDownload
"""Slack alert bot: post to a channel when a place's county forecast grade changes to D or worse.
pip install requests
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...   (Slack "Incoming Webhooks" app)
export GETFIXN_KEY=tfx_live_...
Run it on a schedule (see deploy recipes). Cost: locations x runs per day x $0.05. Data only, not advice.
"""
import os
import sys

import requests

import gf_client as gf


def main():
    locs = gf.load_locations(os.environ.get("LOCATIONS", "locations.json"))
    try:
        changes = gf.check(locs, os.environ.get("STATE", "state.json"), alert_at=os.environ.get("ALERT_AT", "D"))
    except gf.LowBalance as e:
        changes = None
        text = "GetFixn bot paused: %s" % e
    if changes:
        text = "Storm forecast changes:\n" + "\n".join("- " + gf.line(c) for c in changes)
    elif changes is not None:
        return print("no changes")
    requests.post(os.environ["SLACK_WEBHOOK_URL"], json={"text": text}, timeout=20).raise_for_status()
    print(text)
    sys.exit(0 if changes is not None else 3)


if __name__ == "__main__":
    main()

10 / WEBHOOK RELAY

Webhook relay, no SMS

Instead of texting people, the relay sends a signed JSON message to any URL you own. Point it at n8n, Make, Zapier, a Google Apps Script or your own server and do whatever you like from there.

ArchitectureSchedulercron or CIevery 6 hourswebhook_relay.pydiff against last gradesPOST /v1/portfolio/riskGetFixn APIdata.truefixr.comchanged gradesYour webhook URL(s)JSON + X-Signature HMAC
Cost per day

Places x runs per day x $0.05. The relay itself adds no per-message fee.

Example: 10 places, 4 runs a day = $2.00 a day.

Webhook hosts may charge for their own usage. That is between you and them.

Deploy with

Files: webhook_relay.py, gf_client.py, locations.json

webhook_relay.pyDownload
"""Webhook relay, no SMS: when a county forecast grade changes, POST JSON to YOUR url(s)
(your own server, n8n, Make, Zapier, a Google Apps Script, anything that takes a webhook).
pip install requests
export RELAY_URLS=https://example.com/hook,https://other.example/hook   GETFIXN_KEY=tfx_live_...
export RELAY_SECRET=any-long-random-string   (optional: adds an HMAC-SHA256 X-Signature header so you can verify the sender)
"""
import hashlib
import hmac
import json
import os
import time

import requests

import gf_client as gf


def send(url, payload):
    body = json.dumps(payload, sort_keys=True).encode()
    headers = {"Content-Type": "application/json"}
    secret = os.environ.get("RELAY_SECRET")
    if secret:
        headers["X-Signature"] = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
    for attempt in range(3):
        try:
            if requests.post(url, data=body, headers=headers, timeout=20).status_code < 300:
                return True
        except requests.RequestException:
            pass
        time.sleep(2 ** attempt)
    return False


def main():
    locs = gf.load_locations(os.environ.get("LOCATIONS", "locations.json"))
    changes = gf.check(locs, os.environ.get("STATE", "state.json"), alert_at=os.environ.get("ALERT_AT", "D"))
    if not changes:
        return print("no changes")
    payload = {"source": "getfixn-example", "note": "forecast data, not advice", "changes": changes}
    for url in os.environ["RELAY_URLS"].split(","):
        print(url.split("/")[2], "ok" if send(url.strip(), payload) else "FAILED")


if __name__ == "__main__":
    main()
what your webhook receives (format example, values made up)
{
  "source": "getfixn-example",
  "note": "forecast data, not advice",
  "changes": [
    {"label": "Dallas", "lat": 32.78, "lon": -96.8, "county": "Dallas", "state": "TX", "was": "B", "now": "D"}
  ]
}

11 / SCHEDULED MONITOR

Scheduled monitor (cron)

The simplest bot: run one script every few hours. It prints what changed, appends it to alerts.jsonl, and exits with code 3 if the balance needs a top-up so your scheduler can tell you.

Architecturecron / systemd timer0 and 6, 12, 18 hoursruns oncemonitor_cron.pystate.json + alerts.jsonlPOST /v1/portfolio/riskGetFixn APIdata.truefixr.comgradesLog / your own toolsstdout, alerts.jsonl
Cost per day

Places x runs per day x $0.05.

Example: 4 places every 6 hours = 4 x 4 x $0.05 = $0.80 a day, about $24 over 30 days.

Add weather-only checks for 4 places every hour: 4 x 24 x $0.0002 = $0.0192 a day.

Deploy with

Files: monitor_cron.py, gf_client.py, locations.json

monitor_cron.pyDownload
"""Scheduled monitor: one run = one check. Prints changes and appends them to alerts.jsonl.
pip install requests
export GETFIXN_KEY=tfx_live_...
Exit codes: 0 fine, 3 balance/key problem (so cron mail or CI can tell you to top up).
The forecast refreshes 4 times a day, so running more often than every 6 hours only costs you money.
"""
import json
import os
import sys
import time

import gf_client as gf


def main():
    locs = gf.load_locations(os.environ.get("LOCATIONS", "locations.json"))
    try:
        changes = gf.check(locs, os.environ.get("STATE", "state.json"), alert_at=os.environ.get("ALERT_AT", "D"))
    except gf.LowBalance as e:
        print("PAUSED:", e)
        sys.exit(3)
    with open("alerts.jsonl", "a") as f:
        for c in changes:
            print(gf.line(c))
            f.write(json.dumps(dict(c, ts=int(time.time()))) + "\n")
    if not changes:
        print("no changes")


if __name__ == "__main__":
    main()

12 / RESEARCH SIGNAL FILE

The simple version: a storm-risk signals file

The smaller sibling of the Kalshi weather bot. It fetches county forecast grades and live weather for a list of places and writes signals.csv and signals.json. Your own code reads that file. That is the whole job. Both bots default to the same file names, so give one of them its own folder or set OUT_STEM on the Kalshi one.

Does
One call for grades, one for weather, one row per place with a timestamp. Grade is also given as a number, A = 0 to F = 5.
Does not
There is no order placement code, no exchange library, no exchange keys and no wallet in this bot. We will not add any. Connecting a file to a market is your decision and your responsibility.
No claim it helps
We do not say weather or storm data moves any market. Whether a signal is useful is for you to test, and most ideas will not work. Past data and forecasts say nothing certain about the future.
ArchitectureSchedulercron, 4 times a dayruns oncesignal_bot.pygrades + weather, mergedPOST /v1/portfolio/risk, /v1/weather/batchGetFixn APIdata.truefixr.comJSON resultssignals.csv + signals.jsonone row per place per runyou read itYOUR strategy codenot written by us, not provided
Cost per day

Each run: places x ($0.05 + $0.0002).

Example: 10 places, 4 runs a day = 10 x $0.0502 x 4 = $2.01 a day.

Grades refresh 4 times a day, so more runs would only add weather. A weather-only job for 10 places every hour costs 10 x 24 x $0.0002 = $0.048 a day.

Deploy with

Files: signal_bot.py, gf_client.py, locations.json

signal_bot.pyDownload
"""Research signal file for YOUR OWN strategy (prediction-market or weather-sensitive research).

This script only READS data and WRITES signals.csv and signals.json. It places no orders, talks to
no exchange and holds no exchange keys. Whatever you do with the file is your decision and your risk.

GetFixn sells data, not trading advice. Nothing here is investment advice. Forecasts can be wrong.
You are responsible for your own strategies and for following the rules of any exchange or market you use.

pip install requests
export GETFIXN_KEY=tfx_live_...
Cost per run: locations x ($0.05 + $0.0002). Forecast grades refresh 4 times a day.
"""
import csv
import json
import os
import sys
import time

import gf_client as gf

FIELDS = ["ts_utc", "label", "lat", "lon", "county", "state", "grade", "grade_score",
          "temperature_f", "alert_count", "alerts"]


def build(locs, window="24h"):
    g = {r["label"]: r for r in gf.grades(locs, window)}
    w = {r["label"]: r for r in gf.weather(locs)}
    ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
    rows = []
    for l in locs:
        gr, we = g[l["label"]], w[l["label"]]
        grade = gr.get("grade")
        alerts = we.get("alerts") or []
        rows.append({
            "ts_utc": ts, "label": l["label"], "lat": l["lat"], "lon": l["lon"],
            "county": gr.get("county"), "state": gr.get("state"), "grade": grade,
            "grade_score": gf.GRADES.index(grade) if grade in gf.GRADES else None,  # A=0 ... F=5
            "temperature_f": (we.get("current") or {}).get("temperature_f"),
            "alert_count": len(alerts), "alerts": json.dumps(alerts)})
    return rows


def write(rows, stem="signals"):
    with open(stem + ".json", "w") as f:
        json.dump(rows, f, indent=2)
    new = not os.path.exists(stem + ".csv")
    with open(stem + ".csv", "a", newline="") as f:  # CSV keeps history, one block per run
        w = csv.DictWriter(f, FIELDS)
        if new:
            w.writeheader()
        w.writerows(rows)


if __name__ == "__main__":
    try:
        rows = build(gf.load_locations(os.environ.get("LOCATIONS", "locations.json")), os.environ.get("WINDOW", "24h"))
    except gf.LowBalance as e:
        sys.exit("PAUSED: %s" % e)
    write(rows)
    print("wrote %d rows to signals.csv and signals.json" % len(rows))
signals.csv (format example, values made up)
ts_utc,label,lat,lon,county,state,grade,grade_score,temperature_f,alert_count,alerts
2026-01-01T12:00:00Z,Austin,30.27,-97.74,Travis,TX,B,1,71.0,0,[]
2026-01-01T12:00:00Z,Dallas,32.78,-96.8,Dallas,TX,D,3,64.0,1,"[{...}]"
read_signals.py (skeleton, not a strategy)
# Your own file reader. The strategy part is yours: GetFixn does not provide or suggest one.
import csv
rows = list(csv.DictReader(open("signals.csv")))
latest = rows[-4:]   # last run, if you watch 4 places
for r in latest:
    print(r["label"], r["grade"], r["alert_count"])
# ...your research or your own rules go here. Nothing in this file places an order.

If you use a signals file for market research

  • Treat the file as one input among many, for backtests and paper trading first. Do not connect it to real money until you understand the risks below.
  • Forecast grades describe weather risk at a place. They do not describe prices, odds or outcomes of any market.
  • Check the rules of the exchange or market you use before automating anything. Some prohibit bots, some restrict by region, some treat automated trading differently.
  • Keep your own records of what the data said and when. The CSV appends every run for that reason.

13 / DEPLOY

Where to run it

Chat bots (Discord, Telegram) need to stay on, so use systemd or Docker. Monitors (Slack, relay, cron, signals) just need to run once on a schedule, so any scheduler works.

Any Linux box or small VPS. The loop above sources .env so the key stays out of the crontab itself.

crontab + .env
# crontab -e   (every 6 hours at :15)
15 */6 * * * cd /opt/gf-bots && set -a && . ./.env && set +a && /usr/bin/python3 monitor_cron.py >> monitor.log 2>&1

# /opt/gf-bots/.env  (chmod 600 .env, never commit it)
GETFIXN_KEY=tfx_live_your_key_here
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...

The chips under each bot open the matching tab. Cloudflare Worker, GitHub Action and systemd files are provided as written and have not been run here.

14 / RISKS

Risks, plainly

Forecasts can be wrong
A grade of D means higher forecast risk, not that anything will happen. A grade of A does not mean nothing can happen. Never treat one as a certainty.
Money risk is yours
If you connect any output to a market, you can lose money, including all of it. We do not know your situation, we do not recommend any trade, and we do not take part in your decisions.
Bots can run up costs
A bug that loops can spend your balance fast at $0.05 a lookup. Put a spending cap in your code, test with one place, and watch the first day. The agent loop in the AI agents page shows a hard cap.
Keys leak
Anyone with your key can spend your balance. Keep it in an environment variable, never in code or screenshots, never in chat. Rotate it from your account page if you think it leaked.
Rules differ by market
Exchanges, prediction markets and chat platforms each have rules about bots, automation, region and who may take part. Read and follow them. That is your job, not ours.
Not official warnings
This is not a warning service. Do not use it for life-safety decisions. Follow your local National Weather Service alerts. GetFixn is not affiliated with NOAA or the NWS.

Next: a key and one place

Buy a key (from $25, pay as you go, no subscription), copy a bot, and run it on one place first. Heavy or commercial/trading volume? Email admin@truefixr.com, we can talk about a bigger deal.