Aluode commited on
Commit
3151dff
Β·
verified Β·
1 Parent(s): 3425ac5

Upload 6 files

Browse files
Files changed (6) hide show
  1. README.md +52 -6
  2. app.py +134 -0
  3. change.py +224 -0
  4. clutch.py +143 -0
  5. requirements.txt +5 -0
  6. stock.py +247 -0
README.md CHANGED
@@ -1,15 +1,61 @@
1
  ---
2
- title: StockCheck
3
  emoji: πŸ“‰
4
- colorFrom: purple
5
- colorTo: pink
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: Fable writes code that uses Clutch2 ideas to analyze stocks.
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)