bryceweiner commited on
Commit
5c8ea79
·
verified ·
1 Parent(s): 570df5a

release 7 (a744da0941)

Browse files
CHANGELOG.md CHANGED
@@ -3,6 +3,46 @@
3
  Releases are identified by date. The Docker distribution (branch `distribution`, Hugging Face `bryceweiner/fly-trader`)
4
  ships signed releases built from this development line.
5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  ## 2026-10-18, release 6: A restart never holds a sale back, and Kalshi orders go first
7
 
8
  Published to Hugging Face as signed release 6. Fly #157 and selector #154 are unchanged, and so are the definitions they
 
3
  Releases are identified by date. The Docker distribution (branch `distribution`, Hugging Face `bryceweiner/fly-trader`)
4
  ships signed releases built from this development line.
5
 
6
+ ## 2026-10-18, release 7: A live buy costs what its paper twin does, and the fly learns again
7
+
8
+ Published to Hugging Face as signed release 7. Fly #157 and selector #154 are unchanged, and so are the definitions they
9
+ were judged on: no model, seed or infrastructure file changes.
10
+
11
+ ### Upgrading
12
+
13
+ - **Nothing to do on a Docker install:** its updater installs this release within the hour, as usual.
14
+ - **A server on the host updater** (`deploy/fly_update.py`) holds this code release for 24 hours, or applies it at once
15
+ with `fly_update.py approve 7`.
16
+ - **New columns are added on start:** `deposit_sol` and `deposit_refunded` on `positions`.
17
+ - **If your fly's learning froze** (the console's model page says learning is frozen), lift it with Resume learning once
18
+ this release runs.
19
+
20
+ ### Live money
21
+
22
+ - **A live buy costs what its paper twin does.**
23
+ - The ~0.0015 SOL that a first buy of a token pays to open its token account is a deposit. The account's close
24
+ returns it.
25
+ - It was booked as part of the buy, so live buys looked 1–3 % bigger than paper and their entry prices 1–3 % higher.
26
+ - The deposit is now kept apart from the buy and counted in live wealth until the close returns it. Only the close's
27
+ fee is the position's.
28
+
29
+ ### The fly
30
+
31
+ - **It learns again, and one strategy's trouble no longer stops the other's learning.**
32
+ - On 2026-10-13 the rollback checks froze all of the fly's learning after rolling ev back three times in three days.
33
+ - ev cannot learn (its learning rate is 0), so there was nothing to roll back. The IC check had tripped only because
34
+ the selector's own ev ranking had a bad day (IC −0.10 to −0.13).
35
+ - Capitulation, which was learning, stopped too. Five days of decay then erased what it had learned, and the
36
+ re-bootstrap the freeze asked for never ran.
37
+ - The checks now judge only what learning did. A strategy that has learned nothing trips none. The IC check compares
38
+ the fly with its frozen copy on the same rows instead of with zero, and the replays count it the same way.
39
+ - Reaching the rollback limit freezes that strategy's learning only.
40
+ - **One fly's operator commands reach it.**
41
+ - The Solana and Robinhood Chain books share one memory. Before, whichever book's minute ran first took a pause,
42
+ resume or rollback, and a resume could reach only the book that did not need it.
43
+ - The Robinhood Chain book also kept learning into the shared memory while the Solana book was frozen.
44
+ - Only the Solana book takes these commands now, and the Robinhood Chain book's learning follows its pause and freezes.
45
+
46
  ## 2026-10-18, release 6: A restart never holds a sale back, and Kalshi orders go first
47
 
48
  Published to Hugging Face as signed release 6. Fly #157 and selector #154 are unchanged, and so are the definitions they
fly_trader/agent/fly_live.py CHANGED
@@ -22,6 +22,7 @@ The paper book ``paper_fly`` keeps running as the mirror. Only after the handove
22
  live trades, the mean return per trade trailing the paper mirror's same decisions by more than ``GAP_MAX`` pauses
23
  entries and raises an alert;
24
  - between minutes (``idle``), never in front of an order: the wealth mark — wallet SOL + open positions net of exit cost
 
25
  → ``wealth_marks`` (book 'live'), read once the worker has nothing queued or in flight (a balance read beside a
26
  booking counts that trade twice or not at all) and not just before a boundary; the kill switch on its own peak; and
27
  every ``SWEEP_S``, mid-minute, the written-off tokens are swept (what they fetch is booked onto their dead-bag).
@@ -251,17 +252,17 @@ class LiveMirror:
251
  if mark is None:
252
  return None
253
  rebase = rails.kill_rebase_at(conn)
254
- lamports, gross_v, exit_cost, exposure, n_open = mark
255
- sol_now = lamports / config.LAMPORTS_PER_SOL; wealth = sol_now + gross_v - exit_cost
256
  pk = conn.execute("SELECT max(wealth) AS pk FROM wealth_marks WHERE book = %s AND (%s::timestamptz IS NULL OR ts > %s)",
257
  (BOOK, rebase, rebase)).fetchone(); peak = max(float(pk["pk"] or 0.0), wealth)
258
  conn.execute("INSERT INTO wealth_marks (beat_id, book, ts, sol_free, positions_value, exit_cost, wealth, peak, drawdown, exposure, n_open) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
259
  "ON CONFLICT (beat_id, book) DO NOTHING",
260
- (beat_id, BOOK, m1, sol_now, gross_v - exit_cost, exit_cost, wealth, peak, (1.0 - wealth / peak) if peak > 0 else 0.0, exposure, n_open))
261
  if config.VAULT_ENABLED: # deposits, withdrawals and payouts are not performance
262
  from ..vault import nav as vault_nav, payout as vault_payout # trading wallet + treasury are one book: a top-up or sweep is no loss
263
- vault_nav.mark(conn, m1, lamports + vault_payout.cached_balance(), int(round(gross_v * config.LAMPORTS_PER_SOL)),
264
- int(round(exit_cost * config.LAMPORTS_PER_SOL)))
265
  ix = vault_nav.status(conn, since=rebase)
266
  killed = rails.check_drawdown(conn, ix["value"], ix["peak"], unit="index")
267
  else:
@@ -271,7 +272,8 @@ class LiveMirror:
271
  return self.last_mark
272
 
273
  def _mark(self, conn, ctx) -> tuple | None:
274
- """(wallet lamports, open positions' gross value, their exit cost, exposure at cost, open positions), read while no
 
275
  swap is between its fill and its booking (the worker's ``busy``): a balance read beside a booking in progress
276
  counts that trade twice or not at all. None when the worker stayed busy ``MARK_WAIT_S``."""
277
  if not self.worker.busy.acquire(timeout=MARK_WAIT_S):
@@ -288,7 +290,7 @@ class LiveMirror:
288
  gross = int(p["qty"]) / 10 ** int(p.get("decimals") or 6) * px
289
  gross_v += gross; exposure += float(p["cost_sol"])
290
  exit_cost += gross * exit_cost_fraction(gross, ctx.resqs.get(p["mint"]) or ctx.last_resq(p["mint"]), ctx.mcap(p["mint"], px), p.get("program_label"), ctx.fee(p["mint"]))
291
- return lamports, gross_v, exit_cost, exposure, len(opens)
292
  finally:
293
  self.worker.busy.release()
294
 
 
22
  live trades, the mean return per trade trailing the paper mirror's same decisions by more than ``GAP_MAX`` pauses
23
  entries and raises an alert;
24
  - between minutes (``idle``), never in front of an order: the wealth mark — wallet SOL + open positions net of exit cost
25
+ + the token accounts' deposits not yet returned (a first buy's rent, ledger.deposits_held)
26
  → ``wealth_marks`` (book 'live'), read once the worker has nothing queued or in flight (a balance read beside a
27
  booking counts that trade twice or not at all) and not just before a boundary; the kill switch on its own peak; and
28
  every ``SWEEP_S``, mid-minute, the written-off tokens are swept (what they fetch is booked onto their dead-bag).
 
252
  if mark is None:
253
  return None
254
  rebase = rails.kill_rebase_at(conn)
255
+ lamports, gross_v, exit_cost, exposure, n_open, deposits = mark
256
+ sol_now = lamports / config.LAMPORTS_PER_SOL; wealth = sol_now + deposits + gross_v - exit_cost
257
  pk = conn.execute("SELECT max(wealth) AS pk FROM wealth_marks WHERE book = %s AND (%s::timestamptz IS NULL OR ts > %s)",
258
  (BOOK, rebase, rebase)).fetchone(); peak = max(float(pk["pk"] or 0.0), wealth)
259
  conn.execute("INSERT INTO wealth_marks (beat_id, book, ts, sol_free, positions_value, exit_cost, wealth, peak, drawdown, exposure, n_open) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) "
260
  "ON CONFLICT (beat_id, book) DO NOTHING",
261
+ (beat_id, BOOK, m1, sol_now, deposits + gross_v - exit_cost, exit_cost, wealth, peak, (1.0 - wealth / peak) if peak > 0 else 0.0, exposure, n_open))
262
  if config.VAULT_ENABLED: # deposits, withdrawals and payouts are not performance
263
  from ..vault import nav as vault_nav, payout as vault_payout # trading wallet + treasury are one book: a top-up or sweep is no loss
264
+ vault_nav.mark(conn, m1, lamports + int(round(deposits * config.LAMPORTS_PER_SOL)) + vault_payout.cached_balance(),
265
+ int(round(gross_v * config.LAMPORTS_PER_SOL)), int(round(exit_cost * config.LAMPORTS_PER_SOL)))
266
  ix = vault_nav.status(conn, since=rebase)
