Spaces:
Running
Running
Upload 6 files
Browse files
README.md
CHANGED
|
@@ -1,15 +1,61 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: π
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.19.0
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: mit
|
| 12 |
-
short_description:
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Did This Stock Actually Change
|
| 3 |
emoji: π
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 6.19.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
license: mit
|
| 11 |
+
short_description: Which moves were real events, which were dice. No API key.
|
| 12 |
---
|
| 13 |
|
| 14 |
+
# π Did This Stock Actually Change?
|
| 15 |
+
|
| 16 |
+
Financial charts make every wiggle look like a story; most wiggles are dice. Type a
|
| 17 |
+
ticker (free Yahoo data via `yfinance`, **no API key**) and get the honest split:
|
| 18 |
+
|
| 19 |
+
- **Shock events** β days or clusters that broke out of the stock's normal movement,
|
| 20 |
+
with dates and sizes ("β12.3%, sharpest day ~11Γ normal"). Detected by the
|
| 21 |
+
[Clutch](https://huggingface.co/spaces/Aluode/Clutch2)'s leaky-integrator gate fed
|
| 22 |
+
daily returns normalized by *past-only local* volatility β so one outsized day trips
|
| 23 |
+
it instantly, several stressed days in a row accumulate and trip it too (which a
|
| 24 |
+
naive threshold misses), and a permanently rougher stock doesn't spam flags.
|
| 25 |
+
- **Volatility regime changes** β "a typical day went from Β±1.1% to Β±2.7% around
|
| 26 |
+
2025-09-17". Non-overlapping-window vols, strongest-split ratio test, day-level
|
| 27 |
+
refinement. At most one per window: only the strongest is claimed.
|
| 28 |
+
- **Drift vs luck** β the period's total return compared against what pure chance could
|
| 29 |
+
produce at this stock's wobble (2ΟΒ·βn). Most yearly stock moves are **not**
|
| 30 |
+
distinguishable from luck, and this page says so, which chart commentary never will.
|
| 31 |
+
- **Free headlines** β Yahoo's keyless news feed; if a flagged event is recent, the
|
| 32 |
+
headlines may be the *why*. (Recent items only β Yahoo's free feed can't be matched
|
| 33 |
+
to events from months back.)
|
| 34 |
+
|
| 35 |
+
## Measured calibration (synthetic GBM suites, "normal" suspicion)
|
| 36 |
+
|
| 37 |
+
- pure random walk, 60 seeds β **2.8 shock events/yr** (these are the year's genuinely
|
| 38 |
+
biggest moves, labeled with their size in Γ-normal-day units β the reader can judge)
|
| 39 |
+
- a β9% overnight gap (5Γ daily vol) β detected **52/60**
|
| 40 |
+
- three consecutive β2.2Ο days (slow bleed a threshold misses) β detected **53/60**
|
| 41 |
+
- constant volatility β false "regime change" **3/60**; a 1.2%β2.8% vol change β
|
| 42 |
+
detected **34/40**, flagged on average 9 days from the true day
|
| 43 |
+
- the strict setting trades detection for silence (39/60 on the gap); the eager setting
|
| 44 |
+
the reverse. The slider is the trade-off, stated.
|
| 45 |
+
|
| 46 |
+
Real returns are fatter-tailed than GBM, so real tickers will show somewhat more events
|
| 47 |
+
than 2.8/yr β that is the data being eventful, not the detector lying.
|
| 48 |
+
|
| 49 |
+
Known limits: Yahoo rate-limits shared servers occasionally (the app says so and retries
|
| 50 |
+
work); very short histories (<40 days) are refused; the luck bound uses whole-period
|
| 51 |
+
volatility, so a mid-window regime change widens it.
|
| 52 |
+
|
| 53 |
+
Nothing is stored. This describes the past, predicts nothing, and is not investment
|
| 54 |
+
advice or a recommendation to buy or sell anything.
|
| 55 |
+
|
| 56 |
+
## Files
|
| 57 |
+
- `stock.py` β data fetch (cached, keyless), news parser, shock gate, vol-regime test, verdicts
|
| 58 |
+
- `change.py` / `clutch.py` β the shared engine from the companion Spaces
|
| 59 |
+
- `app.py` β the Gradio app
|
| 60 |
+
|
| 61 |
+
Built by Antti Luode (PerceptionLab). *Do not hype. Do not lie. Just show.*
|
app.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py β "Did This Stock Actually Change?"
|
| 3 |
+
Type a ticker. Free Yahoo data comes in, and you get a plain answer: which moves in
|
| 4 |
+
the past year were real events, which were ordinary wobble, whether the ride itself
|
| 5 |
+
got rougher β plus the latest free headlines. No API key, no account, nothing stored.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import matplotlib
|
| 9 |
+
matplotlib.use("Agg")
|
| 10 |
+
from matplotlib.figure import Figure
|
| 11 |
+
import numpy as np
|
| 12 |
+
import gradio as gr
|
| 13 |
+
|
| 14 |
+
import stock
|
| 15 |
+
|
| 16 |
+
INK = "#1b1b1b"
|
| 17 |
+
RED = "#d64545"
|
| 18 |
+
ORANGE = "#e8a33d"
|
| 19 |
+
CALM = "#2e9e5b"
|
| 20 |
+
|
| 21 |
+
PERIODS = {"6 months": "6mo", "1 year": "1y", "2 years": "2y"}
|
| 22 |
+
SENS = {1: 1.5, 2: 1.0, 3: 0.7} # 1 = only the undeniable β¦ 3 = flag early
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def make_plot(ticker, dates, close, a, currency):
|
| 26 |
+
n = len(close)
|
| 27 |
+
fig = Figure(figsize=(7.6, 4.0), dpi=96)
|
| 28 |
+
ax = fig.add_subplot(111)
|
| 29 |
+
t = np.arange(n)
|
| 30 |
+
ax.plot(t, close, color=INK, lw=1.2)
|
| 31 |
+
for e in a["shocks"]:
|
| 32 |
+
ax.axvspan(e["i0"] + 1, e["i1"] + 1, color=RED, alpha=0.18)
|
| 33 |
+
mid = (e["i0"] + e["i1"]) // 2 + 1
|
| 34 |
+
ax.text(mid, float(np.max(close)), stock.pct(e["cum_ret"]),
|
| 35 |
+
color=RED, fontsize=8.5, ha="center", va="top", fontweight="bold")
|
| 36 |
+
for v in a["vols"]:
|
| 37 |
+
ax.axvline(v["at"] + 1, color=ORANGE, lw=1.6, ls="--")
|
| 38 |
+
ax.text(v["at"] + 1, float(np.min(close)), " ride changed",
|
| 39 |
+
color=ORANGE, fontsize=8.5, va="bottom", fontweight="bold")
|
| 40 |
+
# x ticks: ~6 date labels
|
| 41 |
+
idx = np.linspace(0, n - 1, 6).astype(int)
|
| 42 |
+
ax.set_xticks(idx)
|
| 43 |
+
ax.set_xticklabels([dates[i] for i in idx], fontsize=8)
|
| 44 |
+
cur = f" ({currency})" if currency else ""
|
| 45 |
+
ax.set_ylabel(f"price{cur}")
|
| 46 |
+
ne = len(a["shocks"])
|
| 47 |
+
ax.set_title(f"{ticker.upper()} β red = real events ({ne}), everything else = ordinary wobble",
|
| 48 |
+
fontsize=11)
|
| 49 |
+
ax.grid(alpha=0.22)
|
| 50 |
+
fig.tight_layout()
|
| 51 |
+
return fig
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def news_md(ticker, items, a, dates):
|
| 55 |
+
if not items:
|
| 56 |
+
return ("*No free headlines available right now (Yahoo occasionally rate-limits "
|
| 57 |
+
"shared servers β try again in a minute).*")
|
| 58 |
+
lines = []
|
| 59 |
+
recent_shock = any(e["i1"] + 1 >= len(dates) - 15 for e in a["shocks"])
|
| 60 |
+
if recent_shock:
|
| 61 |
+
lines.append("One of the flagged events is **recent** β these headlines may be "
|
| 62 |
+
"the *why*:")
|
| 63 |
+
lines.append(f"#### π° Latest free headlines for {ticker.upper()}")
|
| 64 |
+
for it in items:
|
| 65 |
+
meta = " Β· ".join(x for x in (it["publisher"], it["when"]) if x)
|
| 66 |
+
title = it["title"].replace("|", "-")
|
| 67 |
+
if it["url"]:
|
| 68 |
+
lines.append(f"- [{title}]({it['url']}) <small>{meta}</small>")
|
| 69 |
+
else:
|
| 70 |
+
lines.append(f"- {title} <small>{meta}</small>")
|
| 71 |
+
lines.append("<small>Headlines are Yahoo Finance's free feed β recent items only; "
|
| 72 |
+
"they cannot be matched to events from months ago.</small>")
|
| 73 |
+
return "\n".join(lines)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def run_check(ticker, period_label, sensitivity):
|
| 77 |
+
ticker = (ticker or "").strip()
|
| 78 |
+
if not ticker:
|
| 79 |
+
return "Type a ticker first (Yahoo format β e.g. `AAPL`, `NOK`, `BTC-USD`, `^GSPC`).", None, ""
|
| 80 |
+
dates, close, currency, err = stock.fetch_prices(ticker, PERIODS[period_label])
|
| 81 |
+
if err:
|
| 82 |
+
return err, None, ""
|
| 83 |
+
a = stock.analyze(dates, close, SENS[int(sensitivity)])
|
| 84 |
+
md = stock.verdict_md(ticker.upper(), dates, close, a, currency)
|
| 85 |
+
fig = make_plot(ticker, dates, close, a, currency)
|
| 86 |
+
nm = news_md(ticker, stock.fetch_news(ticker), a, dates)
|
| 87 |
+
return md, fig, nm
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
HEADER = """
|
| 91 |
+
# π Did This Stock Actually Change?
|
| 92 |
+
|
| 93 |
+
Financial charts make **every** wiggle look like a story. Most wiggles are dice.
|
| 94 |
+
Type any ticker and get the honest split:
|
| 95 |
+
|
| 96 |
+
> which moves were **real events** (worth asking *why*), which were **ordinary
|
| 97 |
+
> wobble** (worth ignoring), whether **the ride itself got rougher** β and whether the
|
| 98 |
+
> period's overall drift is even **distinguishable from luck**.
|
| 99 |
+
|
| 100 |
+
Free Yahoo data, free headlines, **no API key, no account, nothing stored**.
|
| 101 |
+
Works for stocks (`AAPL`, `NOK`), crypto (`BTC-USD`), indices (`^GSPC`, `^OMXH25`).
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
+
FOOTER = """
|
| 105 |
+
---
|
| 106 |
+
<small>Engine: the same *Clutch* surprise gate that powers the
|
| 107 |
+
[compute demo](https://huggingface.co/spaces/Aluode/Clutch2) and
|
| 108 |
+
[Did Something Actually Change?](https://huggingface.co/spaces/Aluode/DidItChange),
|
| 109 |
+
here fed daily returns (shocks) and rolling volatility (regime changes), with measured
|
| 110 |
+
false-alarm and detection rates in the README. It describes the past only, predicts
|
| 111 |
+
nothing, and is not investment advice. Built by Antti Luode (PerceptionLab).
|
| 112 |
+
*Do not hype. Do not lie. Just show.*</small>
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
with gr.Blocks(title="Did This Stock Actually Change?") as demo:
|
| 116 |
+
gr.Markdown(HEADER)
|
| 117 |
+
with gr.Row():
|
| 118 |
+
s_ticker = gr.Textbox(label="Ticker (Yahoo format)", value="NOK", scale=2)
|
| 119 |
+
s_period = gr.Dropdown(list(PERIODS), value="1 year", label="Window", scale=1)
|
| 120 |
+
s_sens = gr.Slider(1, 3, 2, step=1, scale=2,
|
| 121 |
+
label="suspicion (1 = only the undeniable Β· 3 = flag early hints)")
|
| 122 |
+
s_run = gr.Button("π Check it", variant="primary", scale=1)
|
| 123 |
+
with gr.Row():
|
| 124 |
+
with gr.Column(scale=3):
|
| 125 |
+
s_plot = gr.Plot(label="the year, judged")
|
| 126 |
+
s_md = gr.Markdown()
|
| 127 |
+
with gr.Column(scale=2):
|
| 128 |
+
s_news = gr.Markdown()
|
| 129 |
+
gr.Examples([["NOK"], ["AAPL"], ["NVDA"], ["BTC-USD"], ["^GSPC"]], inputs=[s_ticker])
|
| 130 |
+
s_run.click(run_check, [s_ticker, s_period, s_sens], [s_md, s_plot, s_news])
|
| 131 |
+
gr.Markdown(FOOTER)
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
demo.launch()
|
change.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
change.py β 'Did something actually change?' engine for normal people.
|
| 3 |
+
|
| 4 |
+
Same Clutch + MagnitudeGate as the compute demo, pointed at a human question:
|
| 5 |
+
is this series just its usual wobble, or did something really shift, and when?
|
| 6 |
+
|
| 7 |
+
Loop (identical structure to the drift substrate):
|
| 8 |
+
cheap = extrapolate the cached linear model of your recent numbers
|
| 9 |
+
costly = refit that model on the last `window` points
|
| 10 |
+
error = |prediction - today's number| / typical wobble
|
| 11 |
+
A gate trip == the model of "normal" broke == a real change. Trips close together
|
| 12 |
+
are merged into one EVENT with a plain-language before/after summary.
|
| 13 |
+
|
| 14 |
+
Honesty rules: warm-up trips are ignored, pure noise must yield "no change",
|
| 15 |
+
slow steady trends are reported as trends (they never break a linear model, and
|
| 16 |
+
saying otherwise would be lying).
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
import numpy as np
|
| 21 |
+
from clutch import Clutch, MagnitudeGate
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ------------------------------------------------------------------ input
|
| 25 |
+
def parse_numbers(text=None, file_obj=None):
|
| 26 |
+
raw = ""
|
| 27 |
+
if file_obj is not None:
|
| 28 |
+
path = file_obj if isinstance(file_obj, str) else getattr(file_obj, "name", None)
|
| 29 |
+
if path:
|
| 30 |
+
with open(path, "r", errors="ignore") as f:
|
| 31 |
+
raw = f.read()
|
| 32 |
+
elif text:
|
| 33 |
+
raw = text
|
| 34 |
+
if not raw.strip():
|
| 35 |
+
return None, "No numbers yet β paste some, or pick an example above."
|
| 36 |
+
rows = []
|
| 37 |
+
for line in raw.strip().splitlines():
|
| 38 |
+
nums = re.findall(r"[-+]?\d*[\.,]?\d+(?:[eE][-+]?\d+)?", line.replace(",", "."))
|
| 39 |
+
if nums:
|
| 40 |
+
rows.append([float(x) for x in nums])
|
| 41 |
+
if not rows:
|
| 42 |
+
return None, "I couldn't find any numbers in that."
|
| 43 |
+
ncol = max(len(r) for r in rows)
|
| 44 |
+
if ncol == 1:
|
| 45 |
+
y = np.array([r[0] for r in rows if len(r) == 1], float)
|
| 46 |
+
else:
|
| 47 |
+
y = np.array([r[-1] for r in rows if len(r) == ncol], float)
|
| 48 |
+
y = y[np.isfinite(y)]
|
| 49 |
+
if len(y) < 14:
|
| 50 |
+
return None, f"Only {len(y)} values β I need at least 14 to tell change from noise."
|
| 51 |
+
return y, None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ------------------------------------------------------------------ core
|
| 55 |
+
def _wobble(y):
|
| 56 |
+
"""Typical day-to-day wobble: robust std (MAD) of first differences."""
|
| 57 |
+
d = np.diff(y)
|
| 58 |
+
mad = np.median(np.abs(d - np.median(d)))
|
| 59 |
+
return float(1.4826 * mad + 1e-9)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class _Model:
|
| 63 |
+
def __init__(self, y, window, scale):
|
| 64 |
+
self.y, self.window, self.scale = y, window, scale
|
| 65 |
+
self.t = 0
|
| 66 |
+
self.a, self.b, self.origin = 0.0, float(y[0]), 0
|
| 67 |
+
self.last_resid = 1.0
|
| 68 |
+
|
| 69 |
+
def predict(self, t):
|
| 70 |
+
return self.a * (t - self.origin) + self.b
|
| 71 |
+
|
| 72 |
+
def cheap(self, _):
|
| 73 |
+
return self.predict(self.t)
|
| 74 |
+
|
| 75 |
+
def costly(self, _):
|
| 76 |
+
lo = max(0, self.t - self.window)
|
| 77 |
+
xs = np.arange(lo, self.t + 1)
|
| 78 |
+
ys = self.y[lo:self.t + 1]
|
| 79 |
+
if len(xs) >= 2:
|
| 80 |
+
a, b = np.polyfit(xs - lo, ys, 1)
|
| 81 |
+
self.a, self.b, self.origin = float(a), float(b), lo
|
| 82 |
+
insample = float(np.mean(np.abs(np.polyval([self.a, self.b], xs - lo) - ys))) if len(xs) else 0.0
|
| 83 |
+
return self.predict(self.t), (insample / self.scale) < 1.2
|
| 84 |
+
|
| 85 |
+
def err(self, _):
|
| 86 |
+
return self.last_resid
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def detect(y, sensitivity=1.0, sigma_mode="iid"):
|
| 90 |
+
"""Run the clutch over y. Returns dict with trips, events, checks, window, scale."""
|
| 91 |
+
n = len(y)
|
| 92 |
+
window = int(np.clip(n // 10, 7, 30))
|
| 93 |
+
scale = _wobble(y) # day-to-day wobble (for the human text)
|
| 94 |
+
# iid: noise around a trend -> one-step noise is wobble/sqrt(2)
|
| 95 |
+
# walk: random-walk-like (stock prices) -> the daily move IS the innovation
|
| 96 |
+
sigma = scale / np.sqrt(2.0) if sigma_mode == "iid" else scale
|
| 97 |
+
# sensitivity 0.5 (paranoid) .. 2.0 (relaxed): scales the trip threshold
|
| 98 |
+
gate = MagnitudeGate(gain=2.0, leak=1.8, trip=8.0 * sensitivity)
|
| 99 |
+
clutch = Clutch(gate)
|
| 100 |
+
m = _Model(y, window, sigma)
|
| 101 |
+
trips, checks = [], 0
|
| 102 |
+
for t in range(n):
|
| 103 |
+
m.t = t
|
| 104 |
+
before = clutch.stats.expensive_calls
|
| 105 |
+
pred, _mode = clutch.step(None, m.cheap, m.costly, m.err)
|
| 106 |
+
if clutch.stats.expensive_calls > before:
|
| 107 |
+
checks += 1
|
| 108 |
+
if t > window: # ignore warm-up
|
| 109 |
+
trips.append(t)
|
| 110 |
+
m.last_resid = abs(pred - y[t]) / sigma
|
| 111 |
+
|
| 112 |
+
# merge trips within `window` of each other into events
|
| 113 |
+
events = []
|
| 114 |
+
for t in trips:
|
| 115 |
+
if events and t - events[-1][-1] <= window:
|
| 116 |
+
events[-1].append(t)
|
| 117 |
+
else:
|
| 118 |
+
events.append([t])
|
| 119 |
+
|
| 120 |
+
out_events = []
|
| 121 |
+
for grp in events:
|
| 122 |
+
at0 = grp[0]
|
| 123 |
+
last = min(grp[-1], at0 + 3 * window)
|
| 124 |
+
lo = max(0, at0 - 2 * window)
|
| 125 |
+
hi = min(n, last + 1 + window)
|
| 126 |
+
# refine: best single step position within the local window
|
| 127 |
+
best_c, best_sse = None, np.inf
|
| 128 |
+
for c in range(lo + 3, hi - 2):
|
| 129 |
+
l, r = y[lo:c], y[c:hi]
|
| 130 |
+
sse = ((l - l.mean()) ** 2).sum() + ((r - r.mean()) ** 2).sum()
|
| 131 |
+
if sse < best_sse:
|
| 132 |
+
best_sse, best_c = sse, c
|
| 133 |
+
cp = best_c if best_c is not None else at0
|
| 134 |
+
before_mean = float(np.mean(y[lo:cp]))
|
| 135 |
+
after_mean = float(np.mean(y[cp:hi]))
|
| 136 |
+
shift = after_mean - before_mean
|
| 137 |
+
kind = "shift" if abs(shift) >= 2.0 * sigma else "blip"
|
| 138 |
+
out_events.append(dict(at=cp, span=(grp[0], last), before=before_mean,
|
| 139 |
+
after=after_mean, shift=shift, kind=kind))
|
| 140 |
+
|
| 141 |
+
# overall slow trend (fits the whole series; never trips the gate, honestly reported)
|
| 142 |
+
xs = np.arange(n)
|
| 143 |
+
slope = float(np.polyfit(xs, y, 1)[0])
|
| 144 |
+
trend_total = slope * n
|
| 145 |
+
trendy = abs(trend_total) > 3.0 * scale and not any(e["kind"] == "shift" for e in out_events)
|
| 146 |
+
|
| 147 |
+
return dict(events=out_events, trips=trips, checks=checks, window=window,
|
| 148 |
+
scale=scale, slope=slope, trend_total=trend_total, trendy=trendy, n=n)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
# ------------------------------------------------------------------ language
|
| 152 |
+
def verdict_text(y, res, unit="", period="day"):
|
| 153 |
+
u = f" {unit}" if unit else ""
|
| 154 |
+
n, scale = res["n"], res["scale"]
|
| 155 |
+
shifts = [e for e in res["events"] if e["kind"] == "shift"]
|
| 156 |
+
blips = [e for e in res["events"] if e["kind"] == "blip"]
|
| 157 |
+
lines = []
|
| 158 |
+
|
| 159 |
+
if not shifts and not res["trendy"]:
|
| 160 |
+
lines.append(f"## π Just noise β nothing actually changed")
|
| 161 |
+
lines.append(f"Across all **{n} {period}s**, your numbers stayed inside their normal "
|
| 162 |
+
f"wobble of about **Β±{scale:.2g}{u}** per {period}. "
|
| 163 |
+
f"Ups and downs smaller than that are not signal β reacting to them is "
|
| 164 |
+
f"reacting to dice rolls.")
|
| 165 |
+
if blips:
|
| 166 |
+
days = ", ".join(f"{period} {e['at']}" for e in blips)
|
| 167 |
+
lines.append(f"There were brief odd readings around **{days}**, but the numbers "
|
| 168 |
+
f"came straight back β one-off blips, not a real change.")
|
| 169 |
+
elif res["trendy"]:
|
| 170 |
+
direction = "upward" if res["slope"] > 0 else "downward"
|
| 171 |
+
lines.append(f"## π No sudden change β but a steady {direction} drift")
|
| 172 |
+
lines.append(f"Nothing jumped, but over the whole {n} {period}s your numbers drifted "
|
| 173 |
+
f"**{res['trend_total']:+.3g}{u}** in total (about {res['slope']:+.3g}{u} "
|
| 174 |
+
f"per {period}). Day-to-day comparisons will feel like noise (wobble "
|
| 175 |
+
f"Β±{scale:.2g}{u}); the drift only shows over weeks. That slow kind of "
|
| 176 |
+
f"change is exactly what people miss.")
|
| 177 |
+
else:
|
| 178 |
+
lines.append(f"## π Yes β something really changed")
|
| 179 |
+
for e in shifts:
|
| 180 |
+
direction = "up" if e["shift"] > 0 else "down"
|
| 181 |
+
times = abs(e["shift"]) / scale
|
| 182 |
+
lines.append(f"- Around **{period} {e['at']}**, your typical level moved "
|
| 183 |
+
f"**{direction} from {e['before']:.3g}{u} to {e['after']:.3g}{u}** "
|
| 184 |
+
f"({e['shift']:+.3g}{u} β about {times:.0f}Γ your normal {period}-to-"
|
| 185 |
+
f"{period} wobble). That is a real shift, not luck.")
|
| 186 |
+
if blips:
|
| 187 |
+
lines.append(f"- ({len(blips)} brief blip(s) also detected that reversed on their "
|
| 188 |
+
f"own β those you can ignore.)")
|
| 189 |
+
|
| 190 |
+
saved = (1 - res["checks"] / n) * 100
|
| 191 |
+
lines.append("")
|
| 192 |
+
lines.append(f"**Your attention, saved:** instead of judging every single {period} "
|
| 193 |
+
f"({n} looks), checking on the **{res['checks']} {period}s flagged above** "
|
| 194 |
+
f"would have caught everything that mattered β **{saved:.0f}% fewer looks, "
|
| 195 |
+
f"zero missed changes** on this data.")
|
| 196 |
+
lines.append("")
|
| 197 |
+
lines.append(f"<small>How it works: a tiny model keeps predicting your next number from "
|
| 198 |
+
f"the recent trend; only when reality breaks the prediction harder than your "
|
| 199 |
+
f"normal wobble (Β±{scale:.2g}{u}) does it flag a change. This is a statistics "
|
| 200 |
+
f"tool, not medical or financial advice.</small>")
|
| 201 |
+
return "\n".join(lines)
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ------------------------------------------------------------------ examples
|
| 205 |
+
def example_series(name, seed=3):
|
| 206 |
+
rng = np.random.default_rng(seed)
|
| 207 |
+
if name.startswith("Weight"):
|
| 208 |
+
n = 90
|
| 209 |
+
y = 84.0 + rng.normal(0, 0.45, n)
|
| 210 |
+
y[52:] -= np.linspace(0, 0.11 * (n - 52), n - 52) # diet bites ~day 52 (~0.8 kg/wk)
|
| 211 |
+
return np.round(y, 1), "kg", "day"
|
| 212 |
+
if name.startswith("Sleep"):
|
| 213 |
+
n = 60
|
| 214 |
+
y = 7.1 + rng.normal(0, 0.55, n) # pure noise: nothing changed
|
| 215 |
+
return np.round(y, 1), "h", "night"
|
| 216 |
+
if name.startswith("Electricity"):
|
| 217 |
+
n = 52
|
| 218 |
+
y = 62 + rng.normal(0, 4.5, n)
|
| 219 |
+
y[30:] += 21 # heater breaks / tariff jumps week 30
|
| 220 |
+
return np.round(y, 1), "β¬", "week"
|
| 221 |
+
# "Spending β slow creep"
|
| 222 |
+
n = 80
|
| 223 |
+
y = 31 + np.linspace(0, 13.0, n) + rng.normal(0, 2.2, n) # lifestyle creep, no jump
|
| 224 |
+
return np.round(y, 2), "β¬", "day"
|
clutch.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
clutch.py β a substrate-agnostic dual-process controller.
|
| 3 |
+
|
| 4 |
+
Distilled from Antti Luode's Loom Navigator. The one reusable idea in that demo:
|
| 5 |
+
run a CHEAP cached policy by default, and only pay for an EXPENSIVE planner when a
|
| 6 |
+
"surprise" signal trips a gate. When calm, latch the expensive result back into the
|
| 7 |
+
cheap cache.
|
| 8 |
+
|
| 9 |
+
This module makes no assumptions about *what* the substrates are. You supply:
|
| 10 |
+
- cheap_step(state) -> next action, from the cached plan (O(1)-ish)
|
| 11 |
+
- expensive_plan(state) -> a fresh plan (may be O(N^2) or worse)
|
| 12 |
+
- error_signal(state) -> scalar in [0, inf): "how wrong was my last prediction?"
|
| 13 |
+
|
| 14 |
+
Two gate strategies are provided, corresponding to two readings of "surprise":
|
| 15 |
+
- MagnitudeGate: leaky integrator of error, trips over a threshold. (the Loom's gate)
|
| 16 |
+
- AcceleratorGate: triggers on the *second difference* of error β the "accelerometer"
|
| 17 |
+
/ jerk reading (Park & Cohen 2025 framing). Faster, noise-sensitive.
|
| 18 |
+
|
| 19 |
+
Nothing here is hyped. It's a clutch: it decides when to spend compute.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from dataclasses import dataclass, field
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class MagnitudeGate:
|
| 26 |
+
"""Leaky integrator of error. Trips when accumulated surprise crosses `trip`."""
|
| 27 |
+
def __init__(self, gain=5.0, leak=0.5, trip=10.0, reset=0.0):
|
| 28 |
+
self.gain, self.leak, self.trip, self.reset = gain, leak, trip, reset
|
| 29 |
+
self.surprise = 0.0
|
| 30 |
+
|
| 31 |
+
def update(self, err):
|
| 32 |
+
self.surprise = max(0.0, self.surprise + self.gain * err - self.leak)
|
| 33 |
+
return self.surprise > self.trip
|
| 34 |
+
|
| 35 |
+
def relax(self, amount=0.5):
|
| 36 |
+
self.surprise = max(0.0, self.surprise - amount)
|
| 37 |
+
|
| 38 |
+
def clear(self):
|
| 39 |
+
self.surprise = self.reset
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class AcceleratorGate:
|
| 43 |
+
"""Second-difference ('jerk') detector. Trips on a sudden change in error.
|
| 44 |
+
|
| 45 |
+
Optional refractory period suppresses re-triggering for `refractory` steps after
|
| 46 |
+
a fire β the biological low-pass that makes a derivative signal usable in noise.
|
| 47 |
+
"""
|
| 48 |
+
def __init__(self, trip=1.5, refractory=0):
|
| 49 |
+
self.trip, self.refractory = trip, refractory
|
| 50 |
+
self.e1 = 0.0 # err at t-1
|
| 51 |
+
self.e2 = 0.0 # err at t-2
|
| 52 |
+
self.cool = 0
|
| 53 |
+
self.surprise = 0.0 # exposed for logging/UI parity with MagnitudeGate
|
| 54 |
+
|
| 55 |
+
def update(self, err):
|
| 56 |
+
accel = err - 2.0 * self.e1 + self.e2 # discrete 2nd derivative
|
| 57 |
+
self.e2, self.e1 = self.e1, err
|
| 58 |
+
self.surprise = abs(accel)
|
| 59 |
+
if self.cool > 0:
|
| 60 |
+
self.cool -= 1
|
| 61 |
+
return False
|
| 62 |
+
if abs(accel) > self.trip:
|
| 63 |
+
self.cool = self.refractory
|
| 64 |
+
return True
|
| 65 |
+
return False
|
| 66 |
+
|
| 67 |
+
def relax(self, amount=0.5):
|
| 68 |
+
pass # derivative gate has no accumulator to bleed
|
| 69 |
+
|
| 70 |
+
def clear(self):
|
| 71 |
+
self.e1 = self.e2 = 0.0
|
| 72 |
+
self.cool = 0
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@dataclass
|
| 76 |
+
class ClutchStats:
|
| 77 |
+
steps: int = 0
|
| 78 |
+
expensive_calls: int = 0 # how many times the planner ran
|
| 79 |
+
habitual_steps: int = 0
|
| 80 |
+
cognitive_steps: int = 0
|
| 81 |
+
trips: int = 0 # gate fired
|
| 82 |
+
history: list = field(default_factory=list) # ('H'|'C') per step
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class Clutch:
|
| 86 |
+
"""The controller. Owns the mode and the gate; delegates the substrates to you."""
|
| 87 |
+
def __init__(self, gate):
|
| 88 |
+
self.gate = gate
|
| 89 |
+
self.mode = "COGNITIVE" # start uncached: must plan first
|
| 90 |
+
self.stats = ClutchStats()
|
| 91 |
+
|
| 92 |
+
def step(self, state, cheap_step, expensive_plan, error_signal,
|
| 93 |
+
latch_when_calm=True):
|
| 94 |
+
"""Advance one tick. Returns (action, mode).
|
| 95 |
+
|
| 96 |
+
cheap_step(state) -> action or None if the cache is exhausted/invalid
|
| 97 |
+
expensive_plan(state) -> (action, calm_bool). calm_bool=True means "I found a
|
| 98 |
+
clean plan, safe to latch back to habit."
|
| 99 |
+
error_signal(state) -> scalar >= 0
|
| 100 |
+
"""
|
| 101 |
+
s = self.stats
|
| 102 |
+
s.steps += 1
|
| 103 |
+
err = error_signal(state)
|
| 104 |
+
tripped = self.gate.update(err)
|
| 105 |
+
if tripped:
|
| 106 |
+
s.trips += 1
|
| 107 |
+
|
| 108 |
+
if self.mode == "HABITUAL":
|
| 109 |
+
action = cheap_step(state)
|
| 110 |
+
if tripped or action is None:
|
| 111 |
+
self.mode = "COGNITIVE" # shed the habit
|
| 112 |
+
else:
|
| 113 |
+
self.gate.relax()
|
| 114 |
+
s.habitual_steps += 1
|
| 115 |
+
s.history.append("H")
|
| 116 |
+
return action, "HABITUAL"
|
| 117 |
+
|
| 118 |
+
# COGNITIVE
|
| 119 |
+
action, calm = expensive_plan(state)
|
| 120 |
+
s.expensive_calls += 1
|
| 121 |
+
s.cognitive_steps += 1
|
| 122 |
+
s.history.append("C")
|
| 123 |
+
if latch_when_calm and calm:
|
| 124 |
+
self.gate.clear()
|
| 125 |
+
self.mode = "HABITUAL" # latch the fresh plan
|
| 126 |
+
return action, "COGNITIVE"
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
class FilteredAcceleratorGate(AcceleratorGate):
|
| 130 |
+
"""Accelerometer gate with an EMA low-pass on the error before differentiating.
|
| 131 |
+
The biological analogue: dendritic integration time-constant smoothing the jerk signal.
|
| 132 |
+
"""
|
| 133 |
+
def __init__(self, trip=1.5, refractory=0, alpha=0.4):
|
| 134 |
+
super().__init__(trip=trip, refractory=refractory)
|
| 135 |
+
self.alpha = alpha
|
| 136 |
+
self.filt = 0.0
|
| 137 |
+
|
| 138 |
+
def update(self, err):
|
| 139 |
+
self.filt = self.alpha * err + (1 - self.alpha) * self.filt
|
| 140 |
+
return super().update(self.filt)
|
| 141 |
+
|
| 142 |
+
def clear(self):
|
| 143 |
+
super().clear(); self.filt = 0.0
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio==6.19.0
|
| 2 |
+
numpy
|
| 3 |
+
matplotlib
|
| 4 |
+
yfinance
|
| 5 |
+
pandas
|
stock.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
stock.py β "Did this stock actually change?" on free Yahoo data (no API key).
|
| 3 |
+
|
| 4 |
+
Finance-native use of the same machinery, honestly matched to how prices behave:
|
| 5 |
+
* SHOCK events β the Clutch's MagnitudeGate fed with |daily return| / typical move.
|
| 6 |
+
A single outsized day trips it instantly; several stressed days in a
|
| 7 |
+
row accumulate and trip it too (which a naive threshold misses).
|
| 8 |
+
* VOLATILITY regime changes β the iid change detector (change.detect) run on rolling
|
| 9 |
+
daily volatility: "a normal day used to be Β±1.1%, now it's Β±2.6%".
|
| 10 |
+
* DRIFT β total return over the window, compared against what pure luck could
|
| 11 |
+
produce (sigma * sqrt(n)), stated plainly.
|
| 12 |
+
|
| 13 |
+
Data: yfinance (Yahoo Finance scrape β free, keyless). News: yfinance's news feed,
|
| 14 |
+
also keyless. Both can rate-limit; failures are reported, never faked.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import time
|
| 18 |
+
import numpy as np
|
| 19 |
+
from clutch import Clutch, MagnitudeGate
|
| 20 |
+
from change import detect
|
| 21 |
+
|
| 22 |
+
# ------------------------------------------------------------------ data (keyless)
|
| 23 |
+
_CACHE = {}
|
| 24 |
+
_TTL = 900 # 15 min
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def fetch_prices(ticker, period="1y"):
|
| 28 |
+
"""Returns (dates, close, currency, err). Cached to be polite to Yahoo."""
|
| 29 |
+
key = (ticker.upper().strip(), period, int(time.time() // _TTL))
|
| 30 |
+
if key in _CACHE:
|
| 31 |
+
return _CACHE[key]
|
| 32 |
+
try:
|
| 33 |
+
import yfinance as yf
|
| 34 |
+
tk = yf.Ticker(ticker.strip())
|
| 35 |
+
hist = tk.history(period=period, interval="1d", auto_adjust=True)
|
| 36 |
+
if hist is None or len(hist) < 40 or "Close" not in hist:
|
| 37 |
+
out = (None, None, "", f"Couldn't get enough daily data for '{ticker}'. "
|
| 38 |
+
"Check the symbol (Yahoo format, e.g. AAPL, NOK, BTC-USD, ^GSPC).")
|
| 39 |
+
else:
|
| 40 |
+
close = hist["Close"].to_numpy(dtype=float)
|
| 41 |
+
dates = [d.strftime("%Y-%m-%d") for d in hist.index]
|
| 42 |
+
cur = ""
|
| 43 |
+
try:
|
| 44 |
+
cur = tk.fast_info.get("currency") or ""
|
| 45 |
+
except Exception:
|
| 46 |
+
pass
|
| 47 |
+
out = (dates, close, cur, None)
|
| 48 |
+
except Exception as e:
|
| 49 |
+
out = (None, None, "", f"Data fetch failed ({type(e).__name__}). Yahoo sometimes "
|
| 50 |
+
"rate-limits shared servers β wait a minute and try again.")
|
| 51 |
+
_CACHE[key] = out
|
| 52 |
+
return out
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def fetch_news(ticker, k=6):
|
| 56 |
+
"""Free Yahoo headlines via yfinance; tolerant of old and new item formats."""
|
| 57 |
+
try:
|
| 58 |
+
import yfinance as yf
|
| 59 |
+
raw = yf.Ticker(ticker.strip()).news or []
|
| 60 |
+
except Exception:
|
| 61 |
+
return []
|
| 62 |
+
return parse_news(raw, k)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def parse_news(raw, k=6):
|
| 66 |
+
items = []
|
| 67 |
+
for it in raw[: k * 2]:
|
| 68 |
+
c = it.get("content", it) if isinstance(it, dict) else {}
|
| 69 |
+
title = c.get("title") or it.get("title")
|
| 70 |
+
if not title:
|
| 71 |
+
continue
|
| 72 |
+
url = ""
|
| 73 |
+
cu = c.get("canonicalUrl") or c.get("clickThroughUrl") or {}
|
| 74 |
+
if isinstance(cu, dict):
|
| 75 |
+
url = cu.get("url", "")
|
| 76 |
+
url = url or it.get("link", "")
|
| 77 |
+
prov = c.get("provider") or {}
|
| 78 |
+
publisher = (prov.get("displayName") if isinstance(prov, dict) else None) \
|
| 79 |
+
or it.get("publisher", "")
|
| 80 |
+
when = c.get("pubDate") or c.get("displayTime") or ""
|
| 81 |
+
if not when and it.get("providerPublishTime"):
|
| 82 |
+
when = time.strftime("%Y-%m-%d", time.gmtime(it["providerPublishTime"]))
|
| 83 |
+
when = str(when)[:10]
|
| 84 |
+
items.append(dict(title=title.strip(), url=url, publisher=publisher, when=when))
|
| 85 |
+
if len(items) >= k:
|
| 86 |
+
break
|
| 87 |
+
return items
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# ------------------------------------------------------------------ analysis
|
| 91 |
+
def robust_sigma(x):
|
| 92 |
+
return float(1.4826 * np.median(np.abs(x - np.median(x))) + 1e-12)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def local_sigma(rets, win=60, warm=20):
|
| 96 |
+
"""Past-only rolling robust sigma, so a rough regime stops spamming shock flags
|
| 97 |
+
but a fresh crash (judged against the calm past) still screams."""
|
| 98 |
+
g = robust_sigma(rets)
|
| 99 |
+
out = np.full(len(rets), g)
|
| 100 |
+
for i in range(warm, len(rets)):
|
| 101 |
+
out[i] = max(robust_sigma(rets[max(0, i - win):i]), 0.4 * g)
|
| 102 |
+
return out
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def shock_events(rets, sensitivity=1.0):
|
| 106 |
+
"""Clutch gate on |return| / local typical move. A single outsized day trips it
|
| 107 |
+
instantly; several stressed days in a row accumulate and trip it too.
|
| 108 |
+
Returns (events, sig_global, calm_days)."""
|
| 109 |
+
sig = robust_sigma(rets)
|
| 110 |
+
loc = local_sigma(rets)
|
| 111 |
+
gate = MagnitudeGate(gain=3.0, leak=3.2, trip=8.0 * sensitivity)
|
| 112 |
+
trips = []
|
| 113 |
+
for i, r in enumerate(rets):
|
| 114 |
+
if gate.update(abs(r) / loc[i]):
|
| 115 |
+
trips.append(i)
|
| 116 |
+
gate.clear()
|
| 117 |
+
events = []
|
| 118 |
+
for i in trips:
|
| 119 |
+
if events and i - events[-1][-1] <= 3:
|
| 120 |
+
events[-1].append(i)
|
| 121 |
+
else:
|
| 122 |
+
events.append([i])
|
| 123 |
+
out = []
|
| 124 |
+
for grp in events:
|
| 125 |
+
i0 = max(0, grp[0] - 2)
|
| 126 |
+
i1 = min(len(rets) - 1, grp[-1])
|
| 127 |
+
cum = float(np.sum(rets[i0:i1 + 1]))
|
| 128 |
+
peak_i = i0 + int(np.argmax(np.abs(rets[i0:i1 + 1])))
|
| 129 |
+
peak = float(np.abs(rets[peak_i]) / loc[peak_i])
|
| 130 |
+
biggest = float(rets[peak_i])
|
| 131 |
+
out.append(dict(i0=i0, i1=i1, cum_ret=cum, peak_z=peak, biggest=biggest))
|
| 132 |
+
calm = len(rets) - sum(e["i1"] - e["i0"] + 1 for e in out)
|
| 133 |
+
return out, sig, calm
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def vol_regimes(rets, sensitivity=1.0, win=15):
|
| 137 |
+
"""Non-overlapping window vols + strongest-split ratio test (near-independent
|
| 138 |
+
samples, unlike a rolling window). Reports at most one regime change β the
|
| 139 |
+
strongest β per period. Returns [] or [dict(at, before, after, ratio)]."""
|
| 140 |
+
n = len(rets)
|
| 141 |
+
m = n // win
|
| 142 |
+
if m < 8:
|
| 143 |
+
return []
|
| 144 |
+
v = np.array([robust_sigma(rets[k * win:(k + 1) * win]) for k in range(m)])
|
| 145 |
+
best = None
|
| 146 |
+
for k in range(3, m - 2):
|
| 147 |
+
before, after = float(np.median(v[:k])), float(np.median(v[k:]))
|
| 148 |
+
ratio = after / (before + 1e-12)
|
| 149 |
+
score = max(ratio, 1.0 / ratio)
|
| 150 |
+
if best is None or score > best[0]:
|
| 151 |
+
best = (score, k, before, after, ratio)
|
| 152 |
+
score, k, before, after, ratio = best
|
| 153 |
+
thresh = 1.0 + 0.75 * sensitivity # sens 1 -> 1.75x; strict 1.5 -> ~2.1x; eager 0.7 -> ~1.5x
|
| 154 |
+
if score < thresh:
|
| 155 |
+
return []
|
| 156 |
+
# refine the change day: best day-level split within +/-2 windows of the coarse one
|
| 157 |
+
c0 = k * win
|
| 158 |
+
best_c, best_dev = c0, 0.0
|
| 159 |
+
for c in range(max(30, c0 - 2 * win), min(n - 30, c0 + 2 * win)):
|
| 160 |
+
b = robust_sigma(rets[max(0, c - 60):c])
|
| 161 |
+
a = robust_sigma(rets[c:c + 60])
|
| 162 |
+
dev = abs(np.log(a / (b + 1e-12) + 1e-12))
|
| 163 |
+
if dev > best_dev:
|
| 164 |
+
best_dev, best_c = dev, c
|
| 165 |
+
before = robust_sigma(rets[max(0, best_c - 60):best_c])
|
| 166 |
+
after = robust_sigma(rets[best_c:best_c + 60])
|
| 167 |
+
return [dict(at=best_c, before=before, after=after, ratio=after / (before + 1e-12))]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def analyze(dates, close, sensitivity=1.0):
|
| 171 |
+
logp = np.log(np.asarray(close, float))
|
| 172 |
+
rets = np.diff(logp)
|
| 173 |
+
shocks, sig_d, calm = shock_events(rets, sensitivity)
|
| 174 |
+
vols = vol_regimes(rets, sensitivity)
|
| 175 |
+
total = float(logp[-1] - logp[0])
|
| 176 |
+
luck2 = 2.0 * sig_d * np.sqrt(len(rets)) # 2-sigma of pure-luck drift
|
| 177 |
+
return dict(rets=rets, sig_d=sig_d, shocks=shocks, vols=vols, calm=calm,
|
| 178 |
+
total=total, luck2=luck2, n=len(rets))
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ------------------------------------------------------------------ language
|
| 182 |
+
def pct(x):
|
| 183 |
+
return f"{(np.exp(x) - 1) * 100:+.1f}%"
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def verdict_md(ticker, dates, close, a, currency=""):
|
| 187 |
+
n = a["n"]
|
| 188 |
+
d0, d1 = dates[0], dates[-1]
|
| 189 |
+
sig_pct = (np.exp(a["sig_d"]) - 1) * 100
|
| 190 |
+
lines = []
|
| 191 |
+
if not a["shocks"] and not a["vols"]:
|
| 192 |
+
lines.append(f"## π {ticker}: a quiet stretch β ordinary wobble only")
|
| 193 |
+
lines.append(f"Across **{n} trading days** ({d0} β {d1}), no day or cluster of days "
|
| 194 |
+
f"broke out of this stock's normal movement (a typical day here is about "
|
| 195 |
+
f"**Β±{sig_pct:.1f}%**). Every scary-looking dip in this window was "
|
| 196 |
+
f"within what dice would produce.")
|
| 197 |
+
else:
|
| 198 |
+
k = len(a["shocks"]) + len(a["vols"])
|
| 199 |
+
lines.append(f"## π {ticker}: {k} real event{'s' if k != 1 else ''} in this window")
|
| 200 |
+
ranked = sorted(a["shocks"],
|
| 201 |
+
key=lambda e: max(e["peak_z"], abs(e["cum_ret"]) / (a["sig_d"] + 1e-12)),
|
| 202 |
+
reverse=True)
|
| 203 |
+
hidden = max(0, len(ranked) - 5)
|
| 204 |
+
for e in sorted(ranked[:5], key=lambda e: e["i0"]):
|
| 205 |
+
day0, day1 = dates[e["i0"] + 1], dates[e["i1"] + 1]
|
| 206 |
+
span = f"on {day1}" if e["i0"] == e["i1"] else f"over {day0} β {day1}"
|
| 207 |
+
if abs(e["cum_ret"]) < 0.5 * abs(e["biggest"]):
|
| 208 |
+
lines.append(f"- **Violent swings {span}** that largely cancelled out "
|
| 209 |
+
f"(net {pct(e['cum_ret'])}, sharpest single day {pct(e['biggest'])}, "
|
| 210 |
+
f"about {e['peak_z']:.0f}Γ normal). Something happened there even "
|
| 211 |
+
f"though the price ended near where it started.")
|
| 212 |
+
else:
|
| 213 |
+
direction = "down" if e["cum_ret"] < 0 else "up"
|
| 214 |
+
lines.append(f"- **Shock {span}**: moved **{direction} {pct(e['cum_ret'])}** "
|
| 215 |
+
f"(sharpest day about {e['peak_z']:.0f}Γ a normal day). That is a "
|
| 216 |
+
f"real event, not wobble β worth knowing *why* (headlines below).")
|
| 217 |
+
if hidden:
|
| 218 |
+
lines.append(f"- (+ {hidden} smaller flare-up{'s' if hidden > 1 else ''} not "
|
| 219 |
+
f"listed β nothing above {ranked[5]['peak_z']:.0f}Γ normal.)")
|
| 220 |
+
for v in a["vols"]:
|
| 221 |
+
day = dates[min(v["at"] + 1, len(dates) - 1)]
|
| 222 |
+
b = (np.exp(v["before"]) - 1) * 100
|
| 223 |
+
af = (np.exp(v["after"]) - 1) * 100
|
| 224 |
+
word = "rougher" if af > b else "calmer"
|
| 225 |
+
lines.append(f"- **The ride got {word} around {day}**: a typical day went from "
|
| 226 |
+
f"about Β±{b:.1f}% to Β±{af:.1f}%. Same stock, different weather.")
|
| 227 |
+
# drift vs luck β the part people get wrong most
|
| 228 |
+
tot, luck = a["total"], a["luck2"]
|
| 229 |
+
lines.append("")
|
| 230 |
+
if abs(tot) > luck:
|
| 231 |
+
lines.append(f"**The drift is real too:** {pct(tot)} over the period β more than the "
|
| 232 |
+
f"Β±{(np.exp(luck)-1)*100:.0f}% that pure day-to-day luck could plausibly "
|
| 233 |
+
f"produce over {n} days.")
|
| 234 |
+
else:
|
| 235 |
+
lines.append(f"**About the overall {pct(tot)} move:** over {n} days, pure luck at this "
|
| 236 |
+
f"stock's wobble could produce anything within about "
|
| 237 |
+
f"Β±{(np.exp(luck)-1)*100:.0f}%. So the period's drift, by itself, is "
|
| 238 |
+
f"**not distinguishable from chance** β an honest thing almost no chart "
|
| 239 |
+
f"commentary will tell you.")
|
| 240 |
+
lines.append("")
|
| 241 |
+
lines.append(f"**Your attention, saved:** {a['calm']} of {n} days were inside the normal "
|
| 242 |
+
f"band β days when checking the chart could tell you nothing.")
|
| 243 |
+
lines.append("")
|
| 244 |
+
lines.append("<small>This describes what already happened in free Yahoo data; it predicts "
|
| 245 |
+
"nothing and is not investment advice or a recommendation to buy or sell "
|
| 246 |
+
"anything. Past shocks say nothing about future ones.</small>")
|
| 247 |
+
return "\n".join(lines)
|