267
  killed = rails.check_drawdown(conn, ix["value"], ix["peak"], unit="index")
268
  else:
 
272
  return self.last_mark
273
 
274
  def _mark(self, conn, ctx) -> tuple | None:
275
+ """(wallet lamports, open positions' gross value, their exit cost, exposure at cost, open positions, the token
276
+ accounts' deposits held in SOL), read while no
277
  swap is between its fill and its booking (the worker's ``busy``): a balance read beside a booking in progress
278
  counts that trade twice or not at all. None when the worker stayed busy ``MARK_WAIT_S``."""
279
  if not self.worker.busy.acquire(timeout=MARK_WAIT_S):
 
290
  gross = int(p["qty"]) / 10 ** int(p.get("decimals") or 6) * px
291
  gross_v += gross; exposure += float(p["cost_sol"])
292
  exit_cost += gross * exit_cost_fraction(gross, ctx.resqs.get(p["mint"]) or ctx.last_resq(p["mint"]), ctx.mcap(p["mint"], px), p.get("program_label"), ctx.fee(p["mint"]))
293
+ return lamports, gross_v, exit_cost, exposure, len(opens), ledger.deposits_held(conn, BOOK)
294
  finally:
295
  self.worker.busy.release()
296
 
fly_trader/agent/fly_session.py CHANGED
@@ -21,9 +21,10 @@ first (CLAUDE.md): ``on_minute`` runs before any other book's minute, ``after_mi
21
  (the bootstrap, D = 0) from their scores of the minutes resolved in the last seven days (``train/fly_calibrate.py``).
22
 
23
  Hourly: learning statistics (``fly_updates``), a snapshot of the plastic state (kind 'fly_plastic'; hourly for 7 days,
24
- then one per day), the rollback checks (``train/fly_governance.py``; a rollback restores the newest good snapshot at
25
- least a day old, or D = 0, pauses the checks a day, and the third within 7 days freezes learning and requests a
26
- re-bootstrap), and the handover (``HANDOVER_DAYS`` of racing, at least ``HANDOVER_MIN_TRADES`` trades, realized P&L
 
27
  at least the selector's). The state file ``data/brain/plastic/state.pt`` is written atomically every minute, before
28
  tags are marked resolved: on restart, pending tags at or before its watermark count as applied.
29
  """
@@ -199,6 +200,7 @@ class FlyBook:
199
  self.lines = {"plastic": dict(own), "frozen": dict(own)}
200
  self.sizing = {"plastic": {k: list(v) for k, v in own_s.items()}, "frozen": {k: list(v) for k, v in own_s.items()}}
201
  self.applied_through = -math.inf; self.learning_frozen = False; self.checks_paused_until = {k: 0.0 for k in self.names}
 
202
  self.race_started_at: float | None = None; self.last_day: int | None = None; self.last_hour: int | None = None
203
  self.rollbacks: dict = {k: [] for k in self.names}; self.nu_set = False; self.hour_stats = self._empty_stats(); self.last_checks: dict = {}
204
  s = self._read_state()
@@ -213,6 +215,7 @@ class FlyBook:
213
  self.nu_set = True
214
  self.lines, self.sizing = s["lines"], s["sizing"]
215
  self.applied_through, self.learning_frozen, self.checks_paused_until = s["applied_through"], s["learning_frozen"], s["checks_paused_until"]
 
216
  self.race_started_at, self.last_day, self.last_hour, self.rollbacks = s["race_started_at"], s["last_day"], s["last_hour"], s["rollbacks"]
217
  elif s:
218
  self.race_started_at = s.get("race_started_at") # a new bootstrap keeps racing the same book
@@ -309,7 +312,7 @@ class FlyBook:
309
 
310
  def _state(self) -> dict:
311
  return {"bootstrap_id": self.boot_id, "cfg": self.cfg, "score": config.FLY_SCORE, "bank": self.bank.state() if self.brain is None else None, "lines": self.lines, "sizing": self.sizing,
312
- "applied_through": self.applied_through, "learning_frozen": self.learning_frozen, "checks_paused_until": self.checks_paused_until,
313
  "race_started_at": self.race_started_at, "last_day": self.last_day, "last_hour": self.last_hour, "rollbacks": self.rollbacks,
314
  "saved_at": time.time()}
315
 
@@ -453,7 +456,9 @@ class FlyBook:
453
  out = {"minute": ctx.m1.isoformat(), "eligible": len(ctx.mints), "picks": ph["picks"], "lines": self.lines["plastic"], "frozen_lines": self.lines["frozen"],
454
  "line": self.lines["plastic"][self.names[0]], "frozen_line": self.lines["frozen"][self.names[0]],
455
  "drift": max(drift.values()) if drift else 0.0, "drift_by_strategy": drift, "pending": len(self.pending), "learned": learned,
456
- "learning_frozen": self.learning_frozen, "channels": self.cfg, "holds_min": {k: v / 60.0 for k, v in self.holds.items()}, "bootstrap": self.boot_id,
 
 
457
  "device": str(self.fly.net.dev)}
458
  if st is not None:
459
  out.update({k: v for k, v in st.items() if k != "entries"}); out["stage"] = "trading"
@@ -613,10 +618,17 @@ class FlyBook:
613
  return stats
614
 
615
  def _teach(self, tags, r: np.ndarray, now: float, pathway: str) -> dict | None:
616
- """One update of ``pathway`` (None while learning is frozen: the memory only decays)."""
617
- if self.learning_frozen:
 
 
 
 
 
618
  self.bank.decay_to(now)
619
  return None
 
 
620
  return self.bank.update(tags, torch.tensor(r, dtype=torch.float32), now, pathway=pathway)
621
 
622
  def _teach_fill(self, tags, res: np.ndarray, now: float, stats: dict) -> None:
@@ -839,7 +851,7 @@ class FlyBook:
839
  H = self.holds[name]; sc = g("score") - np.nan_to_num(g("fill_score"))
840
  rp = gov.picks_returns(ts, mint, H, sc, g("line"), lab); rf = gov.picks_returns(ts, mint, H, g("frozen_score"), g("frozen_line"), lab)
841
  recent = ts + H + LABEL_LAG_S > now - gov.IC_HOURS * 3600.0
842
- out[name] = gov.check((rp, rf), (sc[recent], lab[recent]), self._drift(j))
843
  else:
844
  out[name] = gov.check(None, None, self._drift(j))
845
  return out
@@ -857,7 +869,7 @@ class FlyBook:
857
  a = np.array([r["fill_score"] for r in rows], dtype=np.float64); b = np.array([r["fill"] for r in rows], dtype=np.float64)
858
  ic = float(np.corrcoef(a, b)[0, 1]) if len(rows) >= 3 and a.std() > 0 and b.std() > 0 else None
859
  out[name] = {"n": len(rows), "ic": ic, "drift": float(self.bank.drift(j, pathway="fill")[0])}
860
- if len(rows) >= FILL_IC_MIN and ic is not None and ic < 0 and not self.learning_frozen:
861
  self.bank.reset_columns(self.bank.fill[j]); out[name]["reset"] = True
862
  record_event("warning", "fly", f"{name} fill pathway back to its bootstrap: its fill predictions ran against the fills (IC {ic:+.2f} over {len(rows)})", out[name])
863
  return out
@@ -879,7 +891,7 @@ class FlyBook:
879
  good = not any(c["triggers"] for c in checks.values())
880
  sid = self._snapshot(conn, now, good, checks)
881
  for name, c in checks.items():
882
- if c["triggers"] and now >= self.checks_paused_until.get(name, 0.0) and not self.learning_frozen:
883
  self._rollback(conn, now, c, sid, name=name)
884
  self._prune(conn, now)
885
  activity.prune(activity_dir(), now - activity.KEEP_S)
@@ -895,7 +907,11 @@ class FlyBook:
895
  (self.run_id, str(path), json.dumps(note, default=str))).fetchone()["id"])
896
 
897
  def _commands(self, conn, now: float) -> None:
898
- """Operator commands from the console (``send_command``): pause / resume learning, roll back now."""
 
 
 
 
899
  r = conn.execute("SELECT value FROM ui_settings WHERE key = %s", (COMMAND_KEY,)).fetchone()
900
  if not r or not r["value"]:
901
  return
@@ -912,7 +928,7 @@ class FlyBook:
912
  if cmd == "pause":
913
  self.learning_frozen = True; record_event("info", "fly", "learning paused by the operator")
914
  elif cmd == "resume":
915
- self.learning_frozen = False; self.rollbacks = {}; record_event("info", "fly", "learning resumed by the operator")
916
  elif cmd == "rollback":
917
  for j, name in enumerate(self.names):
918
  self._rollback(conn, now, {"triggers": ["operator"], "drift": self._drift(j)}, None, count=False, name=name)
@@ -940,10 +956,11 @@ class FlyBook:
940
  (f"{name}: " + ", ".join(checks["triggers"]), json.dumps(checks, default=str), from_sid, target["id"] if target is not None else None))
941
  record_event("warning", "fly", f"{name} channel rolled back ({', '.join(checks['triggers'])})", {"to_snapshot": target["id"] if target is not None else "bootstrap (D = 0)", "checks": checks})
942
  if count and len(self.rollbacks[name]) >= ROLLBACK_LIMIT:
943
- self.learning_frozen = True
944
  from ..train import pipeline
945
  pipeline.request_run(fly=True, reason=f"{name}: {len(self.rollbacks[name])} rollbacks in 7 days")
946
- record_event("error", "fly", f"learning frozen after {len(self.rollbacks[name])} {name} rollbacks in 7 days; re-bootstrap requested", {"rollbacks": self.rollbacks})
 
947
 
948
  def _prune(self, conn, now: float) -> None:
949
  rows = conn.execute("SELECT id, path, ts FROM brain_snapshots WHERE kind = 'fly_plastic' AND ts < %s ORDER BY ts DESC",
 
21
  (the bootstrap, D = 0) from their scores of the minutes resolved in the last seven days (``train/fly_calibrate.py``).
22
 
23
  Hourly: learning statistics (``fly_updates``), a snapshot of the plastic state (kind 'fly_plastic'; hourly for 7 days,
24
+ then one per day), the rollback checks (``train/fly_governance.py``: they judge what learning did, against the frozen
25
+ shadow; a rollback restores the newest good snapshot at least a day old, or D = 0, pauses the checks a day, and the
26
+ third within 7 days freezes that strategy's learning -- not the other strategies' -- and requests a re-bootstrap), and
27
+ the handover (``HANDOVER_DAYS`` of racing, at least ``HANDOVER_MIN_TRADES`` trades, realized P&L
28
  at least the selector's). The state file ``data/brain/plastic/state.pt`` is written atomically every minute, before
29
  tags are marked resolved: on restart, pending tags at or before its watermark count as applied.
30
  """
 
200
  self.lines = {"plastic": dict(own), "frozen": dict(own)}
201
  self.sizing = {"plastic": {k: list(v) for k, v in own_s.items()}, "frozen": {k: list(v) for k, v in own_s.items()}}
202
  self.applied_through = -math.inf; self.learning_frozen = False; self.checks_paused_until = {k: 0.0 for k in self.names}
203
+ self.frozen: list[str] = [] # strategies whose learning the rollback limit froze (learning_frozen: the operator's pause, all)
204
  self.race_started_at: float | None = None; self.last_day: int | None = None; self.last_hour: int | None = None
205
  self.rollbacks: dict = {k: [] for k in self.names}; self.nu_set = False; self.hour_stats = self._empty_stats(); self.last_checks: dict = {}
206
  s = self._read_state()
 
215
  self.nu_set = True
216
  self.lines, self.sizing = s["lines"], s["sizing"]
217
  self.applied_through, self.learning_frozen, self.checks_paused_until = s["applied_through"], s["learning_frozen"], s["checks_paused_until"]
218
+ self.frozen = [k for k in s.get("frozen") or [] if k in self.names]
219
  self.race_started_at, self.last_day, self.last_hour, self.rollbacks = s["race_started_at"], s["last_day"], s["last_hour"], s["rollbacks"]
220
  elif s:
221
  self.race_started_at = s.get("race_started_at") # a new bootstrap keeps racing the same book
 
312
 
313
  def _state(self) -> dict:
314
  return {"bootstrap_id": self.boot_id, "cfg": self.cfg, "score": config.FLY_SCORE, "bank": self.bank.state() if self.brain is None else None, "lines": self.lines, "sizing": self.sizing,
315
+ "applied_through": self.applied_through, "learning_frozen": self.learning_frozen, "frozen": self.frozen, "checks_paused_until": self.checks_paused_until,
316
  "race_started_at": self.race_started_at, "last_day": self.last_day, "last_hour": self.last_hour, "rollbacks": self.rollbacks,
317
  "saved_at": time.time()}
318
 
 
456
  out = {"minute": ctx.m1.isoformat(), "eligible": len(ctx.mints), "picks": ph["picks"], "lines": self.lines["plastic"], "frozen_lines": self.lines["frozen"],
457
  "line": self.lines["plastic"][self.names[0]], "frozen_line": self.lines["frozen"][self.names[0]],
458
  "drift": max(drift.values()) if drift else 0.0, "drift_by_strategy": drift, "pending": len(self.pending), "learned": learned,
459
+ "learning_frozen": (self.brain or self).learning_frozen, "frozen_strategies": (self.brain or self).frozen, "channels": self.cfg,
460
+ "holds_min": {k: v / 60.0 for k, v in self.holds.items()},
461
+ "bootstrap": self.boot_id,
462
  "device": str(self.fly.net.dev)}
463
  if st is not None:
464
  out.update({k: v for k, v in st.items() if k != "entries"}); out["stage"] = "trading"
 
618
  return stats
619
 
620
  def _teach(self, tags, r: np.ndarray, now: float, pathway: str) -> dict | None:
621
+ """One update of ``pathway`` by the tags of strategies that may learn (None when none may: the memory only decays).
622
+ A strategy the rollback limit froze keeps decaying but learns nothing; the operator's pause freezes all. Another
623
+ chain's book teaches the Solana book's memory, so that book's freezes are the ones that hold."""
624
+ owner = self.brain if self.brain is not None else self
625
+ held = [self.names.index(k) for k in owner.frozen if k in self.names]
626
+ keep = ~np.isin(np.asarray(tags.s), held) if held else np.ones(len(tags), bool)
627
+ if owner.learning_frozen or not keep.any():
628
  self.bank.decay_to(now)
629
  return None
630
+ if not keep.all():
631
+ tags, r = tags.subset(keep), np.asarray(r)[keep]
632
  return self.bank.update(tags, torch.tensor(r, dtype=torch.float32), now, pathway=pathway)
633
 
634
  def _teach_fill(self, tags, res: np.ndarray, now: float, stats: dict) -> None:
 
851
  H = self.holds[name]; sc = g("score") - np.nan_to_num(g("fill_score"))
852
  rp = gov.picks_returns(ts, mint, H, sc, g("line"), lab); rf = gov.picks_returns(ts, mint, H, g("frozen_score"), g("frozen_line"), lab)
853
  recent = ts + H + LABEL_LAG_S > now - gov.IC_HOURS * 3600.0
854
+ out[name] = gov.check((rp, rf), (sc[recent], lab[recent]), self._drift(j), ic_frozen_rows=(g("frozen_score")[recent], lab[recent]))
855
  else:
856
  out[name] = gov.check(None, None, self._drift(j))
857
  return out
 
869
  a = np.array([r["fill_score"] for r in rows], dtype=np.float64); b = np.array([r["fill"] for r in rows], dtype=np.float64)
870
  ic = float(np.corrcoef(a, b)[0, 1]) if len(rows) >= 3 and a.std() > 0 and b.std() > 0 else None
871
  out[name] = {"n": len(rows), "ic": ic, "drift": float(self.bank.drift(j, pathway="fill")[0])}
872
+ if len(rows) >= FILL_IC_MIN and ic is not None and ic < 0 and not self.learning_frozen and name not in self.frozen:
873
  self.bank.reset_columns(self.bank.fill[j]); out[name]["reset"] = True
874
  record_event("warning", "fly", f"{name} fill pathway back to its bootstrap: its fill predictions ran against the fills (IC {ic:+.2f} over {len(rows)})", out[name])
875
  return out
 
891
  good = not any(c["triggers"] for c in checks.values())
892
  sid = self._snapshot(conn, now, good, checks)
893
  for name, c in checks.items():
894
+ if c["triggers"] and now >= self.checks_paused_until.get(name, 0.0) and not self.learning_frozen and name not in self.frozen:
895
  self._rollback(conn, now, c, sid, name=name)
896
  self._prune(conn, now)
897
  activity.prune(activity_dir(), now - activity.KEEP_S)
 
907
  (self.run_id, str(path), json.dumps(note, default=str))).fetchone()["id"])
908
 
909
  def _commands(self, conn, now: float) -> None:
910
+ """Operator commands from the console (``send_command``): pause / resume learning, roll back now. They act on the
911
+ memory, which another chain's book shares with the Solana book: only the Solana book takes them (one key: whichever
912
+ book's minute ran first used to take a command, and a resume reached only the book that did not need it)."""
913
+ if self.brain is not None:
914
+ return
915
  r = conn.execute("SELECT value FROM ui_settings WHERE key = %s", (COMMAND_KEY,)).fetchone()
916
  if not r or not r["value"]:
917
  return
 
928
  if cmd == "pause":
929
  self.learning_frozen = True; record_event("info", "fly", "learning paused by the operator")
930
  elif cmd == "resume":
931
+ self.learning_frozen = False; self.frozen = []; self.rollbacks = {}; record_event("info", "fly", "learning resumed by the operator")
932
  elif cmd == "rollback":
933
  for j, name in enumerate(self.names):
934
  self._rollback(conn, now, {"triggers": ["operator"], "drift": self._drift(j)}, None, count=False, name=name)
 
956
  (f"{name}: " + ", ".join(checks["triggers"]), json.dumps(checks, default=str), from_sid, target["id"] if target is not None else None))
957
  record_event("warning", "fly", f"{name} channel rolled back ({', '.join(checks['triggers'])})", {"to_snapshot": target["id"] if target is not None else "bootstrap (D = 0)", "checks": checks})
958
  if count and len(self.rollbacks[name]) >= ROLLBACK_LIMIT:
959
+ self.frozen = sorted(set(self.frozen) | {name}) # this strategy's learning only: the others keep learning
960
  from ..train import pipeline
961
  pipeline.request_run(fly=True, reason=f"{name}: {len(self.rollbacks[name])} rollbacks in 7 days")
962
+ record_event("error", "fly", f"{name} learning frozen after {len(self.rollbacks[name])} rollbacks in 7 days; re-bootstrap requested",
963
+ {"rollbacks": self.rollbacks, "frozen": self.frozen})
964
 
965
  def _prune(self, conn, now: float) -> None:
966
  rows = conn.execute("SELECT id, path, ts FROM brain_snapshots WHERE kind = 'fly_plastic' AND ts < %s ORDER BY ts DESC",
fly_trader/chain/balances.py CHANGED
@@ -19,25 +19,30 @@ class Snapshot:
19
  tokens: dict[str, int]
20
  ts: datetime
21
  decimals: dict[str, int] = field(default_factory=dict)
 
22
 
23
  def token(self, mint: str) -> int:
24
  return int(self.tokens.get(mint, 0))
25
 
26
  def to_json(self) -> dict:
27
  return {"lamports": int(self.lamports), "tokens": {m: int(a) for m, a in self.tokens.items()},
28
- "decimals": {m: int(d) for m, d in self.decimals.items()}, "ts": self.ts.isoformat()}
 
29
 
30
 
31
  def snapshot_balances(rpc, pubkey) -> Snapshot:
32
  lamports = int(rpc.get_balance(str(pubkey)))
33
  tokens: dict[str, int] = {}
34
  decimals: dict[str, int] = {}
 
35
  for acct in rpc.get_token_accounts_by_owner(str(pubkey)):
36
  mint = acct["mint"]
37
  tokens[mint] = tokens.get(mint, 0) + int(acct["amount"])
38
  if acct.get("decimals") is not None:
39
  decimals[mint] = int(acct["decimals"])
40
- return Snapshot(lamports=lamports, tokens=tokens, ts=config.utcnow(), decimals=decimals)
 
 
41
 
42
 
43
  def compute_delta(pre: Snapshot, post: Snapshot) -> dict:
 
19
  tokens: dict[str, int]
20
  ts: datetime
21
  decimals: dict[str, int] = field(default_factory=dict)
22
+ deposits: dict[str, int] = field(default_factory=dict) # lamports held by the wallet's token accounts of each mint (their rent)
23
 
24
  def token(self, mint: str) -> int:
25
  return int(self.tokens.get(mint, 0))
26
 
27
  def to_json(self) -> dict:
28
  return {"lamports": int(self.lamports), "tokens": {m: int(a) for m, a in self.tokens.items()},
29
+ "decimals": {m: int(d) for m, d in self.decimals.items()}, "deposits": {m: int(v) for m, v in self.deposits.items()},
30
+ "ts": self.ts.isoformat()}
31
 
32
 
33
  def snapshot_balances(rpc, pubkey) -> Snapshot:
34
  lamports = int(rpc.get_balance(str(pubkey)))
35
  tokens: dict[str, int] = {}
36
  decimals: dict[str, int] = {}
37
+ deposits: dict[str, int] = {}
38
  for acct in rpc.get_token_accounts_by_owner(str(pubkey)):
39
  mint = acct["mint"]
40
  tokens[mint] = tokens.get(mint, 0) + int(acct["amount"])
41
  if acct.get("decimals") is not None:
42
  decimals[mint] = int(acct["decimals"])
43
+ if not acct.get("is_native"): # a wrapped-SOL account's lamports are SOL, not a deposit
44
+ deposits[mint] = deposits.get(mint, 0) + int(acct.get("lamports") or 0)
45
+ return Snapshot(lamports=lamports, tokens=tokens, ts=config.utcnow(), decimals=decimals, deposits=deposits)
46
 
47
 
48
  def compute_delta(pre: Snapshot, post: Snapshot) -> dict:
fly_trader/db/schema.py CHANGED
@@ -131,6 +131,9 @@ BASE_DDL: list[str] = [
131
  realized_sol double precision, fees_sol double precision NOT NULL DEFAULT 0,
132
  status text NOT NULL DEFAULT 'open', forced_exit_kind text, last_swap_ts timestamptz, satiety double precision NOT NULL DEFAULT 0)""",
133
  "CREATE UNIQUE INDEX IF NOT EXISTS positions_open_uniq ON positions (book, mint) WHERE status = 'open'",
 
 
 
134
  "CREATE INDEX IF NOT EXISTS positions_book_status_idx ON positions (book, status)",
135
  """CREATE TABLE IF NOT EXISTS wealth_marks (
136
  beat_id bigint NOT NULL, book text NOT NULL, ts timestamptz NOT NULL DEFAULT now(),
 
131
  realized_sol double precision, fees_sol double precision NOT NULL DEFAULT 0,
132
  status text NOT NULL DEFAULT 'open', forced_exit_kind text, last_swap_ts timestamptz, satiety double precision NOT NULL DEFAULT 0)""",
133
  "CREATE UNIQUE INDEX IF NOT EXISTS positions_open_uniq ON positions (book, mint) WHERE status = 'open'",
134
+ # a live buy's token-account rent (the account's close returns it): a deposit kept apart from the position's cost
135
+ "ALTER TABLE positions ADD COLUMN IF NOT EXISTS deposit_sol double precision",
136
+ "ALTER TABLE positions ADD COLUMN IF NOT EXISTS deposit_refunded boolean NOT NULL DEFAULT false",
137
  "CREATE INDEX IF NOT EXISTS positions_book_status_idx ON positions (book, status)",
138
  """CREATE TABLE IF NOT EXISTS wealth_marks (
139
  beat_id bigint NOT NULL, book text NOT NULL, ts timestamptz NOT NULL DEFAULT now(),
fly_trader/execution/broker_live.py CHANGED
@@ -45,6 +45,7 @@ from ..signer.policy import EXCLUDE_ROUTERS, PolicyError
45
  from ..db.apilog import record_event
46
  from ..db.connection import connect, transaction
47
  from ..logging_setup import scrub
 
48
 
49
  log = logging.getLogger(__name__)
50
 
@@ -73,9 +74,10 @@ class FillResult:
73
  code: int | None
74
  error: str | None
75
  token_delta: int
76
- lamports_delta: int
77
  price_sol: float | None
78
  attempts: int
 
79
 
80
 
81
  def _int_or_none(v) -> int | None:
@@ -339,6 +341,10 @@ class LiveBroker:
339
  log.warning("fill %s: snapshot deltas (%d, %d) differ from the transaction's (%d, %d); using the transaction",
340
  signature[:12], lam, tok, exact[0], exact[1])
341
  lam, tok = exact
 
 
 
 
342
  # a confirmed sell that moved the tokens is a fill even at lam <= 0 (a rugged token's dust proceeds < fees)
343
  verified = (lam < 0 and tok > 0) if side == "buy" else (tok < 0 and (lam > 0 or chain_status == "confirmed"))
344
  jup_status = (ex or {}).get("status")
@@ -353,7 +359,7 @@ class LiveBroker:
353
  order.get("platformFee"), verified_by, pre, post)
354
  log.info("fill %s: %s %s token_delta=%d lamports_delta=%d price_sol=%s (%s)",
355
  fill_id, side, mint, tok, lam, price, verified_by)
356
- return FillResult(True, order_id, fill_id, signature, chain_status, code, error, tok, lam, price, attempt), False
357
  if jup_status == "Success" or chain_status == "confirmed":
358
  tx_info = None
359
  try:
@@ -506,6 +512,9 @@ def close_empty_atas(rpc=None, signer=None, url: str | None = None, batch: int =
506
  with transaction(url) as conn:
507
  conn.execute("INSERT INTO wallet_events (kind, pubkey, detail) VALUES ('ata_closed', %s, %s)",
508
  (owner_s, Jsonb(detail)))
 
 
 
509
  except Exception as e:
510
  log.warning("wallet_events insert failed: %s", type(e).__name__)
511
  if status == "confirmed":
 
45
  from ..db.apilog import record_event
46
  from ..db.connection import connect, transaction
47
  from ..logging_setup import scrub
48
+ from . import ledger
49
 
50
  log = logging.getLogger(__name__)
51
 
 
74
  code: int | None
75
  error: str | None
76
  token_delta: int
77
+ lamports_delta: int # the trade's SOL: the wallet's change less what went into (or came back from) the mint's token account
78
  price_sol: float | None
79
  attempts: int
80
+ deposit_lamports: int = 0 # SOL the swap put into the mint's token account (its rent, a first buy) or took back out (< 0)
81
 
82
 
83
  def _int_or_none(v) -> int | None:
 
341
  log.warning("fill %s: snapshot deltas (%d, %d) differ from the transaction's (%d, %d); using the transaction",
342
  signature[:12], lam, tok, exact[0], exact[1])
343
  lam, tok = exact
344
+ # the rent a first buy pays to open the mint's token account (~0.0015 SOL) is a deposit the account's close returns,
345
+ # not the trade: the trade's SOL, its price and the position's cost leave it out (it is kept on the position)
346
+ dep = int(post.deposits.get(mint, 0)) - int(pre.deposits.get(mint, 0))
347
+ lam += dep
348
  # a confirmed sell that moved the tokens is a fill even at lam <= 0 (a rugged token's dust proceeds < fees)
349
  verified = (lam < 0 and tok > 0) if side == "buy" else (tok < 0 and (lam > 0 or chain_status == "confirmed"))
350
  jup_status = (ex or {}).get("status")
 
359
  order.get("platformFee"), verified_by, pre, post)
360
  log.info("fill %s: %s %s token_delta=%d lamports_delta=%d price_sol=%s (%s)",
361
  fill_id, side, mint, tok, lam, price, verified_by)
362
+ return FillResult(True, order_id, fill_id, signature, chain_status, code, error, tok, lam, price, attempt, dep), False
363
  if jup_status == "Success" or chain_status == "confirmed":
364
  tx_info = None
365
  try:
 
512
  with transaction(url) as conn:
513
  conn.execute("INSERT INTO wallet_events (kind, pubkey, detail) VALUES ('ata_closed', %s, %s)",
514
  (owner_s, Jsonb(detail)))
515
+ if status == "confirmed": # their rent is back in the wallet: no longer a deposit the books hold
516
+ for m in sorted({a["mint"] for a in chunk}):
517
+ ledger.refund_deposits(conn, "live", m)
518
  except Exception as e:
519
  log.warning("wallet_events insert failed: %s", type(e).__name__)
520
  if status == "confirmed":
fly_trader/execution/ledger.py CHANGED
@@ -67,7 +67,9 @@ def _chain_of(book: str) -> str:
67
 
68
  def open_position(conn, *, book: str, mint: str, pool: str | None, qty_raw: int, cost_sol: float,
69
  entry_price: float, decision_id: int | None, fees_sol: float, ts: datetime | None = None,
70
- hold_s: float | None = None, strategy: str | None = None) -> int:
 
 
71
  ts = ts or datetime.now(timezone.utc)
72
  existing = conn.execute("SELECT id, qty, cost_sol, entry_price FROM positions WHERE book=%s AND mint=%s AND status='open'",
73
  (book, mint)).fetchone()
@@ -75,18 +77,36 @@ def open_position(conn, *, book: str, mint: str, pool: str | None, qty_raw: int,
75
  q0, c0 = int(existing["qty"]), float(existing["cost_sol"])
76
  q1, c1 = q0 + qty_raw, c0 + cost_sol
77
  p1 = (float(existing["entry_price"] or entry_price) * q0 + entry_price * qty_raw) / max(q1, 1)
78
- conn.execute("UPDATE positions SET qty=%s, cost_sol=%s, entry_price=%s, fees_sol=fees_sol+%s, last_mark_price=%s, last_mark_ts=%s WHERE id=%s",
79
- (q1, c1, p1, fees_sol, entry_price, ts, existing["id"]))
 
80
  return int(existing["id"])
81
  row = conn.execute(
82
  """INSERT INTO positions (book, mint, pool, opened_at, entry_decision_id, qty, cost_sol, entry_price, peak_price,
83
- last_mark_price, last_mark_ts, fees_sol, status, last_swap_ts, hold_s, strategy, chain)
84
- VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'open',%s,%s,%s,%s) RETURNING id""",
85
- (book, mint, pool, ts, decision_id, qty_raw, cost_sol, entry_price, entry_price, entry_price, ts, fees_sol, ts, hold_s, strategy, _chain_of(book)),
 
86
  ).fetchone()
87
  return int(row["id"])
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  def realize_partial(conn, *, position_id: int, qty_raw: int, cost_part: float, proceeds_sol: float, fees_sol: float,
91
  price: float, ts: datetime | None = None) -> float | None:
92
  """Shrink an open position by qty_raw / cost_part and book proceeds − cost_part into realized_sol.
 
67
 
68
  def open_position(conn, *, book: str, mint: str, pool: str | None, qty_raw: int, cost_sol: float,
69
  entry_price: float, decision_id: int | None, fees_sol: float, ts: datetime | None = None,
70
+ hold_s: float | None = None, strategy: str | None = None, deposit_sol: float = 0.0) -> int:
71
+ """``deposit_sol``: what the buy put into the mint's token account (its rent, a first live buy), kept apart from
72
+ ``cost_sol``: the account's close returns it (``refund_deposits``)."""
73
  ts = ts or datetime.now(timezone.utc)
74
  existing = conn.execute("SELECT id, qty, cost_sol, entry_price FROM positions WHERE book=%s AND mint=%s AND status='open'",
75
  (book, mint)).fetchone()
 
77
  q0, c0 = int(existing["qty"]), float(existing["cost_sol"])
78
  q1, c1 = q0 + qty_raw, c0 + cost_sol
79
  p1 = (float(existing["entry_price"] or entry_price) * q0 + entry_price * qty_raw) / max(q1, 1)
80
+ conn.execute("UPDATE positions SET qty=%s, cost_sol=%s, entry_price=%s, fees_sol=fees_sol+%s, last_mark_price=%s, last_mark_ts=%s, "
81
+ "deposit_sol = NULLIF(COALESCE(deposit_sol, 0) + %s, 0) WHERE id=%s",
82
+ (q1, c1, p1, fees_sol, entry_price, ts, deposit_sol, existing["id"]))
83
  return int(existing["id"])
84
  row = conn.execute(
85
  """INSERT INTO positions (book, mint, pool, opened_at, entry_decision_id, qty, cost_sol, entry_price, peak_price,
86
+ last_mark_price, last_mark_ts, fees_sol, status, last_swap_ts, hold_s, strategy, chain, deposit_sol)
87
+ VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'open',%s,%s,%s,%s,%s) RETURNING id""",
88
+ (book, mint, pool, ts, decision_id, qty_raw, cost_sol, entry_price, entry_price, entry_price, ts, fees_sol, ts, hold_s, strategy, _chain_of(book),
89
+ deposit_sol or None),
90
  ).fetchone()
91
  return int(row["id"])
92
 
93
 
94
+ def refund_deposits(conn, book: str, mint: str) -> float:
95
+ """The mint's token-account deposits not yet returned (paid by the buy that opened the account, whichever position
96
+ of the mint closes it), marked returned; their total in SOL. 0 when none was booked apart (a position bought before
97
+ deposits were: its cost holds the rent, so the whole refund is its own)."""
98
+ rows = conn.execute("UPDATE positions SET deposit_refunded = true WHERE book = %s AND mint = %s AND COALESCE(deposit_sol, 0) > 0 AND NOT deposit_refunded "
99
+ "RETURNING deposit_sol", (book, mint)).fetchall()
100
+ return sum(float(r["deposit_sol"]) for r in rows)
101
+
102
+
103
+ def deposits_held(conn, book: str) -> float:
104
+ """SOL the book's token accounts hold as deposits (their rent) until their close returns it: part of its wealth."""
105
+ r = conn.execute("SELECT COALESCE(sum(deposit_sol), 0) AS s FROM positions WHERE book = %s AND COALESCE(deposit_sol, 0) > 0 AND NOT deposit_refunded",
106
+ (book,)).fetchone()
107
+ return float(r["s"])
108
+
109
+
110
  def realize_partial(conn, *, position_id: int, qty_raw: int, cost_part: float, proceeds_sol: float, fees_sol: float,
111
  price: float, ts: datetime | None = None) -> float | None:
112
  """Shrink an open position by qty_raw / cost_part and book proceeds − cost_part into realized_sol.
fly_trader/execution/worker.py CHANGED
@@ -163,10 +163,13 @@ class ExecutionWorker:
163
  price = fr.price_sol or (cost / max(fr.token_delta / (10 ** req.decimals), 1e-12))
164
  pid = ledger.open_position(conn, book="live", mint=req.mint, pool=req.pool, qty_raw=fr.token_delta,
165
  cost_sol=cost, entry_price=price, decision_id=req.decision_id,
166
- fees_sol=0.0, ts=ts, hold_s=req.hold_s, strategy=req.strategy)
 
167
  rails.record_notional(conn, cost, "entry")
168
  return ExecResult(req, True, fr.fill_id, fr.signature, fr.token_delta, fr.lamports_delta, price, position_id=pid)
169
  else:
 
 
170
  proceeds = fr.lamports_delta / config.LAMPORTS_PER_SOL
171
  price = fr.price_sol if fr.price_sol is not None else (proceeds / max(abs(fr.token_delta) / (10 ** req.decimals), 1e-12))
172
  if req.forced_kind == "dead_bag_sweep": # a written-off position's tokens: what they fetch is that position's
@@ -208,11 +211,12 @@ class ExecutionWorker:
208
  return not req.retry and req.forced_kind != "dead_bag_sweep"
209
 
210
  def _close_account(self, req: ExecRequest, res: ExecResult) -> None:
211
- """The sale left the live book without this mint: close its empty token account (the rent its first buy paid back
212
- to the wallet: about a point of a 0.15 SOL round trip, read as execution loss by the gap monitor) and book what the
213
- close returned, net of its fee, onto the position the sale closed. Not while a buy is queued behind the sale (it
214
- needs the account) or the wallet still holds the token. A failure here never touches the sale: it is logged and
215
- the account stays for ``close-empty-atas``."""
 
216
  from ..vault import walletlock
217
  with self.lock:
218
  if self.inflight.get(req.mint) != ["sell"]:
@@ -224,8 +228,9 @@ class ExecutionWorker:
224
  with walletlock.maybe_exclusive(config.VAULT_ENABLED):
225
  got = self.broker.close_token_account(req.mint)
226
  if got is not None and res.position_id is not None:
227
- with transaction() as conn:
228
- ledger.credit(conn, res.position_id, got / config.LAMPORTS_PER_SOL)
229
- res.realized_sol = (res.realized_sol or 0.0) + got / config.LAMPORTS_PER_SOL
 
230
  except Exception as e:
231
  log.warning("closing the token account of %s after its sale failed (the sale stands): %s: %s", req.mint, type(e).__name__, e)
 
163
  price = fr.price_sol or (cost / max(fr.token_delta / (10 ** req.decimals), 1e-12))
164
  pid = ledger.open_position(conn, book="live", mint=req.mint, pool=req.pool, qty_raw=fr.token_delta,
165
  cost_sol=cost, entry_price=price, decision_id=req.decision_id,
166
+ fees_sol=0.0, ts=ts, hold_s=req.hold_s, strategy=req.strategy,
167
+ deposit_sol=max(fr.deposit_lamports, 0) / config.LAMPORTS_PER_SOL)
168
  rails.record_notional(conn, cost, "entry")
169
  return ExecResult(req, True, fr.fill_id, fr.signature, fr.token_delta, fr.lamports_delta, price, position_id=pid)
170
  else:
171
+ if fr.deposit_lamports < 0: # the swap closed the token account itself: its deposit is back
172
+ ledger.refund_deposits(conn, "live", req.mint)
173
  proceeds = fr.lamports_delta / config.LAMPORTS_PER_SOL
174
  price = fr.price_sol if fr.price_sol is not None else (proceeds / max(abs(fr.token_delta) / (10 ** req.decimals), 1e-12))
175
  if req.forced_kind == "dead_bag_sweep": # a written-off position's tokens: what they fetch is that position's
 
211
  return not req.retry and req.forced_kind != "dead_bag_sweep"
212
 
213
  def _close_account(self, req: ExecRequest, res: ExecResult) -> None:
214
+ """The sale left the live book without this mint: close its empty token account. The rent its first buy paid comes
215
+ back to the wallet: the deposit booked apart on that buy's position is returned (``ledger.refund_deposits``) and only
216
+ the close's fee is the closing position's; a position bought before deposits were booked apart had the rent in its
217
+ cost, so the whole refund is its own. Not while a buy is queued behind the sale (it needs the account) or the
218
+ wallet still holds the token. A failure here never touches the sale: it is logged and the account stays (and its
219
+ deposit counts in wealth) for ``close-empty-atas``."""
220
  from ..vault import walletlock
221
  with self.lock:
222
  if self.inflight.get(req.mint) != ["sell"]:
 
228
  with walletlock.maybe_exclusive(config.VAULT_ENABLED):
229
  got = self.broker.close_token_account(req.mint)
230
  if got is not None and res.position_id is not None:
231
+ with transaction() as conn: # the account's deposit is back, less the close's fee: only the fee is the position's
232
+ pnl = got / config.LAMPORTS_PER_SOL - ledger.refund_deposits(conn, "live", req.mint)
233
+ ledger.credit(conn, res.position_id, pnl)
234
+ res.realized_sol = (res.realized_sol or 0.0) + pnl
235
  except Exception as e:
236
  log.warning("closing the token account of %s after its sale failed (the sale stands): %s: %s", req.mint, type(e).__name__, e)
fly_trader/kalshi/fly_replay.py CHANGED
@@ -71,8 +71,9 @@ def _governance(ds, order, ts_o, res_o, upto, scores, lines, line_log, bank, now
71
  for c in range(1, lines.shape[0]):
72
  lc = R._line_at(line_log, (c, s_), ts_o[p3], float(lines[c, s_]))
73
  rp = gov.picks_returns(ds.ts[r3], ds.mint[r3], ds.hold_s[r3], np.nan_to_num(scores[c, s_, p3], nan=-np.inf), lc, y[r3])
74
- s1 = scores[c, s_, p1]; has = np.isfinite(s1)
75
- out = gov.check((rp[np.isfinite(rp)], rf[np.isfinite(rf)]), (s1[has], ds.outcome[r1][has]), drift[c])
 
76
  counts[(c, s_)]["checks"] += 1
77
  for t in out["triggers"]:
78
  counts[(c, s_)][t] += 1
 
71
  for c in range(1, lines.shape[0]):
72
  lc = R._line_at(line_log, (c, s_), ts_o[p3], float(lines[c, s_]))
73
  rp = gov.picks_returns(ds.ts[r3], ds.mint[r3], ds.hold_s[r3], np.nan_to_num(scores[c, s_, p3], nan=-np.inf), lc, y[r3])
74
+ s1 = scores[c, s_, p1]; f1 = scores[0, s_, p1]; has = np.isfinite(s1) & np.isfinite(f1)
75
+ out = gov.check((rp[np.isfinite(rp)], rf[np.isfinite(rf)]), (s1[has], ds.outcome[r1][has]), drift[c],
76
+ ic_frozen_rows=(f1[has], ds.outcome[r1][has])) # as live: against the frozen shadow
77
  counts[(c, s_)]["checks"] += 1
78
  for t in out["triggers"]:
79
  counts[(c, s_)][t] += 1
fly_trader/kalshi/fly_session.py CHANGED
@@ -21,8 +21,8 @@ minute's live part, ``after_minute`` the rest, once the live orders are out.
21
  maker label the return of a bid one tick inside the ask when a later minute's ask reached it).
22
 
23
  Hourly: learning statistics (``kalshi_fly_updates``), a snapshot of the plastic state (kind 'kalshi_fly_plastic'), the
24
- rollback checks (train/fly_governance.py; a third rollback in 7 days freezes learning and asks kalshi/pipeline.py for a
25
- re-bootstrap). State: ``data/brain/plastic_kalshi/state.pt``; commands ``ui_settings['kalshi_fly_command']``; status
26
  ``ui_settings['kalshi_fly_status']``; activity for the 3D view in ``data/brain/activity_kalshi/``.
27
  """
28
  from __future__ import annotations
@@ -141,6 +141,7 @@ class KalshiFlyBook:
141
  self.lines = {"plastic": dict(self.fly.lines), "frozen": dict(self.fly.lines)}
142
  self.sizing = {"plastic": {k: list(v) for k, v in self.fly.sizings.items()}, "frozen": {k: list(v) for k, v in self.fly.sizings.items()}}
143
  self.applied_through = -math.inf; self.learning_frozen = False; self.checks_paused_until = {k: 0.0 for k in self.names}
 
144
  self.last_day: int | None = None; self.last_hour: int | None = None
145
  self.rollbacks: dict = {k: [] for k in self.names}; self.nu_set = False; self.hour_stats = self._empty_stats(); self.last_checks: dict = {}
146
  s = self._read_state()
@@ -148,6 +149,7 @@ class KalshiFlyBook:
148
  self.bank.load_state(s["bank"]); self.nu_set = True
149
  self.lines, self.sizing = s["lines"], s["sizing"]
150
  self.applied_through, self.learning_frozen, self.checks_paused_until = s["applied_through"], s["learning_frozen"], s["checks_paused_until"]
 
151
  self.last_day, self.last_hour, self.rollbacks = s["last_day"], s["last_hour"], s["rollbacks"]
152
  elif s:
153
  with transaction() as conn:
@@ -164,7 +166,8 @@ class KalshiFlyBook:
164
 
165
  def _state(self) -> dict:
166
  return {"bootstrap_id": self.boot_id, "cfg": self.cfg, "bank": self.bank.state(), "lines": self.lines, "sizing": self.sizing, "applied_through": self.applied_through,
167
- "learning_frozen": self.learning_frozen, "checks_paused_until": self.checks_paused_until, "last_day": self.last_day, "last_hour": self.last_hour,
 
168
  "rollbacks": self.rollbacks, "saved_at": time.time()}
169
 
170
  def _write_state(self) -> None:
@@ -267,7 +270,7 @@ class KalshiFlyBook:
267
  self.last_day = day; self._calibrate(conn, ctx.m1_epoch)
268
  drift = {k: float(self.bank.drift(j)[0]) if self.bank.learn.shape[0] > 1 else float(self.bank.drift()[0]) for j, k in enumerate(self.names)}
269
  out.update({"lines": self.lines["plastic"], "frozen_lines": self.lines["frozen"], "arms": self.arms, "drift": max(drift.values()) if drift else 0.0,
270
- "drift_by_strategy": drift, "pending": len(self.pending), "learned": learned, "learning_frozen": self.learning_frozen, "channels": self.cfg,
271
  "bootstrap": self.boot_id, "device": str(self.fly.net.dev)})
272
  hour = int(ctx.m1_epoch // 3600)
273
  if self.last_hour is None:
@@ -386,12 +389,13 @@ class KalshiFlyBook:
386
  self.pending.push(ss.keys, ss.ts, now + REARM_S, ss.y_dn, ss.u0, ss.k, ss.w, s=j)
387
  rearmed = [(datetime.fromtimestamp(now + REARM_S, timezone.utc), datetime.fromtimestamp(t, timezone.utc), tk, side, name) for t, tk, side, name in sub.keys]
388
  stats = {"n": int(keep.sum()), "unknown": int(sum(1 for k in out_keys if k[1] == "unknown")), "rearmed": rearmed, "keys": out_keys, "waiting": len(rearm)}
 
 
389
  if keep.any():
390
- tv = tags.subset(keep)
391
- if self.learning_frozen:
392
  self.bank.decay_to(now)
393
  else:
394
- st = self.bank.update(tv, torch.tensor(labels[keep], dtype=torch.float32), now)
395
  stats.update(mean_delta=st["mean_delta"][0], mean_abs_delta=st["mean_abs_delta"][0], step=st["step"][0], capped=bool(st["capped"][0]))
396
  h = self.hour_stats; h["n"] += stats["n"]; h["sum_delta"] += st["mean_delta"][0] * stats["n"]; h["sum_abs"] += st["mean_abs_delta"][0] * stats["n"]
397
  h["step"] += st["step"][0]; h["capped"] += int(st["capped"][0])
@@ -512,7 +516,7 @@ class KalshiFlyBook:
512
  g = lambda c: np.array([r[c] for r in rows], dtype=np.float64)
513
  rp = gov.picks_returns(ts, mint, hold, g("score"), g("line"), lab); rf = gov.picks_returns(ts, mint, hold, g("frozen_score"), g("frozen_line"), lab)
514
  recent = np.array([r["resolved_at"].timestamp() for r in rows]) > now - gov.IC_HOURS * 3600.0
515
- out[name] = gov.check((rp, rf), (g("score")[recent], lab[recent]), self._drift(j))
516
  else:
517
  out[name] = gov.check(None, None, self._drift(j))
518
  return out
@@ -528,7 +532,7 @@ class KalshiFlyBook:
528
  good = not any(c["triggers"] for c in checks.values())
529
  sid = self._snapshot(conn, now, good, checks)
530
  for name, c in checks.items():
531
- if c["triggers"] and now >= self.checks_paused_until.get(name, 0.0) and not self.learning_frozen:
532
  self._rollback(conn, now, c, sid, name=name)
533
  self._prune(conn, now)
534
  activity.prune(kalshi_activity_dir(), now - activity.KEEP_S)
@@ -558,7 +562,7 @@ class KalshiFlyBook:
558
  if cmd == "pause":
559
  self.learning_frozen = True; record_event("info", NAME, "learning paused by the operator")
560
  elif cmd == "resume":
561
- self.learning_frozen = False; self.rollbacks = {}; record_event("info", NAME, "learning resumed by the operator")
562
  elif cmd == "rollback":
563
  for j, name in enumerate(self.names):
564
  self._rollback(conn, now, {"triggers": ["operator"], "drift": self._drift(j)}, None, count=False, name=name)
@@ -585,10 +589,11 @@ class KalshiFlyBook:
585
  (f"{name}: " + ", ".join(checks["triggers"]), json.dumps(checks, default=str), from_sid, target["id"] if target is not None else None))
586
  record_event("warning", NAME, f"{name} channel rolled back ({', '.join(checks['triggers'])})", {"to_snapshot": target["id"] if target is not None else "bootstrap (D = 0)", "checks": checks})
587
  if count and len(self.rollbacks[name]) >= ROLLBACK_LIMIT:
588
- self.learning_frozen = True
589
  from . import pipeline
590
  pipeline.request_run(fly=True, reason=f"{name}: {len(self.rollbacks[name])} rollbacks in 7 days")
591
- record_event("error", NAME, f"learning frozen after {len(self.rollbacks[name])} {name} rollbacks in 7 days; re-bootstrap requested", {"rollbacks": self.rollbacks})
 
592
 
593
  def _prune(self, conn, now: float) -> None:
594
  rows = conn.execute("SELECT id, path, ts FROM brain_snapshots WHERE kind = %s AND ts < %s ORDER BY ts DESC",
 
21
  maker label the return of a bid one tick inside the ask when a later minute's ask reached it).
22
 
23
  Hourly: learning statistics (``kalshi_fly_updates``), a snapshot of the plastic state (kind 'kalshi_fly_plastic'), the
24
+ rollback checks (train/fly_governance.py: they judge what learning did, against the frozen shadow; a third rollback of a
25
+ strategy in 7 days freezes that strategy's learning and asks kalshi/pipeline.py for a re-bootstrap). State: ``data/brain/plastic_kalshi/state.pt``; commands ``ui_settings['kalshi_fly_command']``; status
26
  ``ui_settings['kalshi_fly_status']``; activity for the 3D view in ``data/brain/activity_kalshi/``.
27
  """
28
  from __future__ import annotations
 
141
  self.lines = {"plastic": dict(self.fly.lines), "frozen": dict(self.fly.lines)}
142
  self.sizing = {"plastic": {k: list(v) for k, v in self.fly.sizings.items()}, "frozen": {k: list(v) for k, v in self.fly.sizings.items()}}
143
  self.applied_through = -math.inf; self.learning_frozen = False; self.checks_paused_until = {k: 0.0 for k in self.names}
144
+ self.frozen: list[str] = [] # strategies whose learning the rollback limit froze (learning_frozen: the operator's pause, all)
145
  self.last_day: int | None = None; self.last_hour: int | None = None
146
  self.rollbacks: dict = {k: [] for k in self.names}; self.nu_set = False; self.hour_stats = self._empty_stats(); self.last_checks: dict = {}
147
  s = self._read_state()
 
149
  self.bank.load_state(s["bank"]); self.nu_set = True
150
  self.lines, self.sizing = s["lines"], s["sizing"]
151
  self.applied_through, self.learning_frozen, self.checks_paused_until = s["applied_through"], s["learning_frozen"], s["checks_paused_until"]
152
+ self.frozen = [k for k in s.get("frozen") or [] if k in self.names]
153
  self.last_day, self.last_hour, self.rollbacks = s["last_day"], s["last_hour"], s["rollbacks"]
154
  elif s:
155
  with transaction() as conn:
 
166
 
167
  def _state(self) -> dict:
168
  return {"bootstrap_id": self.boot_id, "cfg": self.cfg, "bank": self.bank.state(), "lines": self.lines, "sizing": self.sizing, "applied_through": self.applied_through,
169
+ "learning_frozen": self.learning_frozen, "frozen": self.frozen, "checks_paused_until": self.checks_paused_until, "last_day": self.last_day,
170
+ "last_hour": self.last_hour,
171
  "rollbacks": self.rollbacks, "saved_at": time.time()}
172
 
173
  def _write_state(self) -> None:
 
270
  self.last_day = day; self._calibrate(conn, ctx.m1_epoch)
271
  drift = {k: float(self.bank.drift(j)[0]) if self.bank.learn.shape[0] > 1 else float(self.bank.drift()[0]) for j, k in enumerate(self.names)}
272
  out.update({"lines": self.lines["plastic"], "frozen_lines": self.lines["frozen"], "arms": self.arms, "drift": max(drift.values()) if drift else 0.0,
273
+ "drift_by_strategy": drift, "pending": len(self.pending), "learned": learned, "learning_frozen": self.learning_frozen, "frozen_strategies": self.frozen, "channels": self.cfg,
274
  "bootstrap": self.boot_id, "device": str(self.fly.net.dev)})
275
  hour = int(ctx.m1_epoch // 3600)
276
  if self.last_hour is None:
 
389
  self.pending.push(ss.keys, ss.ts, now + REARM_S, ss.y_dn, ss.u0, ss.k, ss.w, s=j)
390
  rearmed = [(datetime.fromtimestamp(now + REARM_S, timezone.utc), datetime.fromtimestamp(t, timezone.utc), tk, side, name) for t, tk, side, name in sub.keys]
391
  stats = {"n": int(keep.sum()), "unknown": int(sum(1 for k in out_keys if k[1] == "unknown")), "rearmed": rearmed, "keys": out_keys, "waiting": len(rearm)}
392
+ held = [self.names.index(k) for k in self.frozen if k in self.names]
393
+ learn = keep & ~np.isin(np.asarray(tags.s), held) if held else keep # a strategy the rollback limit froze learns nothing
394
  if keep.any():
395
+ if self.learning_frozen or not learn.any():
 
396
  self.bank.decay_to(now)
397
  else:
398
+ st = self.bank.update(tags.subset(learn), torch.tensor(labels[learn], dtype=torch.float32), now)
399
  stats.update(mean_delta=st["mean_delta"][0], mean_abs_delta=st["mean_abs_delta"][0], step=st["step"][0], capped=bool(st["capped"][0]))
400
  h = self.hour_stats; h["n"] += stats["n"]; h["sum_delta"] += st["mean_delta"][0] * stats["n"]; h["sum_abs"] += st["mean_abs_delta"][0] * stats["n"]
401
  h["step"] += st["step"][0]; h["capped"] += int(st["capped"][0])
 
516
  g = lambda c: np.array([r[c] for r in rows], dtype=np.float64)
517
  rp = gov.picks_returns(ts, mint, hold, g("score"), g("line"), lab); rf = gov.picks_returns(ts, mint, hold, g("frozen_score"), g("frozen_line"), lab)
518
  recent = np.array([r["resolved_at"].timestamp() for r in rows]) > now - gov.IC_HOURS * 3600.0
519
+ out[name] = gov.check((rp, rf), (g("score")[recent], lab[recent]), self._drift(j), ic_frozen_rows=(g("frozen_score")[recent], lab[recent]))
520
  else:
521
  out[name] = gov.check(None, None, self._drift(j))
522
  return out
 
532
  good = not any(c["triggers"] for c in checks.values())
533
  sid = self._snapshot(conn, now, good, checks)
534
  for name, c in checks.items():
535
+ if c["triggers"] and now >= self.checks_paused_until.get(name, 0.0) and not self.learning_frozen and name not in self.frozen:
536
  self._rollback(conn, now, c, sid, name=name)
537
  self._prune(conn, now)
538
  activity.prune(kalshi_activity_dir(), now - activity.KEEP_S)
 
562
  if cmd == "pause":
563
  self.learning_frozen = True; record_event("info", NAME, "learning paused by the operator")
564
  elif cmd == "resume":
565
+ self.learning_frozen = False; self.frozen = []; self.rollbacks = {}; record_event("info", NAME, "learning resumed by the operator")
566
  elif cmd == "rollback":
567
  for j, name in enumerate(self.names):
568
  self._rollback(conn, now, {"triggers": ["operator"], "drift": self._drift(j)}, None, count=False, name=name)
 
589
  (f"{name}: " + ", ".join(checks["triggers"]), json.dumps(checks, default=str), from_sid, target["id"] if target is not None else None))
590
  record_event("warning", NAME, f"{name} channel rolled back ({', '.join(checks['triggers'])})", {"to_snapshot": target["id"] if target is not None else "bootstrap (D = 0)", "checks": checks})
591
  if count and len(self.rollbacks[name]) >= ROLLBACK_LIMIT:
592
+ self.frozen = sorted(set(self.frozen) | {name}) # this strategy's learning only: the others keep learning
593
  from . import pipeline
594
  pipeline.request_run(fly=True, reason=f"{name}: {len(self.rollbacks[name])} rollbacks in 7 days")
595
+ record_event("error", NAME, f"{name} learning frozen after {len(self.rollbacks[name])} rollbacks in 7 days; re-bootstrap requested",
596
+ {"rollbacks": self.rollbacks, "frozen": self.frozen})
597
 
598
  def _prune(self, conn, now: float) -> None:
599
  rows = conn.execute("SELECT id, path, ts FROM brain_snapshots WHERE kind = %s AND ts < %s ORDER BY ts DESC",
fly_trader/train/fly_governance.py CHANGED
@@ -1,11 +1,16 @@
1
  """The checks that roll the fly's plasticity back (live, hourly) — and are counted in the replay to measure false alarms.
2
 
3
- Any one trips a rollback:
 
 
4
  - shadow: over the last ``SHADOW_DAYS`` days of resolved picks (each arm at its own line, one position per token), the
5
  plastic fly's mean net return per trade is more than one standard error below its frozen shadow's (the bootstrap fly,
6
  D = 0, scoring the same minutes), with at least ``SHADOW_MIN_TRADES`` trades on each side;
7
- - ic: the rank correlation of its scores with the realized returns over the last ``IC_HOURS`` hours is below 0
8
- (at least ``IC_MIN_ROWS`` resolved minutes);
 
 
 
9
  - drift: ‖D‖_F / ‖w0‖_F above ``DRIFT_MAX``.
10
  """
11
  from __future__ import annotations
@@ -17,6 +22,8 @@ from .decisions import rank_corr, taken_idx
17
  SHADOW_DAYS, SHADOW_MIN_TRADES = 3, 30
18
  IC_HOURS, IC_MIN_ROWS, IC_SAMPLE = 24, 5000, 50_000
19
  DRIFT_MAX = 0.5
 
 
20
 
21
 
22
  def shadow_check(r_plastic: np.ndarray, r_frozen: np.ndarray, min_trades: int = SHADOW_MIN_TRADES) -> tuple[bool, dict]:
@@ -50,8 +57,11 @@ def picks_returns(ts: np.ndarray, mint: np.ndarray, horizon_s: float, scores: np
50
  return np.asarray(rets)[taken_idx(ts, mint, horizon_s, idx)]
51
 
52
 
53
- def check(shadow: tuple[np.ndarray, np.ndarray] | None, ic_rows: tuple[np.ndarray, np.ndarray] | None, drift: float) -> dict:
54
- """All three checks; ``triggers`` lists the ones that tripped."""
 
 
 
55
  out = {"triggers": [], "drift": float(drift)}
56
  if shadow is not None:
57
  t, d = shadow_check(*shadow); out["shadow"] = d
@@ -59,8 +69,13 @@ def check(shadow: tuple[np.ndarray, np.ndarray] | None, ic_rows: tuple[np.ndarra
59
  out["triggers"].append("shadow")
60
  if ic_rows is not None:
61
  t, ic = ic_check(*ic_rows); out["ic"] = ic
 
 
 
62
  if t:
63
  out["triggers"].append("ic")
64
  if drift_check(drift):
65
  out["triggers"].append("drift")
 
 
66
  return out
 
1
  """The checks that roll the fly's plasticity back (live, hourly) — and are counted in the replay to measure false alarms.
2
 
3
+ They judge what learning did, so a channel that has learned nothing (drift at most ``DRIFT_MIN``: a learning rate of 0,
4
+ or a memory decayed back to its bootstrap) trips none: there is nothing for a rollback to undo. Otherwise any one trips
5
+ a rollback:
6
  - shadow: over the last ``SHADOW_DAYS`` days of resolved picks (each arm at its own line, one position per token), the
7
  plastic fly's mean net return per trade is more than one standard error below its frozen shadow's (the bootstrap fly,
8
  D = 0, scoring the same minutes), with at least ``SHADOW_MIN_TRADES`` trades on each side;
9
+ - ic: over the last ``IC_HOURS`` hours (at least ``IC_MIN_ROWS`` resolved minutes), the rank correlation of its scores
10
+ with the realized returns trails its frozen shadow's on the same rows by more than ``IC_MARGIN`` -- learning ranked
11
+ worse than its bootstrap. Not merely below 0: a fly that scores as its teacher would then be rolled back for the
12
+ teacher's own bad day (2026-10-11..13: ev, learning rate 0, rolled back three times at IC -0.10..-0.13, which froze
13
+ learning). Without the shadow's rows, below 0;
14
  - drift: ‖D‖_F / ‖w0‖_F above ``DRIFT_MAX``.
15
  """
16
  from __future__ import annotations
 
22
  SHADOW_DAYS, SHADOW_MIN_TRADES = 3, 30
23
  IC_HOURS, IC_MIN_ROWS, IC_SAMPLE = 24, 5000, 50_000
24
  DRIFT_MAX = 0.5
25
+ DRIFT_MIN = 1e-3 # at most this far from its bootstrap, a channel has learned nothing a rollback could undo
26
+ IC_MARGIN = 0.02 # how far the plastic fly's IC must trail its frozen shadow's to count as learning's harm
27
 
28
 
29
  def shadow_check(r_plastic: np.ndarray, r_frozen: np.ndarray, min_trades: int = SHADOW_MIN_TRADES) -> tuple[bool, dict]:
 
57
  return np.asarray(rets)[taken_idx(ts, mint, horizon_s, idx)]
58
 
59
 
60
+ def check(shadow: tuple[np.ndarray, np.ndarray] | None, ic_rows: tuple[np.ndarray, np.ndarray] | None, drift: float,
61
+ ic_frozen_rows: tuple[np.ndarray, np.ndarray] | None = None) -> dict:
62
+ """All three checks; ``triggers`` lists the ones that tripped. ``ic_frozen_rows``: the frozen shadow's scores and the
63
+ labels of the same rows as ``ic_rows``. A channel at or below ``DRIFT_MIN`` trips nothing (what would have tripped is
64
+ ``not_counted``)."""
65
  out = {"triggers": [], "drift": float(drift)}
66
  if shadow is not None:
67
  t, d = shadow_check(*shadow); out["shadow"] = d
 
69
  out["triggers"].append("shadow")
70
  if ic_rows is not None:
71
  t, ic = ic_check(*ic_rows); out["ic"] = ic
72
+ if ic_frozen_rows is not None:
73
+ _, icf = ic_check(*ic_frozen_rows); out["ic_frozen"] = icf
74
+ t = ic is not None and icf is not None and ic < icf - IC_MARGIN
75
  if t:
76
  out["triggers"].append("ic")
77
  if drift_check(drift):
78
  out["triggers"].append("drift")
79
+ if float(drift) <= DRIFT_MIN and out["triggers"]:
80
+ out["not_counted"], out["triggers"] = out["triggers"], []
81
  return out
fly_trader/train/fly_replay.py CHANGED
@@ -324,10 +324,10 @@ def _governance(ds, order, ts_o, scores, lines, line_log, bank, now, lags, holds
324
  sc3 = np.where(other3, np.nan, sc3)
325
  sc3 = np.nan_to_num(sc3, nan=-np.inf)
326
  rp = gov.picks_returns(ds.ts[r3], ds.mint[r3], H * 60.0, sc3, lc, y[r3])
327
- s1 = scores[c, s_, a1:b1]; has = np.isfinite(s1)
328
  if ci is not None:
329
  has = has & (slots.cidx_o[a1:b1] == ci)
330
- out = gov.check((rp, rf), (s1[has], y[r1][has]), drift[c])
331
  counts[(c, s_)]["checks"] += 1
332
  for t in out["triggers"]:
333
  counts[(c, s_)][t] += 1
 
324
  sc3 = np.where(other3, np.nan, sc3)
325
  sc3 = np.nan_to_num(sc3, nan=-np.inf)
326
  rp = gov.picks_returns(ds.ts[r3], ds.mint[r3], H * 60.0, sc3, lc, y[r3])
327
+ s1 = scores[c, s_, a1:b1]; f1 = scores[0, s_, a1:b1]; has = np.isfinite(s1) & np.isfinite(f1)
328
  if ci is not None:
329
  has = has & (slots.cidx_o[a1:b1] == ci)
330
+ out = gov.check((rp, rf), (s1[has], y[r1][has]), drift[c], ic_frozen_rows=(f1[has], y[r1][has])) # as live: against the frozen shadow
331
  counts[(c, s_)]["checks"] += 1
332
  for t in out["triggers"]:
333
  counts[(c, s_)][t] += 1