Set Up the Jev Paper Trading Bot with Claude Code
Use a starter Markdown prompt to create the project, run setup checks, configure secrets safely, and choose between local paper trading or an optional Bybit demo deployment.
JEV STARTER PROMPT
Jev Starter: build your own AI trading bot
How to use this: open Claude Code in an empty folder, drag this file in (or paste it), and say "go".
Claude sets everything up and talks you through it. Mac, Windows and Linux.
- Path 1, laptop (about 15 min): a live AI trading dashboard, a news terminal, and a strategy file you edit.
Paper trading: live prices, simulated trades, no exchange account.
- Path 2, 24/7 bot (about 45-60 min more): the same bot on an always-on server, with Claude as the brain,
trading on Bybit. It starts on Bybit's demo account (fake money).
You are the setup agent for jev-starter. Do the work yourself: run every command, check every result, fix errors as they come up. The user is watching and may not be technical, so talk to them in plain English, one or two short lines per step. Ask before anything that costs money or creates an account; those are their decisions.
What you're building (say this to the user first, in 3-4 lines)
Jev is a fast AI that answers multiple-choice questions ("buy or sell?") with a confidence score in about half a second, for a fraction of a cent. This builds a live trading dashboard where Jev makes calls on a real market several times a second, a news terminal where Jev reads real crypto headlines, and a strategy file that decides which calls become trades. Optionally, it then moves the bot to a 24/7 server with Claude as the big-picture brain, trading on Bybit.
Then ask: "Do you want just the laptop version (Path 1), or the full 24/7 bot too (Path 2)? Either way we start with the laptop version, so you can see it working first."
Rules (never break these)
- Write every file exactly as given in the Files section. Don't rewrite, reformat or "improve" them. The only file
you may change later is jevlab/strategy.py, when the user asks for a strategy.
- Never type, paste, print or echo any key, secret or password (Vercel, Bybit, server). The user pastes keys into
.env themselves. Never cat the .env file. Copying the file to their own server is fine.
- Never set
BYBIT_MODE=liveorJEV_LIVE_CONFIRMyourself, never enable withdrawal permissions on an API key, and
never weaken the safety checks in bybit.py / bot.py. Real money is the user's own deliberate step (Step B10).
- Never open the dashboard port (8765) to the internet. On the server it's reached only through an SSH tunnel.
- Never promise profit. The honest answer: it depends on the strategy, costs matter, and it needs weeks of demo
results before any real money. Not financial advice.
PART A · Path 1: the laptop version (everyone does this)
A1 · Check the machine
- Work out the OS (
uname -s; if that fails, it's Windows). - Run
uv --version. If it's missing, install it:
- Mac/Linux:
curl -LsSf https://astral.sh/uv/install.sh | sh, thenexport PATH="$HOME/.local/bin:$PATH" - Windows (PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex", then
restart the terminal if `uv` still isn't found
uvdownloads its own Python, so nothing else needs installing.
A2 · Write the project
Create a folder called `jev-starter`, write every file from the **Files** section into it at the exact path shown,
then cd jev-starter and run uv sync.
A3 · Test it before the key
Run uv run python -m jevlab check. Prices and news should say ok, and Jev will say "no key yet". That's expected.
A4 · The Jev key (the user does this part)
- Copy
.env.exampleto.env(cp .env.example .env, orcopy .env.example .envon Windows). - Open https://vercel.com/dashboard for them (
openon Mac,starton Windows,xdg-openon Linux) and explain:
sign in, go to AI Gateway → API Keys → Create key, and copy it. The free tier works but is slow. $5 of AI Gateway credits makes Jev full speed, and Path 2 needs credits because Claude runs through the same key. Buying credits is their choice; never do it for them.
- Open
.envin their editor (open -e .envon Mac,notepad .envon Windows,${EDITOR:-nano} .envon Linux) and
say: *"Paste your key right after AI_GATEWAY_API_KEY=, save, and tell me when you're done."* Then wait.
- Run
uv run python -m jevlab checkagain. It should show Jev's answer and speed, e.g. "BUY 72% in 480 ms".
"key rejected" → the key was pasted wrong or the Vercel team needs a card or credits. "rate-limited" → wait a minute, or add credits.
A5 · The live dashboard
Run uv run python -m jevlab loop in the background (it runs for 10 minutes, and the dashboard stays open until stopped). It opens http://127.0.0.1:8765. Walk them through it in plain English:
- the big number: the live price, straight from the exchange
- each dot on the chart: a Jev call (green = buy, red = sell), with confidence and response time in the feed
- ◆ markers: actual (paper) trades. Most calls end in "hold": every trade has a cost, so the strategy is picky
- the right-hand panel: the paper balance, before and after fees
If the port is busy, add --port 8766. --coin BTC watches Bitcoin instead.
A6 · The newsroom
When the loop is done (or stopped), run uv run python -m jevlab newsroom in the background. It replays the last 36 hours of real crypto headlines in fast motion, round after round. Jev reads each one live (which coin, good or bad news, how big), and the chart shows what the price actually did next. Tell them it's a fast replay of real headlines, because live news is too slow to watch. Stop it when they've seen enough.
A7 · Plug in their own strategy
Open jevlab/strategy.py and explain it in two lines: every time Jev makes a call, decide() chooses buy, sell, flat or hold, and the default is "only act when Jev is 85%+ sure, and wait 15 seconds between flips". Ask: *"Do you have a strategy idea? Describe it in one sentence and I'll plug it in."* If they give one, rewrite decide() (plus SETTINGS / DESCRIPTION) to match, using only the inputs in its docstring and the same return values, then re-run uv run python -m jevlab loop. If not, show the trend-following example at the bottom of the file. Before trusting any strategy, it should pass three questions: why should it make money, does it survive costs, does it work on data it's never seen.
If they only wanted Path 1, wrap up here: remind them of check, loop (--minutes 0 = until Ctrl+C), newsroom and strategy.py, and that this is paper trading.
PART B · Path 2: the 24/7 bot (Claude + Jev on Bybit)
Tell them what's coming in 3 lines: a Bybit demo account (fake money) and API key, a small always-on server from Hostinger, and the bot running there 24/7. Claude reads the market every 10 minutes and sets the direction, Jev makes the fast calls, and hard limits are always on. They need AI Gateway credits for Claude (Step A4).
B1 · Bybit account and demo key (the user does the clicking)
- Check they can use Bybit: Bybit isn't available in every country (for example the US), and they should check
Bybit's terms for where they live. If they can't use it, stop Path 2 here and explain why.
- If they don't have an account, open Miles's Bybit referral link for them: https://partner.bybit.com/b/166453 (say it's Miles's
referral link, with welcome rewards for new users). They sign up and complete any verification Bybit asks for.
- In Bybit, switch to Demo Trading (in the account menu). Demo has its own fake balance; top it up there if
it's empty.
- While in Demo Trading, create an API key: API → Create New Key → system-generated, with Read-Write
permission for Contracts / Derivatives: Orders and Positions only. No withdrawals, no transfers. Leave the IP restriction for now; we lock it to the server in B5. Labels on Bybit's site can change a little; guide them by meaning.
- Open
.envin their editor and have them paste the key afterBYBIT_API_KEY=and the secret after
BYBIT_API_SECRET=, and save. BYBIT_MODE=demo is already set.
- Run
uv run python -m jevlab check. Step 5 should say "connected to Bybit DEMO" with the demo balance, and "key
can't withdraw". If it says the key can withdraw, have them delete it and make a new one without that permission.
B2 · Test the bot on the laptop first
Run uv run python -m jevlab bot --minutes 5 in the background. The dashboard opens with Claude's bias box (e.g. "Claude's bias: FLAT · reason…"), Jev's calls, and the bot's limit orders on Bybit's demo account. Ask them to open Bybit Demo Trading → orders / positions to see the same orders appear. Explain that "hold · Claude says stay out" is normal: in quiet or choppy markets Claude often sits out, and then there are no orders at all. That's the brain doing its job, not a bug. When Claude picks a side, the orders appear. Stop it after 5 minutes. (uv run python -m jevlab bot --dry does the same on Bybit prices with simulated fills, with no Bybit key.)
B3 · Get a server (the user does the buying)
- Open Miles's Hostinger link for them: https://www.hostinger.com/aiedge (Miles's referral link; the code
aiedgegives an extra
discount). They choose a VPS plan (the smallest KVM plan is plenty), with Ubuntu 24.04.
- Pick a data centre outside the US (Bybit blocks US server addresses), in a country where they're allowed to
use Bybit.
- Before they finish setup, make an SSH key for them. It's a key pair: the private half stays on their laptop,
and the public half goes to the server.
- Mac/Linux:
ssh-keygen -t ed25519 -f ~/.ssh/jev_vps -N "" -C jev-vps, then showcat ~/.ssh/jev_vps.pub - Windows:
ssh-keygen -t ed25519 -f "$env:USERPROFILE\.ssh\jev_vps" -N '""' -C jev-vps, then show
`Get-Content "$env:USERPROFILE\.ssh\jev_vps.pub"`
Showing the .pub (public) key is safe. Tell them to paste it into Hostinger's SSH key field. If Hostinger asks for a root password, they choose one themselves; you never need it.
- When the server is ready, ask for its IP address (shown in Hostinger's VPS dashboard).
B4 · Install the bot on the server
Below, $SSH is shorthand for the full command ssh -i ~/.ssh/jev_vps -o StrictHostKeyChecking=accept-new root@<IP> (on Windows, use the $env:USERPROFILE\.ssh\jev_vps key path). Each command runs in a fresh shell, so write the full command every time rather than relying on a variable.
- Test it:
$SSH "echo connected". - Install uv there:
$SSH "curl -LsSf https://astral.sh/uv/install.sh | sh". - Copy the project, including
.env(as a file; never print it), but not.venvorresults:
- Mac/Linux:
rsync -az --exclude .venv --exclude results -e "ssh -i ~/.ssh/jev_vps" ./ root@<IP>:/root/jev-starter/ - Windows (no rsync):
$SSH "mkdir -p /root/jev-starter", thenscp -i <key> -r pyproject.toml README.md .env jevlab dashboard deploy root@<IP>:/root/jev-starter/
$SSH "cd /root/jev-starter && /root/.local/bin/uv sync && /root/.local/bin/uv run python -m jevlab check": Jev and
Bybit DEMO should both say ok from the server. That also confirms the server's location isn't blocked.
B5 · Lock the Bybit key to the server
Have them edit the demo API key on Bybit and restrict it to the server's IP address only. Re-run the server check from B4.4, which should now say "key is locked to IP".
B6 · Run it 24/7
$SSH "cp /root/jev-starter/deploy/jev-bot.service /etc/systemd/system/ && systemctl daemon-reload && systemctl enable --now jev-bot" Then $SSH "systemctl status jev-bot --no-pager" and $SSH "journalctl -u jev-bot -n 30 --no-pager" should show it running. It restarts itself if it crashes or the server reboots.
B7 · Watch it from the laptop
Open a private tunnel in the background: ssh -i ~/.ssh/jev_vps -N -L 8765:127.0.0.1:8765 root@<IP>, then open http://127.0.0.1:8765. It's the same dashboard, now showing the bot on the server. (If port 8765 is busy on the laptop, use -L 8766:127.0.0.1:8765 and open :8766.) The dashboard is never exposed to the internet.
B8 · Controls (tell them, and write these into a short note for them)
- Stop:
$SSH "systemctl stop jev-bot"(it cancels orders and closes the position). Start again:systemctl start jev-bot - Kill switch:
$SSH "touch /root/jev-starter/STOP"closes out and halts. Delete the file and restart to resume. - Logs:
$SSH "journalctl -u jev-bot -f" - Change the strategy: edit
jevlab/strategy.pyon the laptop, test withbot --minutes 5, copy it up (re-run the
B4.3 copy), then $SSH "systemctl restart jev-bot"
- Limits:
MAX_POSITION_USDandMAX_DAILY_LOSS_USDin the server's.env. The bot halts for the day at the loss limit.
B9 · Wrap up
Tell them it's now running 24/7 on Bybit demo, with Claude setting the direction and Jev making the fast calls. Suggest they leave it on demo for at least a few weeks and judge it on after-fee results before even thinking about real money.
B10 · Real money (only if they explicitly ask)
Explain the steps, but they do them themselves: create a live API key with the same rules (orders and positions only, no withdrawals, locked to the server IP), put it in the server's .env, set small limits first (e.g. MAX_POSITION_USD=50, MAX_DAILY_LOSS_USD=10), and set BYBIT_MODE=live and JEV_LIVE_CONFIRM=I accept the risk of trading real money themselves. Then restart the service. Remind them: only risk money they can afford to lose. Not financial advice.
Files
jev-starter/pyproject.toml
[project]
name = "jev-starter"
version = "1.0.0"
description = "A Jev trading bot starter: live dashboard, news terminal, a strategy file you edit, and an optional 24/7 Bybit bot (demo first)."
requires-python = ">=3.10"
dependencies = [
"requests>=2.31",
"pandas>=2.0",
"python-dotenv>=1.0",
"rich>=13",
"websocket-client>=1.7",
"pybit>=5.8",
]jev-starter/.gitignore
.env .venv/ results/ __pycache__/
jev-starter/.env.example
# Copy this file to .env (same folder) and paste your keys after the = signs. # Never share this file. # --- Jev (and Claude) ------------------------------------------------------- # Vercel AI Gateway key: https://vercel.com/dashboard -> AI Gateway -> API Keys -> Create key # The free tier works but is slow (about one Jev call every 2 seconds). # $5 of AI Gateway credits removes that limit, and is needed for Claude (the 24/7 bot's brain). AI_GATEWAY_API_KEY= # Optional: a direct TypeSafe key, if you're off their waitlist. TYPESAFE_API_KEY= # --- 24/7 bot only (Path 2) ------------------------------------------------- # Bybit API key and secret. Start with a DEMO TRADING key (fake money). # The key must NOT have withdrawal permission. Lock it to your server's IP. BYBIT_API_KEY= BYBIT_API_SECRET= BYBIT_MODE=demo # Hard limits, always on: MAX_POSITION_USD=1000 MAX_DAILY_LOSS_USD=50 # Claude model for the big-picture brain (via the same Vercel key). CLAUDE_MODEL=anthropic/claude-sonnet-5 # Real money: leave this blank. Only set BYBIT_MODE=live, and this line to the exact # phrase "I accept the risk of trading real money", after weeks of demo results. JEV_LIVE_CONFIRM=
jev-starter/README.md
# jev-starter A Jev trading bot you can watch, test, and plug your own strategy into. **The laptop version is paper trading only**: it reads live public market data and simulates fills, with no exchange account needed. The optional **24/7 bot** (Path 2) trades on Bybit, starting on Bybit Demo Trading (fake money). ## What's inside | | | |---|---| | **Jev Loop** · `uv run python -m jevlab loop` | Live dashboard: the real price ticking about 8 times a second, Jev's BUY/SELL calls landing on it, and your strategy deciding which calls become trades. Shows P&L before and after fees on a $1,000 paper position. | | **Newsroom** · `uv run python -m jevlab newsroom` | A news terminal. It replays the last 36 hours of real crypto headlines in fast motion, Jev reads each one live (which coin, which way, how big), and then shows what the price actually did next. | | **Your strategy** · `jevlab/strategy.py` | The one file you edit. `decide()` gets Jev's call and the live market numbers, and returns `"buy"`, `"sell"` or `"hold · reason"`. | | **Setup check** · `uv run python -m jevlab check` | Tests prices, news feeds, your Jev key and your speed tier. | ## Setup 1. Install [uv](https://docs.astral.sh/uv/). 2. `cp .env.example .env` and paste your Vercel AI Gateway key after `AI_GATEWAY_API_KEY=`. Get one at vercel.com/dashboard → AI Gateway → API Keys. The free tier works but is slow (about one Jev call every 2 seconds); $5 of AI Gateway credits runs it at full speed. Each call costs a tiny fraction of a cent. 3. `uv sync`, then `uv run python -m jevlab check`. ## Plug in your own strategy Open `jevlab/strategy.py`. The default is three simple rules: - only act when Jev is at least 85% sure - wait 15 seconds between flips - let the loop use limit orders Change `SETTINGS`, or rewrite `decide()`. There's a trend-following example at the bottom of the file. Easiest route: tell Claude your idea in one sentence and ask it to rewrite `decide()`. Then run `uv run python -m jevlab loop` again and watch what changes. Before trusting any strategy, ask three questions: 1. Why should it make money? 2. Does it still make money after costs? 3. Does it work on data it's never seen? ## Flags `--coin BTC` · `--minutes 0` (run until Ctrl+C) · `--taker` (market orders) · `--gap 4` (newsroom pace) · `--port 8766` (a second dashboard) · `--no-open` ## The 24/7 bot (Path 2): Claude + Jev on Bybit `uv run python -m jevlab bot` runs the same loop against Bybit, with Claude as the big-picture brain: - **Claude** reads the market every 10 minutes and sets the bias: long, short or flat - **Jev** makes the fast calls - **`strategy.py`** only trades in Claude's direction It starts on **Bybit Demo Trading** (fake money). `--dry` runs it on Bybit prices with simulated fills and needs no Bybit key. Safety, always on: - post-only limit orders - `MAX_POSITION_USD` and `MAX_DAILY_LOSS_USD` limits - a kill switch: create a file called `STOP` and it closes out and halts - on shutdown it cancels orders and closes the position Real money needs `BYBIT_MODE=live` **and** the exact confirmation phrase in `JEV_LIVE_CONFIRM`. Set those yourself, after weeks of demo results. To run it 24/7, put it on an always-on server (a small Linux VPS) with `deploy/jev-bot.service`. The setup prompt walks through all of it. Not financial advice.
jev-starter/jevlab/__init__.py
"""jev-starter: a Jev trading bot you can see, test and plug your own strategy into. Paper only."""
jev-starter/jevlab/__main__.py
"""jev-starter CLI.
uv run python -m jevlab check # test the setup: live prices, headlines, and one real Jev call
uv run python -m jevlab loop # the Jev Loop: live trading dashboard (paper), your strategy
uv run python -m jevlab newsroom # the news terminal: real headlines, Jev reading each one live
uv run python -m jevlab bot --dry # 24/7 bot on Bybit prices with simulated fills (no Bybit key)
uv run python -m jevlab bot # 24/7 bot on Bybit Demo Trading (fake money) · Claude sets the bias
Useful flags:
--coin BTC loop/bot: which coin to trade (default HYPE)
--minutes 0 loop: run until Ctrl+C (default 10)
--taker loop: market orders instead of limit orders
--gap 4 newsroom: seconds between replayed headlines (default 6)
--port 8766 run a second dashboard alongside the first
--no-open don't open the browser
"""
from __future__ import annotations
import argparse
import threading
def check() -> None:
import time
from . import hl
from .core import console
from .judges import JevJudge, JudgeError
from .news import fetch_headlines
console.print(" [bold]1. Hyperliquid prices[/]")
mids = hl.mids(["BTC", "HYPE"])
console.print(f" BTC {mids['BTC']:,.1f} · HYPE {mids['HYPE']:,.3f} [#3fd68a]ok[/]")
console.print(" [bold]2. News feeds[/]")
n = len(fetch_headlines(24))
console.print(f" {n} headlines in the last 24h [#3fd68a]ok[/]" if n else " no headlines found [#f5b53d]check your internet[/]")
console.print(" [bold]3. Jev[/]")
try:
jev = JevJudge()
except JudgeError:
console.print(" [#f5b53d]no key yet[/]: paste your AI_GATEWAY_API_KEY into .env, save, and run this again")
return
q = {"side": {"type": "choice", "instructions": "Which side should a bot hold for the next few seconds?",
"criteria": {"buy": None, "sell": None}}}
try:
ans, meta = jev.ask({"return_5s_bps": 1.2, "top_of_book_imbalance": 0.4}, q, timeout=15, retries=2)
except JudgeError as exc:
msg = str(exc)
if "401" in msg or "403" in msg:
console.print(" [#ff5d6c]key rejected[/]: check the key in .env, and that your Vercel team has a card or credits")
elif "429" in msg:
console.print(" [#f5b53d]rate-limited[/]: the free tier is busy. Wait a minute, or add $5 of AI Gateway credits for full speed")
else:
console.print(f" [#ff5d6c]failed[/]: {msg[:160]}")
return
side = ans["side"]["choice"]
console.print(f" Jev says [bold]{side.upper()} {ans['side']['probs'][side]:.0%}[/] in {meta['latency_ms']} ms "
f"(model {meta['model']}) [#3fd68a]ok[/]")
console.print(" [bold]4. Speed[/]")
ok = 0
t0 = time.time()
for _ in range(6):
try:
jev.ask({"return_5s_bps": 0.5}, q, timeout=10, retries=0)
ok += 1
except JudgeError:
pass
time.sleep(0.3)
console.print(f" {ok}/6 quick calls went through in {time.time() - t0:.1f}s · "
+ ("full speed" if ok == 6 else "you're on the free tier, so the loop will run slower (add $5 of credits for full speed)"))
check_bybit()
console.print("\n [#3fd68a]All set.[/] Next: [bold]uv run python -m jevlab loop[/]")
def check_bybit() -> None:
import os
from .core import console
console.print(" [bold]5. Bybit (only needed for the 24/7 bot)[/]")
if not os.getenv("BYBIT_API_KEY", "").strip():
console.print(" not set up yet (fine for the laptop version)")
return
from .bybit import BybitError, BybitTrader, bybit_mode
try:
mode = bybit_mode()
trader = BybitTrader(os.getenv("BOT_SYMBOL", "HYPEUSDT"), mode)
eq = trader.equity_usdt()
console.print(f" connected to Bybit [bold]{mode.upper()}[/] · equity {eq:,.2f} USDT [#3fd68a]ok[/]" if eq is not None
else f" connected to Bybit {mode.upper()} [#3fd68a]ok[/]")
except BybitError as exc:
msg = str(exc)
hint = (" (a demo key only works with BYBIT_MODE=demo, a live key only with live)" if "10003" in msg or "API key is invalid" in msg
else " (check the key and secret in .env)")
console.print(f" [#ff5d6c]Bybit said no[/]: {msg[:140]}{hint}")
return
try: # safety checks on the key itself (not every account type exposes this)
info = trader._call(trader.http.get_api_key_information)
perms = info.get("permissions", {}) or {}
flat = {p for v in perms.values() for p in (v or [])}
if any("Withdraw" in p for p in flat):
console.print(" [#ff5d6c]this key can WITHDRAW funds[/]: make a new key without withdrawal permission")
else:
console.print(" key can't withdraw [#3fd68a]ok[/]")
ips = info.get("ips") or []
console.print(" key is locked to IP " + ", ".join(ips) + " [#3fd68a]ok[/]" if ips and ips != ["*"]
else " key isn't locked to an IP yet (lock it to your server's IP once the server is set up)")
except Exception:
console.print(" [dim](couldn't read the key's permissions here, so double-check them on Bybit: no withdrawals)[/]")
def main() -> None:
ap = argparse.ArgumentParser(prog="jevlab")
ap.add_argument("command", choices=["check", "loop", "newsroom", "bot"])
ap.add_argument("--coin", default="HYPE", help="loop/bot: the coin to trade")
ap.add_argument("--minutes", type=float, default=10.0, help="loop/bot: how long to run (0 = until stopped)")
ap.add_argument("--pace", type=float, default=0.3, help="loop: fastest seconds between Jev calls")
ap.add_argument("--late-ms", type=float, default=1500, help="loop: answers slower than this are ignored")
ap.add_argument("--taker", action="store_true", help="loop: market orders instead of limit orders")
ap.add_argument("--maker-wait", type=float, default=10.0, help="loop: seconds a limit order rests before cancel")
ap.add_argument("--hours", type=float, default=36.0, help="newsroom: how far back to replay headlines")
ap.add_argument("--gap", type=float, default=6.0, help="newsroom: seconds between replayed headlines")
ap.add_argument("--dry", action="store_true", help="bot: Bybit prices but simulated fills, no Bybit key needed")
ap.add_argument("--brain-every", type=float, default=10.0, help="bot: minutes between Claude's big-picture reads")
ap.add_argument("--port", type=int, default=8765, help="dashboard port")
ap.add_argument("--no-open", action="store_true", help="don't open the dashboard in a browser")
a = ap.parse_args()
from .core import console
if a.command != "bot": # the bot prints its own banner, with its trading mode
console.print("[bold #8b7bff]jev-starter[/] [dim]· paper trading on live Hyperliquid data · no real orders are ever placed[/]")
if a.command == "check":
check()
return
if a.command == "loop":
from .loop import run_loop
run_loop(a.coin.upper(), a.pace, a.minutes, a.port, not a.no_open, a.late_ms, not a.taker, a.maker_wait)
elif a.command == "bot":
from .bot import run_bot
run_bot(a.coin.upper(), a.pace, a.minutes, a.port, not a.no_open, a.late_ms, a.maker_wait, a.brain_every, a.dry)
if a.no_open and not a.minutes:
return
else:
from .newsroom import run_newsroom
run_newsroom(a.hours, a.gap, a.port, not a.no_open)
console.print(" [dim]dashboard still open · Ctrl+C to stop[/]")
try:
threading.Event().wait()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()jev-starter/jevlab/core.py
"""Shared bits: where results go, and the terminal styling."""
from __future__ import annotations
from pathlib import Path
from rich import box
from rich.console import Console
from rich.panel import Panel
RESULTS = Path(__file__).resolve().parent.parent / "results"
console = Console(highlight=False)
def header(title: str, subtitle: str) -> None:
console.print()
console.print(Panel(f"[dim]{subtitle}[/]", title=f"[bold #8b7bff]{title}[/]", title_align="left",
border_style="#3a3f5c", box=box.ROUNDED, padding=(0, 2)))jev-starter/jevlab/hl.py
"""Hyperliquid public market data. Mainnet, read-only, no key, no orders.
Fills in every lab are simulated against this real data with Hyperliquid's
real base-tier perp fees. Nothing here can place an order.
"""
from __future__ import annotations
import time
import pandas as pd
import requests
INFO_URL = "https://api.hyperliquid.xyz/info"
# Hyperliquid perps, base fee tier (per side).
TAKER_FEE = 0.00045
MAKER_FEE = 0.00015
def _info(body: dict, timeout: float = 10.0):
r = requests.post(INFO_URL, json=body, timeout=timeout)
r.raise_for_status()
return r.json()
def l2_book(coin: str) -> dict:
"""Best 20 levels a side, as floats: {"bids": [(px, sz)], "asks": [...], "time": ms}."""
d = _info({"type": "l2Book", "coin": coin})
bids, asks = d["levels"]
return {
"bids": [(float(l["px"]), float(l["sz"])) for l in bids],
"asks": [(float(l["px"]), float(l["sz"])) for l in asks],
"time": d["time"],
}
def mids(coins: list[str]) -> dict[str, float]:
d = _info({"type": "allMids"})
return {c: float(d[c]) for c in coins if c in d}
def candles(coin: str, interval: str, start_ms: int, end_ms: int | None = None) -> pd.DataFrame:
"""OHLCV candles, indexed by open time (UTC). Pages through the 5000-bar cap."""
end_ms = end_ms or int(time.time() * 1000)
rows, cursor = [], start_ms
while cursor < end_ms:
batch = _info(
{"type": "candleSnapshot", "req": {"coin": coin, "interval": interval, "startTime": cursor, "endTime": end_ms}}
)
if not batch:
break
rows.extend(batch)
last_open = batch[-1]["t"]
if last_open <= cursor or len(batch) < 2:
break
cursor = last_open + 1
if not rows:
return pd.DataFrame(columns=["open", "high", "low", "close", "volume"])
df = pd.DataFrame(rows).drop_duplicates("t")
df.index = pd.to_datetime(df["t"], unit="ms", utc=True)
out = df[["o", "h", "l", "c", "v"]].astype(float)
out.columns = ["open", "high", "low", "close", "volume"]
return out.sort_index()jev-starter/jevlab/judges.py
"""The Jev client. Sends one state plus typed questions, gets typed answers back.
Jev question types (TypeSafe systemone API):
noul yes/no, answered as a probability of "yes"
choice pick one option from a dict of options
score pick a level from a list ordered low -> high
Answers are normalised to:
noul -> {"p": float}
choice -> {"choice": str, "probs": {option: float}}
score -> {"score": float (0-based level, may be an expected value), "probs": [float, ...]}
Reaches Jev through the Vercel AI Gateway (AI_GATEWAY_API_KEY), or directly with a
TypeSafe key (TYPESAFE_API_KEY) if you have one.
"""
from __future__ import annotations
import os
import random
import time
import requests
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
JEV_GATEWAY_URL = "https://ai-gateway.vercel.sh/typesafe/v1/systemone"
JEV_DIRECT_URL = "https://api.typesafe.ai/v1/systemone"
class JudgeError(Exception):
pass
def _post(url: str, key: str, body: dict, timeout: float, retries: int = 6) -> dict:
for attempt in range(retries + 1):
retry_after = None
try:
r = requests.post(
url,
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
json=body,
timeout=timeout,
)
except requests.RequestException as exc:
if attempt == retries:
raise JudgeError(str(exc)) from exc
else:
if r.status_code == 200:
return r.json()
if r.status_code not in (429, 500, 502, 503, 529) or attempt == retries:
raise JudgeError(f"HTTP {r.status_code}: {r.text[:300]}")
retry_after = r.headers.get("retry-after")
# 429 = the free tier is busy. Back off (honouring Retry-After) rather than drop the call.
try:
wait = float(retry_after)
except (TypeError, ValueError):
wait = min(8.0, 0.75 * 2**attempt) + random.random()
time.sleep(wait)
raise JudgeError("unreachable")
# ---------------------------------------------------------------- normalising
def _norm_probs(raw, options: list[str]) -> dict:
if isinstance(raw, dict):
probs = {o: float(raw.get(o, 0.0)) for o in options}
elif isinstance(raw, list) and len(raw) == len(options):
probs = dict(zip(options, map(float, raw)))
else:
return {}
total = sum(probs.values())
return {o: p / total for o, p in probs.items()} if total > 0 else {}
def normalise(questions: dict, raw_answers: dict) -> dict:
"""Coerce any judge's raw answers into the one shape the labs score."""
out = {}
for qid, q in questions.items():
a = raw_answers.get(qid)
if a is None:
raise JudgeError(f"missing answer for '{qid}'")
if q["type"] == "noul":
p = a.get("noul", a.get("p")) if isinstance(a, dict) else a
out[qid] = {"p": max(0.0, min(1.0, float(p)))}
elif q["type"] == "choice":
options = list(q["criteria"])
probs = _norm_probs(a.get("probabilities", a.get("probs")), options)
choice = a.get("choice")
if choice not in options:
if not probs:
raise JudgeError(f"bad choice answer for '{qid}': {a}")
choice = max(probs, key=probs.get)
if not probs:
probs = {o: float(o == choice) for o in options}
out[qid] = {"choice": choice, "probs": probs}
elif q["type"] == "score":
# Jev returns score as an expected level (e.g. 1.69) with probabilities
# keyed "0".."n-1"; other judges may return a label or an index.
levels = q["criteria"]
score = a.get("score")
if isinstance(score, str):
score = levels.index(score) if score in levels else float(score)
raw_p = a.get("probabilities", a.get("probs"))
if isinstance(raw_p, dict) and all(str(k).isdigit() for k in raw_p):
raw_p = {levels[int(k)]: v for k, v in raw_p.items() if int(k) < len(levels)}
probs_d = _norm_probs(raw_p, levels)
probs = [probs_d.get(l, 0.0) for l in levels] if probs_d else [
float(i == round(float(score))) for i in range(len(levels))
]
out[qid] = {"score": max(0.0, min(len(levels) - 1.0, float(score))), "probs": probs}
return out
# ---------------------------------------------------------------- judges
class JevJudge:
name = "jev"
def __init__(self):
direct = os.getenv("TYPESAFE_API_KEY", "").strip()
gateway = os.getenv("AI_GATEWAY_API_KEY", "").strip()
if direct:
self.url, self.key, self.model = JEV_DIRECT_URL, direct, "jev-latest"
elif gateway:
self.url, self.key, self.model = JEV_GATEWAY_URL, gateway, "typesafe-ai/jev"
else:
raise JudgeError("no AI_GATEWAY_API_KEY or TYPESAFE_API_KEY in .env")
def ask(self, state, questions, timeout=20.0, retries=6):
t0 = time.monotonic()
body = {"state": state, "model": self.model, "questions": questions}
data = _post(self.url, self.key, body, timeout, retries)
ms = (time.monotonic() - t0) * 1000
answers = normalise(questions, data.get("answers", data))
return answers, {"judge": self.name, "model": data.get("model", self.model), "latency_ms": round(ms)}jev-starter/jevlab/news.py
"""Crypto headlines from public RSS feeds, and the questions Jev answers about each one."""
from __future__ import annotations
import html
import re
import xml.etree.ElementTree as ET
from email.utils import parsedate_to_datetime
import pandas as pd
import requests
FEEDS = {
"cointelegraph": "https://cointelegraph.com/rss",
"coindesk": "https://www.coindesk.com/arc/outboundfeeds/rss/",
"decrypt": "https://decrypt.co/feed",
"theblock": "https://www.theblock.co/rss.xml",
}
COINS = ["BTC", "ETH", "SOL"]
QUESTIONS = {
"asset": {
"type": "choice",
"instructions": "Which tradable asset does this news most directly affect?",
"criteria": {"BTC": None, "ETH": None, "SOL": None, "whole_crypto_market": None, "none": None},
},
"direction": {
"type": "choice",
"instructions": "Is this news likely to push that asset's price up or down from here?",
"criteria": {"bullish": None, "bearish": None, "neutral": None},
},
"impact": {
"type": "score",
"instructions": "How much is this news likely to move that asset's price?",
"criteria": ["Noise", "Minor", "Notable", "Major"],
},
}
def fetch_headlines(hours: float, quiet: bool = False) -> list[dict]:
cutoff = pd.Timestamp.now(tz="UTC") - pd.Timedelta(hours=hours)
items, seen = [], set()
for source, url in FEEDS.items():
try:
r = requests.get(url, timeout=15, headers={"User-Agent": "jev-lab/0.1 rss reader"})
r.raise_for_status()
root = ET.fromstring(r.content)
except Exception as exc:
if not quiet:
print(f" ! feed {source} unavailable: {str(exc)[:80]}")
continue
n = 0
for it in root.iter("item"):
title = html.unescape((it.findtext("title") or "").strip())
pub = it.findtext("pubDate")
if not title or not pub or title.lower() in seen:
continue
ts = pd.Timestamp(parsedate_to_datetime(pub)).tz_convert("UTC")
if ts < cutoff:
continue
summary = re.sub(r"<[^>]+>", " ", html.unescape(it.findtext("description") or ""))
summary = re.sub(r"\s+", " ", summary).strip()[:400]
seen.add(title.lower())
items.append({"source": source, "headline": title, "summary": summary, "published": ts})
n += 1
if not quiet:
print(f" feed {source:<14} {n} headlines")
return sorted(items, key=lambda x: x["published"])
def judge_call(ans: dict) -> tuple[str | None, int]:
asset = ans["asset"]["choice"]
coin = {"BTC": "BTC", "ETH": "ETH", "SOL": "SOL", "whole_crypto_market": "BTC"}.get(asset)
side = {"bullish": 1, "bearish": -1}.get(ans["direction"]["choice"], 0)
if ans["impact"]["score"] < 2: # only act on "Notable" or "Major"
side = 0
return coin, sidejev-starter/jevlab/server.py
"""A tiny local web server for the dashboards. Serves this folder on 127.0.0.1 only."""
from __future__ import annotations
import threading
import webbrowser
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
class _Handler(SimpleHTTPRequestHandler):
page = "loop.html" # which dashboard "/" opens
def end_headers(self):
self.send_header("Cache-Control", "no-store")
super().end_headers()
def do_GET(self):
if self.path in ("/", "/index.html"):
self.path = f"/dashboard/{self.page}"
return super().do_GET()
def log_message(self, *args): # keep the terminal clean
pass
def serve(port: int = 8765, open_browser: bool = True, page: str = "loop.html") -> ThreadingHTTPServer:
handler = type("Handler", (_Handler,), {"page": page})
try:
server = ThreadingHTTPServer(("127.0.0.1", port), partial(handler, directory=str(ROOT)))
except OSError:
raise SystemExit(f" port {port} is busy. Stop the other dashboard (Ctrl+C) or add --port {port + 1}")
threading.Thread(target=server.serve_forever, daemon=True).start()
url = f"http://127.0.0.1:{port}/"
print(f" dashboard: {url}")
if open_browser:
webbrowser.open(url)
return serverjev-starter/jevlab/strategy.py
"""YOUR STRATEGY. This is the one file you're meant to edit.
Jev makes a call several times a second ("buy, 91% sure"). Your strategy decides
which of those calls actually become trades. Most should end in "hold": every
trade has a cost, so a bot that trades every call bleeds.
decide() is called every time Jev answers, with:
call Jev's answer, e.g. {"side": "buy", "conf": 0.91}
market the live numbers Jev was shown, for example:
return_5s_bps, return_30s_bps price change over 5s / 30s (1 bps = 0.01%)
top_of_book_imbalance -1 (all sellers) .. +1 (all buyers) at the best price
depth5_imbalance the same, over the top 5 price levels
aggressor_buy_share_5s / _30s share of recent trade volume that was buyers (0..1)
trades_last_5s how busy the market is right now
spread_bps, microprice_vs_mid_bps, tick_volatility_60s_bps
claude_bias 24/7 bot only: "long", "short" or "flat", Claude's
big-picture call, refreshed every few minutes
position 1 = you're long, -1 = you're short, 0 = flat
seconds_since_trade seconds since your last fill
Return one of:
"buy" go (or stay) long
"sell" go (or stay) short
"flat" close the position
"hold · reason" do nothing. The reason shows up in the dashboard feed.
The loop handles everything else: placing the (paper) order, fees, the
dashboard. Want a different strategy? Describe it to Claude in one sentence
and ask it to rewrite decide(), e.g. "only buy when Jev is 90%+ sure AND
buyers have been in control for the last 30 seconds".
"""
# The defaults are the three rules from the video.
SETTINGS = {
"min_conf": 0.85, # only act when Jev is at least this sure
"min_hold": 15, # seconds to sit still after a trade (no flip-flopping)
}
DESCRIPTION = f"trade only at ≥{SETTINGS['min_conf']:.0%} conviction · ≥{SETTINGS['min_hold']}s between flips"
def decide(call: dict, market: dict, position: int, seconds_since_trade: float) -> str:
want = 1 if call["side"] == "buy" else -1
bias = market.get("claude_bias") # only set on the 24/7 bot, where Claude is the brain
if bias == "flat":
return "flat" if position else "hold · Claude says stay out"
if (bias == "long" and position < 0) or (bias == "short" and position > 0):
return "flat" # Claude changed its mind: get out of the old direction first
if (bias == "long" and want < 0) or (bias == "short" and want > 0):
return f"hold · against Claude's {bias} bias"
if call["conf"] < SETTINGS["min_conf"]:
return "hold · low conviction"
if want == position:
return "hold · already " + ("long" if want > 0 else "short")
if seconds_since_trade < SETTINGS["min_hold"]:
return "hold · too soon to flip"
return call["side"]
# ---------------------------------------------------------------------------
# Example: trade with the trend only. Uncomment to use it (and delete the
# decide() above).
#
# def decide(call, market, position, seconds_since_trade):
# trend_up = market.get("return_30s_bps", 0) > 0 and market.get("aggressor_buy_share_30s", 0.5) > 0.55
# trend_down = market.get("return_30s_bps", 0) < 0 and market.get("aggressor_buy_share_30s", 0.5) < 0.45
# if call["conf"] < 0.8:
# return "hold · low conviction"
# if call["side"] == "buy" and trend_up and position != 1:
# return "buy"
# if call["side"] == "sell" and trend_down and position != -1:
# return "sell"
# return "hold · against the trend"jev-starter/jevlab/loop.py
"""The Jev loop: Jev trading calls landing on a live Hyperliquid price feed,
with YOUR strategy (jevlab/strategy.py) deciding which calls become trades.
uv run python -m jevlab loop # HYPE, 10 minutes
uv run python -m jevlab loop --coin BTC --minutes 0 # 0 = run until Ctrl+C
The dashboard plots the live microprice (size-weighted mid) from Hyperliquid's
best-bid/ask stream, about 8 updates a second. Jev is asked "buy or sell?" every
--pace seconds (default 0.3s). Calls overlap, each answer lands the moment it
arrives, and the pace backs off by itself if the key gets rate-limited (the free
Vercel tier allows roughly one call every 2 seconds; $5 of credits removes that).
Every Jev answer goes to strategy.decide(). If it says "buy" or "sell", the loop
rests a limit order at the best bid/ask (maker fee, no spread). The order counts
as filled only when the market trades THROUGH its price, and it's cancelled
after --maker-wait seconds. --taker trades immediately at the ask/bid instead.
Positions are $1,000, P&L is shown before and after fees, and nothing is hidden.
PAPER ONLY: this reads public market data and simulates fills. It never places
a real order and never needs an exchange key.
"""
from __future__ import annotations
import json
import os
import statistics
import threading
import time
from collections import deque
from concurrent.futures import ThreadPoolExecutor
import websocket
from . import hl
from .core import RESULTS, console, header
from . import strategy
from .judges import JevJudge, JudgeError
from .server import serve
WS_URL = "wss://api.hyperliquid.xyz/ws"
NOTIONAL = 1000.0 # $ per side of the paper position
TICK_KEEP_S = 300 # price history kept for the dashboard
QUESTIONS = {
"side": {
"type": "choice",
"instructions": "Which side should this bot hold for the next few seconds: buy (be long) or sell (be short)?",
"criteria": {"buy": None, "sell": None},
}
}
class Market:
"""Live Hyperliquid feed for one coin: best bid/ask (~8 updates a second),
top-5 depth, and every trade. Runs in a background thread and reconnects."""
def __init__(self, coin: str):
self.coin = coin
self.lock = threading.Lock()
self.bbo = None # (bid, ask, bid_sz, ask_sz, t)
self.depth5 = None # (bid_usd, ask_usd, t)
self.trades = deque() # (t, is_buy, px, sz)
self.mids = deque() # (t, mid) on every bbo update
self.ticks = deque() # (t, microprice, bid, ask), thinned to 20/s for the dashboard
self.ready = threading.Event()
def start(self) -> None:
def on_open(ws):
for sub in ("bbo", "l2Book", "trades"):
ws.send(json.dumps({"method": "subscribe", "subscription": {"type": sub, "coin": self.coin}}))
self.app = websocket.WebSocketApp(WS_URL, on_open=on_open, on_message=lambda ws, m: self._on_message(m))
threading.Thread(target=lambda: self.app.run_forever(ping_interval=20, reconnect=3), daemon=True).start()
def _on_message(self, raw: str) -> None:
msg = json.loads(raw)
ch, data, now = msg.get("channel"), msg.get("data"), time.time()
with self.lock:
if ch == "bbo" and data.get("bbo") and None not in data["bbo"]:
b, a = data["bbo"]
self.bbo = (float(b["px"]), float(a["px"]), float(b["sz"]), float(a["sz"]), now)
bid, ask, bsz, asz, _ = self.bbo
self.mids.append((now, (bid + ask) / 2))
if not self.ticks or now - self.ticks[-1][0] >= 0.05:
self.ticks.append((now, (bid * asz + ask * bsz) / (bsz + asz), bid, ask))
self.ready.set()
elif ch == "l2Book":
bids, asks = data["levels"]
self.depth5 = (sum(float(l["px"]) * float(l["sz"]) for l in bids[:5]),
sum(float(l["px"]) * float(l["sz"]) for l in asks[:5]), now)
elif ch == "trades":
for tr in data:
self.trades.append((tr["time"] / 1000, tr["side"] == "B", float(tr["px"]), float(tr["sz"])))
for dq, keep in ((self.mids, 120), (self.trades, 120), (self.ticks, TICK_KEEP_S)):
while dq and now - dq[0][0] > keep:
dq.popleft()
def snapshot(self) -> dict:
"""The state Jev sees: small, numeric, and fresh. No dates, no coin name."""
with self.lock:
bid, ask, bsz, asz, _ = self.bbo
now = time.time()
mid = (bid + ask) / 2
micro = (bid * asz + ask * bsz) / (bsz + asz)
def ret(sec):
past = next((m for t, m in self.mids if t >= now - sec), mid)
return round(1e4 * (mid / past - 1), 2)
def flow(sec):
tr = [(b, px * sz) for t, b, px, sz in self.trades if t >= now - sec]
vol = sum(v for _, v in tr)
return (round(sum(v for b, v in tr if b) / vol, 3) if vol else 0.5), len(tr)
buy5, n5 = flow(5)
buy30, _ = flow(30)
m60 = [m for t, m in self.mids if t >= now - 60]
steps = [1e4 * (b / a - 1) for a, b in zip(m60, m60[1:]) if a != b]
state = {
"spread_bps": round(1e4 * (ask - bid) / mid, 3),
"microprice_vs_mid_bps": round(1e4 * (micro / mid - 1), 3),
"top_of_book_imbalance": round((bsz - asz) / (bsz + asz), 3),
"aggressor_buy_share_5s": buy5,
"aggressor_buy_share_30s": buy30,
"trades_last_5s": n5,
"return_5s_bps": ret(5),
"return_30s_bps": ret(30),
"tick_volatility_60s_bps": round(statistics.pstdev(steps), 3) if len(steps) > 2 else 0.0,
}
if self.depth5:
bd, ad, _ = self.depth5
state["depth5_imbalance"] = round((bd - ad) / (bd + ad), 3)
return {"mid": mid, "micro": micro, "bid": bid, "ask": ask, "spread_bps": 1e4 * (ask - bid) / mid, "state": state}
def trades_since(self, t0: float) -> list[tuple]:
with self.lock:
return [tr for tr in self.trades if tr[0] >= t0]
def recent_ticks(self, seconds: float = 90) -> list[list[float]]:
with self.lock:
now = time.time()
return [[round(t, 3), m, b, a] for t, m, b, a in self.ticks if t >= now - seconds]
class PaperBook:
"""A $1,000 long-or-short paper position. Tracks coins and cash, so buying at
the bid or selling at the ask is credited exactly, and fees are kept apart."""
def __init__(self):
self.pos, self.qty, self.cash, self.fees = 0, 0.0, 0.0, 0.0
self.trades = 0
self.equity = [] # [t, before_fees_usd, after_fees_usd]
def fill(self, target: int, px: float, fee_rate: float) -> None:
dq = target * NOTIONAL / px - self.qty
self.cash -= dq * px
self.fees += abs(dq) * px * fee_rate
self.qty += dq
self.pos = target
self.trades += 1
def gross(self, mid: float) -> float:
return self.cash + self.qty * mid
def snap(self, t: float, mid: float) -> dict:
g = self.gross(mid)
self.equity.append([round(t, 2), round(g, 3), round(g - self.fees, 3)])
del self.equity[:-2400]
return {"pos": self.pos, "gross": round(g, 3), "fees": round(self.fees, 3), "net": round(g - self.fees, 3),
"trades": self.trades, "equity": self.equity}
def run_loop(coin: str, pace_s: float, minutes: float, port: int, open_browser: bool, late_ms: float = 1500,
maker: bool = True, maker_wait: float = 10.0) -> None:
exec_txt = f"limit orders ({hl.MAKER_FEE * 1e4:.1f} bps)" if maker else f"market orders ({hl.TAKER_FEE * 1e4:.1f} bps + spread)"
header("THE JEV LOOP", f"{coin}-PERP · Jev calls as fast as the key allows (floor {pace_s:g}s) · "
f"strategy: {strategy.DESCRIPTION} · {exec_txt} · "
f"{'until Ctrl+C' if not minutes else f'{minutes:g} min'} · $1,000 paper position")
try:
jev = JevJudge()
except JudgeError as exc:
raise SystemExit(f" Jev key missing: {exc}. Add AI_GATEWAY_API_KEY to .env first.")
market = Market(coin)
market.start()
if not market.ready.wait(15):
raise SystemExit(" no price feed from Hyperliquid after 15s, check the connection")
RESULTS.mkdir(exist_ok=True)
out = RESULTS / "loop.json"
log = open(RESULTS / "loop_log.jsonl", "a")
book = PaperBook()
decisions: list[dict] = []
fills: list[dict] = []
latencies: list[float] = []
counts = {"ok": 0, "late": 0, "throttled": 0, "error": 0}
lock = threading.Lock()
st = {"interval": pace_s, "streak": 0, "prev": None, "order": None, "last_trade_t": 0.0, "hits": 0, "scored": 0}
started = time.time()
serve(port, open_browser, page="loop.html")
def check_order() -> None:
"""Fill the resting limit order only if the market traded through its price."""
o = st["order"]
if not o:
return
now = time.time()
buying = o["buying"]
through = any((buying and px < o["px"]) or (not buying and px > o["px"])
for _, _, px, _ in market.trades_since(o["t"]))
snap = market.snapshot()
through |= (buying and snap["ask"] < o["px"]) or (not buying and snap["bid"] > o["px"])
if through:
book.fill(o["target"], o["px"], hl.MAKER_FEE)
fills.append({"t": now, "side": "buy" if buying else "sell", "px": o["px"], "kind": "limit",
"call": o["call"], "wait_s": round(now - o["t"], 1)})
st["order"], st["last_trade_t"] = None, now
elif now > o["expires"]:
st["order"] = None
def write(status: str) -> None:
with lock:
check_order()
mid = market.snapshot()["mid"]
recent = [d["t"] for d in decisions if d["t"] >= time.time() - 10]
payload = {
"status": status, "coin": coin, "pace": pace_s, "interval": round(st["interval"], 3),
"rate_per_s": round(len(recent) / 10, 2), "late_ms": late_ms,
"strategy_note": strategy.DESCRIPTION, "execution": "maker" if maker else "taker", "maker_wait": maker_wait,
"started": started, "updated": time.time(), "model": jev.model, "notional": NOTIONAL,
"maker_fee_bps": hl.MAKER_FEE * 1e4, "taker_fee_bps": hl.TAKER_FEE * 1e4,
"counts": dict(counts), "blocks": len(decisions),
"last_ms": latencies[-1] if latencies else None,
"avg_ms": round(statistics.mean(latencies[-200:])) if latencies else None,
"hits": st["hits"], "scored": st["scored"],
"order": st["order"], "decisions": decisions[-200:], "fills": fills[-100:],
"book": book.snap(time.time(), mid), "ticks": market.recent_ticks(),
}
tmp = out.with_suffix(".tmp")
tmp.write_text(json.dumps(payload))
os.replace(tmp, out)
def decide(rec: dict, now: dict) -> str:
"""Hand Jev's call to strategy.decide(), then turn its answer into a (paper) order. Runs under the lock."""
try:
choice = strategy.decide({"side": rec["side"], "conf": rec["conf"]}, rec["state"], book.pos,
time.time() - st["last_trade_t"])
except Exception as exc: # a broken strategy shouldn't kill the loop: show it and hold
return f"hold · strategy error: {str(exc)[:60]}"
if choice not in ("buy", "sell", "flat"):
return str(choice or "hold")
want = {"buy": 1, "sell": -1, "flat": 0}[choice]
if want == book.pos:
if st["order"] and st["order"]["target"] != want:
st["order"] = None
return "hold · already " + {1: "long", -1: "short", 0: "flat"}[want]
if st["order"] and st["order"]["target"] == want:
return "hold · limit order working"
if maker:
buying = want > book.pos
px = now["bid"] if buying else now["ask"]
st["order"] = {"target": want, "buying": buying, "px": px, "t": time.time(), "expires": time.time() + maker_wait,
"call": rec["block"]}
return f"limit {'buy' if buying else 'sell'} @ {px:g}"
buying = want > book.pos
px = now["ask"] if buying else now["bid"]
book.fill(want, px, hl.TAKER_FEE)
fills.append({"t": time.time(), "side": "buy" if buying else "sell", "px": px, "kind": "market", "call": rec["block"], "wait_s": 0})
st["last_trade_t"] = time.time()
return f"market {choice} @ {px:g}"
def ask(seq: int) -> None:
snap = market.snapshot()
rec = {"block": seq, "t_ask": time.time(), "state": snap["state"]}
try:
ans, meta = jev.ask(snap["state"], QUESTIONS, timeout=5.0, retries=0)
side = ans["side"]["choice"]
rec.update(side=side, conf=round(ans["side"]["probs"][side], 3), ms=meta["latency_ms"],
status="ok" if meta["latency_ms"] <= late_ms else "late")
except JudgeError as exc:
rec.update(side=None, conf=None, ms=None, status="throttled" if "429" in str(exc) else "error",
error=str(exc)[:160])
now = market.snapshot() # the market as it is when the answer ARRIVES
rec.update(t=time.time(), mid=now["mid"], micro=now["micro"])
with lock:
counts[rec["status"]] += 1
if rec["ms"]:
latencies.append(rec["ms"])
if rec["status"] == "throttled": # back off hard on a 429, creep back on good answers
st["interval"], st["streak"] = min(4.0, st["interval"] * 1.6), 0
elif rec["status"] == "ok":
st["streak"] += 1
if st["streak"] >= 3:
st["interval"] = max(pace_s, st["interval"] * 0.9)
prev = st["prev"]
if prev and now["mid"] != prev["mid"]: # was the previous call's direction right, up to this one?
st["hits"] += (now["mid"] > prev["mid"]) == (prev["side"] == "buy")
st["scored"] += 1
if rec["status"] == "ok":
st["prev"] = rec
rec["action"] = decide(rec, now)
else:
rec["action"] = "hold · late" if rec["status"] == "late" else rec["status"]
decisions.append(rec)
log.write(json.dumps(rec) + "\n")
colour = {"buy": "#3fd68a", "sell": "#ff5d6c"}.get(rec["side"], "#f5b53d")
console.print(f" {rec['block']:>5} [{colour}]{(rec['side'] or '-').upper():<5}[/] "
f"{(rec['conf'] or 0):.2f} {rec['ms'] or '-':>5} ms [dim]{rec['action']}[/]")
console.print(" [dim] # side conf ms action[/]")
stop = threading.Event()
def writer(): # fills resting orders and keeps the dashboard's price history fresh
while not stop.is_set():
write("running")
stop.wait(0.25)
threading.Thread(target=writer, daemon=True).start()
seq = 0
try:
with ThreadPoolExecutor(max_workers=8) as pool:
while not minutes or time.time() - started < minutes * 60:
seq += 1
pool.submit(ask, seq)
time.sleep(st["interval"])
except KeyboardInterrupt:
pass
stop.set()
write("done")
log.close()
mid = market.snapshot()["mid"]
g = book.gross(mid)
console.print(f"\n {len(decisions)} Jev calls · {book.trades} trades · before fees ${g:+.2f} · fees ${book.fees:.2f} · "
f"after fees [bold]{'[#3fd68a]' if g - book.fees > 0 else '[#ff5d6c]'}${g - book.fees:+.2f}[/][/]")
console.print(f" direction right {st['hits']}/{st['scored']} · late {counts['late']} · throttled {counts['throttled']}")jev-starter/jevlab/newsroom.py
"""The Jev newsroom: real crypto headlines arriving on a news terminal, Jev
reading each one live, and the real price move that followed.
uv run python -m jevlab newsroom # replay the last 36h, one headline every 6s
uv run python -m jevlab newsroom --gap 4 --hours 24
Why a replay: crypto news only lands a few times an hour, which is too slow to
watch. So the newsroom replays the last day or two of real headlines in fast
motion. Every headline is sent to Jev live as it appears on screen (real answer,
real response time), and because those headlines already happened, the chart can
show what the price actually did over the next hour. The headlines are newer
than Jev's training data, and Jev never sees the publish time.
The trading rule: trade only headlines Jev
rates "Notable" or "Major", in the direction it calls, from 1 minute after
publishing, held for 60 minutes. No orders are ever placed.
"""
from __future__ import annotations
import json
import os
import time
import pandas as pd
from . import hl
from .core import RESULTS, console, header
from .judges import JevJudge, JudgeError
from .news import COINS, QUESTIONS, fetch_headlines, judge_call
from .server import serve
BEFORE_MIN, AFTER_MIN = 10, 60 # the reaction chart spans publish-10m .. publish+60m
def price_paths(items: list[dict]) -> None:
"""Attach each coin's % move around every headline, from real 1m candles."""
start = int((items[0]["published"] - pd.Timedelta(minutes=BEFORE_MIN + 5)).timestamp() * 1000)
candles = {c: hl.candles(c, "1m", start) for c in COINS}
for it in items:
entry_t = it["published"].floor("min") + pd.Timedelta(minutes=1)
it["paths"] = {}
for c, df in candles.items():
if entry_t not in df.index:
continue
base = df.loc[entry_t, "open"]
window = df.loc[entry_t - pd.Timedelta(minutes=BEFORE_MIN): entry_t + pd.Timedelta(minutes=AFTER_MIN), "close"]
it["paths"][c] = [[round((ts - entry_t).total_seconds() / 60, 1), round(float(1e4 * (px / base - 1)), 2)]
for ts, px in window.items()]
def run_newsroom(hours: float, every: float, port: int, open_browser: bool) -> None:
header("THE JEV NEWSROOM", f"replaying the last {hours:g}h of real crypto headlines · one every {every:g}s · "
f"Jev reads each one live · then the real {AFTER_MIN}-minute price move")
try:
jev = JevJudge()
except JudgeError as exc:
raise SystemExit(f" Jev key missing: {exc}. Add AI_GATEWAY_API_KEY to .env first.")
def load_items() -> list[dict]:
now = pd.Timestamp.now(tz="UTC")
fresh = [i for i in fetch_headlines(hours) if i["published"] + pd.Timedelta(minutes=AFTER_MIN + 2) < now]
if fresh:
console.print(f" {len(fresh)} headlines · loading the price moves after each one…")
price_paths(fresh)
return fresh
items = load_items()
if not items:
raise SystemExit(" no headlines old enough to score yet")
RESULTS.mkdir(exist_ok=True)
out = RESULTS / "newsroom.json"
score = {"read": 0, "tagged": 0, "trades": 0, "right": 0, "net_bps": 0.0, "latency_ms": []}
feed: list[dict] = []
started = time.time()
rnd = {"n": 1, "index": 0}
def write(status: str) -> None:
lat = sorted(score["latency_ms"])
payload = {"status": status, "hours": hours, "every": every, "total": len(items), "index": rnd["index"], "round": rnd["n"],
"started": started, "updated": time.time(), "model": jev.model,
"cost_bps": round(2e4 * hl.TAKER_FEE + 1, 1), "after_min": AFTER_MIN, "before_min": BEFORE_MIN,
"score": {**{k: v for k, v in score.items() if k != "latency_ms"},
"median_ms": lat[len(lat) // 2] if lat else None},
"feed": feed[-300:]}
tmp = out.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, default=str))
os.replace(tmp, out)
write("running")
serve(port, open_browser, page="newsroom.html")
time.sleep(2.5) # let the page load before the first headline lands
try:
while True:
for it in items:
rnd["index"] += 1
t0 = time.time()
row = {"headline": it["headline"], "source": it["source"], "published": it["published"].timestamp(),
"paths": it["paths"], "arrived": t0, "answer": None}
feed.append(row)
write("running") # the headline hits the wire before Jev has answered
state = {"headline": it["headline"], "summary": it["summary"], "source": it["source"]}
try:
ans, meta = jev.ask(state, QUESTIONS, timeout=10.0, retries=3)
except JudgeError as exc:
row.update(error=str(exc)[:120], answered=time.time())
write("running")
console.print(f" [#f5b53d]skip[/] {it['headline'][:70]} ({str(exc)[:40]})")
time.sleep(max(0.0, every - (time.time() - t0)))
continue
coin, side = judge_call(ans)
side = side if coin else 0 # no tradeable coin, no trade
path = it["paths"].get(coin or "BTC") or []
ret = next((v for m, v in path if m == AFTER_MIN), None)
row.update(answer=ans, ms=meta["latency_ms"], answered=time.time(), coin=coin,
direction=ans["direction"]["choice"], impact=ans["impact"]["score"],
trade=bool(side), side=side, ret_bps=ret)
score["read"] += 1
score["latency_ms"].append(meta["latency_ms"])
if coin:
score["tagged"] += 1
if side and ret is not None:
score["trades"] += 1
score["right"] += int(side * ret > 0)
row["net_bps"] = round(side * ret - (2e4 * hl.TAKER_FEE + 1), 1)
score["net_bps"] += row["net_bps"]
write("running")
tag = f"{coin or '-':<4} {row['direction']:<8} impact {row['impact']:.1f}"
console.print(f" {meta['latency_ms']:>4} ms {tag} {'[bold]TRADE[/]' if side else '[dim]no trade[/]'} "
f"[dim]{it['headline'][:60]}[/]")
time.sleep(max(0.0, every - (time.time() - t0)))
# round finished: pull the latest headlines and go again, with a fresh scoreboard
console.print(f"\n round {rnd['n']} done · reloading headlines for the next round")
items = load_items() or items
rnd["n"] += 1
rnd["index"] = 0
score.update(read=0, tagged=0, trades=0, right=0, net_bps=0.0, latency_ms=[])
except KeyboardInterrupt:
pass
write("done")
console.print(f"\n {score['read']} headlines read · {score['tagged']} tagged to a coin · {score['trades']} worth trading · "
f"{score['right']} right · {score['net_bps']:+.1f} bps after costs")jev-starter/jevlab/brain.py
"""Claude as the big-picture brain (Path 2).
Every few minutes, Claude reads a plain summary of the market (recent price moves,
volatility, funding, and the latest crypto headlines) and sets the bias for the
next stretch: long, short, or flat. Jev and strategy.py still make the fast calls,
but only in the direction Claude allows.
Uses the same Vercel AI Gateway key as Jev. The model is CLAUDE_MODEL in .env
(default anthropic/claude-sonnet-5). Claude on the gateway needs AI Gateway
credits (the paid tier).
"""
from __future__ import annotations
import json
import os
import re
import threading
import time
import requests
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
CHAT_URL = "https://ai-gateway.vercel.sh/v1/chat/completions"
BYBIT_REST = "https://api.bybit.com/v5/market"
def market_summary(symbol: str) -> dict:
"""Plain numbers Claude can reason about, from Bybit's public market data."""
k = requests.get(f"{BYBIT_REST}/kline", params={"category": "linear", "symbol": symbol, "interval": "5", "limit": 49},
timeout=10).json()["result"]["list"]
closes = [float(r[4]) for r in reversed(k)] # oldest -> newest, 5-minute bars, last ~4 hours
t = requests.get(f"{BYBIT_REST}/tickers", params={"category": "linear", "symbol": symbol}, timeout=10).json()["result"]["list"][0]
pct = lambda a, b: round(100 * (b / a - 1), 2)
moves = [abs(pct(a, b)) for a, b in zip(closes, closes[1:])]
return {
"symbol": symbol,
"price": closes[-1],
"change_15m_pct": pct(closes[-4], closes[-1]),
"change_1h_pct": pct(closes[-13], closes[-1]),
"change_4h_pct": pct(closes[0], closes[-1]),
"change_24h_pct": round(100 * float(t["price24hPcnt"]), 2),
"avg_5m_move_pct": round(sum(moves) / len(moves), 3),
"high_4h": max(closes), "low_4h": min(closes),
"funding_rate_pct": round(100 * float(t.get("fundingRate") or 0), 4),
}
def headlines(limit: int = 8) -> list[str]:
try:
from .news import fetch_headlines
return [h["headline"] for h in fetch_headlines(6, quiet=True)][-limit:]
except Exception:
return []
PROMPT = """You are the portfolio manager for a small, cautious crypto trading bot.
A fast AI makes buy/sell calls every second, but it can only trade in the direction you allow.
Decide the bias for the next {minutes} minutes: "long" (only buying allowed), "short" (only selling allowed),
or "flat" (stay out, close any position).
If the evidence leans one way (momentum across timeframes, funding, the news), pick that side and let
"confidence" say how strongly it leans. Choose "flat" only when the signals genuinely conflict or the market is dead.
Market data:
{summary}
Latest crypto headlines:
{news}
Reply with ONLY a JSON object: {{"bias": "long" | "short" | "flat", "confidence": 0.0-1.0, "reason": "one short sentence"}}"""
class Brain:
def __init__(self, symbol: str, every_min: float):
self.key = os.getenv("AI_GATEWAY_API_KEY", "").strip()
self.model = os.getenv("CLAUDE_MODEL", "anthropic/claude-sonnet-5").strip()
self.symbol, self.every = symbol, every_min
self.state = {"bias": None, "confidence": None, "reason": "waiting for Claude's first read", "t": None,
"model": self.model, "ms": None, "error": None}
self.lock = threading.Lock()
def think(self) -> None:
t0 = time.time()
try:
summary = market_summary(self.symbol)
news = "\n".join(f"- {h}" for h in headlines()) or "- (none)"
body = {"model": self.model, "temperature": 0, "max_tokens": 200, "messages": [
{"role": "user", "content": PROMPT.format(minutes=self.every, summary=json.dumps(summary, indent=1), news=news)}]}
r = requests.post(CHAT_URL, headers={"Authorization": f"Bearer {self.key}"}, json=body, timeout=60)
if r.status_code != 200:
raise RuntimeError(f"HTTP {r.status_code}: {r.text[:160]}")
text = r.json()["choices"][0]["message"]["content"]
out = json.loads(re.search(r"\{.*\}", text, re.S).group(0))
bias = out.get("bias") if out.get("bias") in ("long", "short", "flat") else "flat"
with self.lock:
self.state.update(bias=bias, confidence=float(out.get("confidence") or 0), reason=str(out.get("reason", ""))[:160],
t=time.time(), ms=round((time.time() - t0) * 1000), error=None, summary=summary)
except Exception as exc: # keep the last bias; if there's never been one, the bot stays flat
with self.lock:
self.state["error"] = str(exc)[:160]
if self.state["bias"] is None:
self.state.update(bias="flat", reason="Claude unavailable, staying out", t=time.time())
def start(self, stop: threading.Event) -> None:
def run():
while not stop.is_set():
self.think()
stop.wait(self.every * 60)
threading.Thread(target=run, daemon=True).start()
def current(self) -> dict:
with self.lock:
return dict(self.state)jev-starter/jevlab/bybit.py
"""Bybit: the live price feed and order placement for the 24/7 bot (Path 2).
Two modes, set with BYBIT_MODE in .env:
demo (default) Bybit Demo Trading: real market prices, fake money.
live Real money. Refused unless JEV_LIVE_CONFIRM is set to the exact
phrase in LIVE_PHRASE. Only the account owner should ever set it.
Orders are post-only limit orders at the best bid (to buy) or best ask (to sell),
so the bot never crosses the spread and pays the maker fee. An order that isn't
filled within the wait time is cancelled.
"""
from __future__ import annotations
import json
import math
import os
import threading
import time
import websocket
from dotenv import load_dotenv
from pybit.unified_trading import HTTP
from .loop import Market
load_dotenv(os.path.join(os.path.dirname(__file__), "..", ".env"))
WS_PUBLIC = "wss://stream.bybit.com/v5/public/linear"
LIVE_PHRASE = "I accept the risk of trading real money"
class BybitError(Exception):
pass
def bybit_mode() -> str:
mode = os.getenv("BYBIT_MODE", "demo").strip().lower() or "demo"
if mode not in ("demo", "live"):
raise BybitError(f"BYBIT_MODE must be 'demo' or 'live', not '{mode}'")
if mode == "live" and os.getenv("JEV_LIVE_CONFIRM", "").strip() != LIVE_PHRASE:
raise BybitError("BYBIT_MODE=live, but JEV_LIVE_CONFIRM isn't set to the exact confirmation phrase. "
"Refusing to trade real money.")
return mode
class BybitMarket(Market):
"""Same live snapshot as the Hyperliquid feed, from Bybit's public stream:
best bid/ask (level 1 order book, ~25 updates a second) and every trade."""
def __init__(self, symbol: str):
super().__init__(symbol)
self.symbol = symbol
def start(self) -> None:
topics = [f"orderbook.1.{self.symbol}", f"publicTrade.{self.symbol}"]
def on_open(ws):
ws.send(json.dumps({"op": "subscribe", "args": topics}))
def heartbeat(): # Bybit drops connections that don't ping every ~20s
while ws.sock and ws.sock.connected:
time.sleep(18)
try:
ws.send(json.dumps({"op": "ping"}))
except Exception:
return
threading.Thread(target=heartbeat, daemon=True).start()
self.app = websocket.WebSocketApp(WS_PUBLIC, on_open=on_open, on_message=lambda ws, m: self._on_bybit(m))
threading.Thread(target=lambda: self.app.run_forever(reconnect=3), daemon=True).start()
def _on_bybit(self, raw: str) -> None:
msg = json.loads(raw)
topic, data, now = msg.get("topic", ""), msg.get("data"), time.time()
with self.lock:
if topic.startswith("orderbook.1.") and data and data.get("b") and data.get("a"):
bid, bsz = map(float, data["b"][0])
ask, asz = map(float, data["a"][0])
self.bbo = (bid, ask, bsz, asz, now)
self.mids.append((now, (bid + ask) / 2))
if not self.ticks or now - self.ticks[-1][0] >= 0.05:
self.ticks.append((now, (bid * asz + ask * bsz) / (bsz + asz), bid, ask))
self.ready.set()
elif topic.startswith("publicTrade.") and data:
for tr in data:
self.trades.append((tr["T"] / 1000, tr["S"] == "Buy", float(tr["p"]), float(tr["v"])))
for dq, keep in ((self.mids, 120), (self.trades, 120), (self.ticks, 300)):
while dq and now - dq[0][0] > keep:
dq.popleft()
class BybitTrader:
"""Places and tracks post-only limit orders on one USDT perpetual."""
def __init__(self, symbol: str, mode: str):
key, secret = os.getenv("BYBIT_API_KEY", "").strip(), os.getenv("BYBIT_API_SECRET", "").strip()
if not key or not secret:
raise BybitError("BYBIT_API_KEY / BYBIT_API_SECRET are missing from .env")
self.symbol, self.mode = symbol, mode
self.http = HTTP(demo=(mode == "demo"), api_key=key, api_secret=secret, recv_window=10000)
info = HTTP().get_instruments_info(category="linear", symbol=symbol)["result"]["list"]
if not info:
raise BybitError(f"{symbol} isn't a Bybit USDT perpetual")
lot, price = info[0]["lotSizeFilter"], info[0]["priceFilter"]
self.qty_step, self.min_qty = float(lot["qtyStep"]), float(lot["minOrderQty"])
self.min_notional = float(lot.get("minNotionalValue") or 5)
self.tick = float(price["tickSize"])
# --- helpers -----------------------------------------------------------------
def _decimals(self, step: float) -> int:
return max(0, -int(math.floor(math.log10(step)))) if step < 1 else 0
def round_qty(self, qty: float) -> float:
return round(math.floor(qty / self.qty_step + 1e-9) * self.qty_step, self._decimals(self.qty_step))
def fmt_px(self, px: float) -> str:
return f"{round(round(px / self.tick) * self.tick, self._decimals(self.tick)):.{self._decimals(self.tick)}f}"
def _call(self, fn, **kw) -> dict:
try:
res = fn(**kw)
except Exception as exc: # pybit raises on non-zero retCode
raise BybitError(str(exc)[:300]) from exc
if res.get("retCode") not in (0, None):
raise BybitError(f"{res.get('retCode')}: {res.get('retMsg')}")
return res.get("result", {})
# --- account -------------------------------------------------------------------
def equity_usdt(self) -> float | None:
res = self._call(self.http.get_wallet_balance, accountType="UNIFIED")
for acct in res.get("list", []):
if acct.get("totalEquity"):
return float(acct["totalEquity"])
return None
def position(self) -> tuple[float, float]:
"""Signed size in coins (+ long, - short) and average entry price."""
rows = self._call(self.http.get_positions, category="linear", symbol=self.symbol).get("list", [])
size = sum(float(r["size"]) * (1 if r["side"] == "Buy" else -1) for r in rows if float(r.get("size") or 0))
avg = next((float(r["avgPrice"]) for r in rows if float(r.get("size") or 0)), 0.0)
return size, avg
# --- orders --------------------------------------------------------------------
def place_post_only(self, side: str, qty: float, px: float, reduce_only: bool = False) -> str:
res = self._call(self.http.place_order, category="linear", symbol=self.symbol, side="Buy" if side == "buy" else "Sell",
orderType="Limit", qty=str(qty), price=self.fmt_px(px), timeInForce="PostOnly",
reduceOnly=reduce_only, orderLinkId=f"jev-{int(time.time() * 1000)}")
return res["orderId"]
def order(self, order_id: str) -> dict:
"""Current state of one order: status, filled qty, average price, fee paid."""
for fn in (self.http.get_open_orders, self.http.get_order_history):
rows = self._call(fn, category="linear", symbol=self.symbol, orderId=order_id).get("list", [])
if rows:
r = rows[0]
return {"status": r["orderStatus"], "filled": float(r.get("cumExecQty") or 0),
"avg_px": float(r.get("avgPrice") or 0), "fee": float(r.get("cumExecFee") or 0)}
return {"status": "Unknown", "filled": 0.0, "avg_px": 0.0, "fee": 0.0}
def cancel(self, order_id: str) -> None:
try:
self._call(self.http.cancel_order, category="linear", symbol=self.symbol, orderId=order_id)
except BybitError:
pass # already filled or gone
def cancel_all(self) -> None:
try:
self._call(self.http.cancel_all_orders, category="linear", symbol=self.symbol)
except BybitError:
pass
def flatten(self) -> dict | None:
"""Close any open position with a reduce-only market order (kill switch, loss limit, exit).
Returns the closing fill (signed qty, price, fee) so the P&L includes it."""
size, _ = self.position()
if abs(size) < self.min_qty:
return None
res = self._call(self.http.place_order, category="linear", symbol=self.symbol, side="Sell" if size > 0 else "Buy",
orderType="Market", qty=str(self.round_qty(abs(size))), reduceOnly=True)
for _ in range(10): # market orders fill almost instantly; give it a moment to report
time.sleep(0.3)
o = self.order(res["orderId"])
if o["status"] == "Filled":
return {"dq": -size, "px": o["avg_px"], "fee": o["fee"]}
return {"dq": -size, "px": 0.0, "fee": 0.0}jev-starter/jevlab/bot.py
"""The 24/7 bot (Path 2): Jev + Claude + your strategy, trading on Bybit.
uv run python -m jevlab bot --dry # Bybit prices, simulated fills. No Bybit key needed
uv run python -m jevlab bot # Bybit Demo Trading (fake money), needs a demo API key
uv run python -m jevlab bot --minutes 0 # run until stopped (this is what the server runs)
How it decides:
* Claude (the brain) reads the market every --brain-every minutes and sets the bias:
long, short or flat.
* Jev makes a fast buy/sell call several times a second.
* strategy.py combines them. By default it only trades in Claude's direction, and only
when Jev is 85%+ sure.
* Orders are post-only limit orders at the best bid/ask (maker fee, no spread),
cancelled if they don't fill within --maker-wait seconds.
Safety, always on:
* Demo Trading unless BYBIT_MODE=live AND JEV_LIVE_CONFIRM is the exact phrase (see bybit.py).
* MAX_POSITION_USD caps the position size (default $1,000).
* MAX_DAILY_LOSS_USD: hit it and the bot cancels everything, closes the position and stops
trading for the day (default $50).
* Kill switch: create a file called STOP in the project folder, and the bot closes out and halts.
* On shutdown it cancels open orders and closes the position.
"""
from __future__ import annotations
import json
import os
import statistics
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from datetime import datetime, timezone
from pathlib import Path
from . import strategy
from .brain import Brain
from .bybit import BybitError, BybitMarket, BybitTrader, bybit_mode
from .core import RESULTS, console, header
from .judges import JevJudge, JudgeError
from .loop import QUESTIONS
from .server import serve
MAKER_FEE = 0.0002 # Bybit base-tier maker fee on USDT perps, used for --dry fills
STOP_FILE = Path(__file__).resolve().parent.parent / "STOP"
class Book:
"""Position and P&L from actual fills: coins, cash and fees, marked to the live mid."""
def __init__(self):
self.qty, self.cash, self.fees, self.trades = 0.0, 0.0, 0.0, 0
self.equity: list[list[float]] = []
self.day = datetime.now(timezone.utc).date()
self.day_start_net = 0.0
def fill(self, dq: float, px: float, fee_usd: float) -> None:
self.cash -= dq * px
self.qty += dq
self.fees += fee_usd
self.trades += 1
def net(self, mid: float) -> float:
return self.cash + self.qty * mid - self.fees
def snap(self, t: float, mid: float) -> dict:
g = self.cash + self.qty * mid
self.equity.append([round(t, 2), round(g, 3), round(g - self.fees, 3)])
del self.equity[:-2400]
pos = 1 if self.qty > 0 else -1 if self.qty < 0 else 0
return {"pos": pos, "qty": self.qty, "gross": round(g, 3), "fees": round(self.fees, 3),
"net": round(g - self.fees, 3), "trades": self.trades, "equity": self.equity}
def run_bot(coin: str, pace_s: float, minutes: float, port: int, open_browser: bool, late_ms: float = 1500,
maker_wait: float = 10.0, brain_every: float = 10.0, dry: bool = False) -> None:
symbol = f"{coin}USDT"
try:
mode = "dry" if dry else bybit_mode()
except BybitError as exc:
raise SystemExit(f" {exc}")
position_usd = float(os.getenv("MAX_POSITION_USD", "1000"))
max_loss = float(os.getenv("MAX_DAILY_LOSS_USD", "50"))
mode_label = {"dry": "Bybit prices · simulated fills", "demo": "Bybit DEMO · fake money", "live": "LIVE · REAL MONEY"}[mode]
header("THE JEV BOT", f"{symbol} on Bybit · {mode_label} · Claude sets the bias every {brain_every:g} min · "
f"Jev calls as fast as the key allows · strategy: {strategy.DESCRIPTION} · "
f"max position ${position_usd:,.0f} · daily loss limit ${max_loss:,.0f}")
if mode == "live":
console.print(" [bold #ff5d6c]LIVE MODE: this bot is trading real money.[/] Kill switch: create a file named STOP.")
try:
jev = JevJudge()
except JudgeError as exc:
raise SystemExit(f" Jev key missing: {exc}. Add AI_GATEWAY_API_KEY to .env first.")
trader = None
if mode != "dry":
try:
trader = BybitTrader(symbol, mode)
trader.cancel_all() # start clean: no stale orders from a previous run
equity = trader.equity_usdt()
console.print(f" Bybit {mode} account connected · equity {equity:,.2f} USDT" if equity is not None
else f" Bybit {mode} account connected")
except BybitError as exc:
raise SystemExit(f" Bybit problem: {exc}")
market = BybitMarket(symbol)
market.start()
if not market.ready.wait(15):
raise SystemExit(" no price feed from Bybit after 15s, check the connection")
book = Book()
if trader: # adopt any position already open, so the numbers are honest from the first second
size, avg = trader.position()
if size:
book.qty, book.cash = size, -size * avg
console.print(f" existing position adopted: {size:+g} {coin} @ {avg:g}")
stop = threading.Event()
brain = Brain(symbol, brain_every)
brain.start(stop)
RESULTS.mkdir(exist_ok=True)
out = RESULTS / "loop.json"
log = open(RESULTS / "bot_log.jsonl", "a")
decisions: list[dict] = []
fills: list[dict] = []
latencies: list[float] = []
counts = {"ok": 0, "late": 0, "throttled": 0, "error": 0}
lock = threading.Lock()
st = {"interval": pace_s, "streak": 0, "prev": None, "order": None, "last_trade_t": 0.0, "hits": 0, "scored": 0,
"halted": None}
started = time.time()
serve(port, open_browser, page="loop.html")
def record_fill(dq: float, px: float, fee: float, kind: str, call, waited: float) -> None:
book.fill(dq, px, fee)
fills.append({"t": time.time(), "side": "buy" if dq > 0 else "sell", "px": px, "qty": abs(dq), "kind": kind,
"call": call, "wait_s": round(waited, 1)})
st["last_trade_t"] = time.time()
def check_order() -> None:
o = st["order"]
if not o:
return
now = time.time()
if trader is None: # --dry: fill only if the market traded through our price
through = any((o["buying"] and px < o["px"]) or (not o["buying"] and px > o["px"])
for _, _, px, _ in market.trades_since(o["t"]))
if through:
record_fill(o["qty"] if o["buying"] else -o["qty"], o["px"], o["qty"] * o["px"] * MAKER_FEE, "limit", o["call"], now - o["t"])
st["order"] = None
elif now > o["expires"]:
st["order"] = None
return
try:
s = trader.order(o["id"])
except BybitError:
return
new = s["filled"] - o["seen_qty"]
if new > 1e-12: # count only the newly filled part, with its share of the fee
px = s["avg_px"] or o["px"]
record_fill(new if o["buying"] else -new, px, s["fee"] - o["seen_fee"], "limit", o["call"], now - o["t"])
o["seen_qty"], o["seen_fee"] = s["filled"], s["fee"]
if s["status"] in ("Filled", "Cancelled", "Rejected", "Deactivated", "PartiallyFilledCanceled"):
st["order"] = None
elif now > o["expires"]:
trader.cancel(o["id"])
o["expires"] = now + 3 # look once more for fills that raced the cancel, then let go
def close_out() -> None:
try:
f = trader.flatten()
except BybitError as exc:
console.print(f" [#ff5d6c]couldn't close the position: {exc}. Close it on Bybit manually.[/]")
return
if f and f["px"]:
record_fill(f["dq"], f["px"], f["fee"], "market", None, 0)
def halt(reason: str) -> None:
st["halted"] = reason
st["order"] = None
console.print(f"\n [bold #f5b53d]HALTED: {reason}[/]. Cancelling orders and closing the position.")
if trader:
trader.cancel_all()
close_out()
else:
mid = market.snapshot()["mid"]
if book.qty:
record_fill(-book.qty, mid, abs(book.qty) * mid * 0.00055, "market", None, 0)
def risk_check(mid: float) -> None:
if st["halted"]:
return
today = datetime.now(timezone.utc).date()
if today != book.day: # a new UTC day resets the loss limit
book.day, book.day_start_net = today, book.net(mid)
if book.net(mid) - book.day_start_net < -max_loss:
halt(f"daily loss limit (${max_loss:,.0f}) reached")
elif STOP_FILE.exists():
halt("kill switch (STOP file)")
def write(status: str) -> None:
with lock:
check_order()
mid = market.snapshot()["mid"]
risk_check(mid)
recent = [d["t"] for d in decisions if d["t"] >= time.time() - 10]
o = st["order"]
payload = {
"status": status, "venue": "bybit", "symbol": symbol, "coin": coin, "mode": mode,
"venue_label": f"{symbol} · Bybit", "mode_label": mode_label + (f" · HALTED: {st['halted']}" if st["halted"] else ""),
"pace": pace_s, "interval": round(st["interval"], 3), "rate_per_s": round(len(recent) / 10, 2),
"late_ms": late_ms, "strategy_note": strategy.DESCRIPTION, "execution": "maker", "maker_wait": maker_wait,
"started": started, "updated": time.time(), "model": jev.model, "notional": position_usd,
"maker_fee_bps": MAKER_FEE * 1e4, "taker_fee_bps": 5.5, "counts": dict(counts), "blocks": len(decisions),
"last_ms": latencies[-1] if latencies else None,
"avg_ms": round(statistics.mean(latencies[-200:])) if latencies else None,
"hits": st["hits"], "scored": st["scored"], "brain": brain.current(),
"order": ({"target": o["target"], "buying": o["buying"], "px": o["px"], "t": o["t"]} if o else None),
"decisions": decisions[-200:], "fills": fills[-100:],
"book": book.snap(time.time(), mid), "ticks": market.recent_ticks(),
}
tmp = out.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, default=str))
os.replace(tmp, out)
def decide(rec: dict, now: dict) -> str:
"""Jev's call + Claude's bias -> strategy.decide() -> a post-only limit order. Runs under the lock."""
if st["halted"]:
return f"hold · halted ({st['halted']})"
bias = brain.current().get("bias")
if bias is None:
return "hold · waiting for Claude's first read"
market_view = {**rec["state"], "claude_bias": bias}
pos = 1 if book.qty > 0 else -1 if book.qty < 0 else 0
try:
choice = strategy.decide({"side": rec["side"], "conf": rec["conf"]}, market_view, pos, time.time() - st["last_trade_t"])
except Exception as exc:
return f"hold · strategy error: {str(exc)[:60]}"
if choice not in ("buy", "sell", "flat"):
return str(choice or "hold")
want = {"buy": 1, "sell": -1, "flat": 0}[choice]
if want == pos:
return "hold · already " + {1: "long", -1: "short", 0: "flat"}[want]
if st["order"]:
if st["order"]["target"] == want:
return "hold · limit order working"
if trader:
trader.cancel(st["order"]["id"])
st["order"] = None
size = position_usd / now["mid"]
size = trader.round_qty(size) if trader else round(size, 6)
dq = want * size - book.qty
buying = dq > 0
qty = trader.round_qty(abs(dq)) if trader else round(abs(dq), 6)
if trader and (qty < trader.min_qty or qty * now["mid"] < trader.min_notional):
return "hold · order too small for Bybit"
px = now["bid"] if buying else now["ask"]
order = {"target": want, "buying": buying, "qty": qty, "px": px, "t": time.time(), "expires": time.time() + maker_wait,
"call": rec["block"], "seen_qty": 0.0, "seen_fee": 0.0, "id": None}
if trader:
try:
order["id"] = trader.place_post_only("buy" if buying else "sell", qty, px, reduce_only=(want == 0))
except BybitError as exc:
return f"hold · order rejected: {str(exc)[:70]}"
st["order"] = order
return f"limit {'buy' if buying else 'sell'} {qty:g} @ {px:g}"
def ask(seq: int) -> None:
snap = market.snapshot()
rec = {"block": seq, "t_ask": time.time(), "state": snap["state"]}
try:
ans, meta = jev.ask(snap["state"], QUESTIONS, timeout=5.0, retries=0)
side = ans["side"]["choice"]
rec.update(side=side, conf=round(ans["side"]["probs"][side], 3), ms=meta["latency_ms"],
status="ok" if meta["latency_ms"] <= late_ms else "late")
except JudgeError as exc:
rec.update(side=None, conf=None, ms=None, status="throttled" if "429" in str(exc) else "error", error=str(exc)[:160])
now = market.snapshot()
rec.update(t=time.time(), mid=now["mid"], micro=now["micro"])
with lock:
counts[rec["status"]] += 1
if rec["ms"]:
latencies.append(rec["ms"])
if rec["status"] == "throttled":
st["interval"], st["streak"] = min(4.0, st["interval"] * 1.6), 0
elif rec["status"] == "ok":
st["streak"] += 1
if st["streak"] >= 3:
st["interval"] = max(pace_s, st["interval"] * 0.9)
prev = st["prev"]
if prev and now["mid"] != prev["mid"]:
st["hits"] += (now["mid"] > prev["mid"]) == (prev["side"] == "buy")
st["scored"] += 1
if rec["status"] == "ok":
st["prev"] = rec
rec["action"] = decide(rec, now)
else:
rec["action"] = "hold · late" if rec["status"] == "late" else rec["status"]
decisions.append(rec)
log.write(json.dumps(rec) + "\n")
log.flush()
if rec["action"].startswith("limit") or rec["action"].startswith("hold · order"):
console.print(f" {time.strftime('%H:%M:%S')} #{rec['block']:<6} Jev {(rec['side'] or '-').upper():<4} "
f"{(rec['conf'] or 0):.2f} → {rec['action']}")
def writer():
while not stop.is_set():
try:
write("running")
except Exception as exc: # a hiccup reading the exchange shouldn't stop the bot
console.print(f" [dim]status update skipped: {str(exc)[:80]}[/]")
stop.wait(0.5)
threading.Thread(target=writer, daemon=True).start()
console.print(" [dim]trades and orders print here · the dashboard shows every call[/]")
seq = 0
try:
with ThreadPoolExecutor(max_workers=8) as pool:
while not minutes or time.time() - started < minutes * 60:
seq += 1
pool.submit(ask, seq)
time.sleep(st["interval"])
except KeyboardInterrupt:
pass
stop.set()
with lock:
if trader:
trader.cancel_all()
if os.getenv("FLATTEN_ON_EXIT", "true").lower() != "false":
close_out()
write("done")
log.close()
mid = market.snapshot()["mid"]
console.print(f"\n {len(decisions)} Jev calls · {book.trades} fills · fees ${book.fees:.2f} · net ${book.net(mid):+.2f}")jev-starter/deploy/jev-bot.service
# systemd service: keeps the Jev bot running 24/7 on the server and restarts it if it crashes. # Install: sudo cp deploy/jev-bot.service /etc/systemd/system/ && sudo systemctl daemon-reload # sudo systemctl enable --now jev-bot # Logs: journalctl -u jev-bot -f Stop: sudo systemctl stop jev-bot [Unit] Description=Jev trading bot (Bybit) After=network-online.target Wants=network-online.target [Service] WorkingDirectory=/root/jev-starter ExecStart=/root/.local/bin/uv run python -m jevlab bot --minutes 0 --no-open Restart=on-failure RestartSec=15 KillSignal=SIGINT TimeoutStopSec=30 [Install] WantedBy=multi-user.target
jev-starter/dashboard/loop.html
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Jev Loop</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&family=JetBrains+Mono:wght@400;500;700;800&display=swap" rel="stylesheet">
<style>
:root{--bg:#06080c;--bg2:#0c1016;--panel:#0e131a;--line:#1a222d;--line2:#253041;--fg:#e9eef4;--muted:#8a95a5;--dim:#586272;
--jev:#8b7bff;--good:#3fd68a;--bad:#ff5d6c;--late:#f5b53d;--mono:"JetBrains Mono",ui-monospace,monospace}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;color:var(--fg);font:14px/1.45 Inter,-apple-system,BlinkMacSystemFont,sans-serif;-webkit-font-smoothing:antialiased;
background:radial-gradient(1000px 520px at 90% -15%,rgba(139,123,255,.18),transparent 60%),radial-gradient(900px 500px at -10% 115%,rgba(63,214,138,.06),transparent 60%),var(--bg)}
.app{height:100vh;min-height:680px;display:grid;grid-template-rows:auto minmax(0,1fr);gap:12px;padding:14px 18px 12px;max-width:1760px;margin:0 auto}
header{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.brand{font:800 23px/1 Inter;letter-spacing:-.025em}
.brand em{font-style:normal;background:linear-gradient(95deg,var(--jev),#d6ccff);-webkit-background-clip:text;background-clip:text;color:transparent}
.stats{display:flex;gap:16px;font:12px var(--mono);color:var(--dim)}
.stats b{color:var(--fg);font-weight:500}
.spacer{flex:1}
.pill{font:500 11.5px var(--mono);padding:6px 10px;border-radius:999px;border:1px solid var(--line2);color:var(--muted);background:rgba(12,16,22,.7);display:flex;align-items:center;gap:7px}
.pill.model{color:#cfc7ff;border-color:rgba(139,123,255,.45);background:rgba(139,123,255,.12)}
.live{width:7px;height:7px;border-radius:50%;background:var(--good);box-shadow:0 0 10px var(--good);animation:pulse 1.3s infinite}
.done .live{background:var(--dim);box-shadow:none;animation:none}
@keyframes pulse{50%{opacity:.35}}
.grid{display:grid;grid-template-columns:minmax(0,2.05fr) minmax(0,1fr);gap:12px;min-height:0}
.card{background:linear-gradient(180deg,rgba(14,19,26,.95),rgba(12,16,22,.95));border:1px solid var(--line);border-radius:18px;min-height:0;overflow:hidden}
.main{display:grid;grid-template-rows:auto auto minmax(0,1fr) auto;padding:16px 18px 12px;gap:10px}
.pxrow{display:flex;align-items:flex-start;gap:20px}
.price{font:800 clamp(40px,4.6vw,68px)/1 var(--mono);letter-spacing:-.04em;transition:color .25s}
.price.up{color:var(--good)} .price.down{color:var(--bad)}
.sub{display:flex;gap:16px;margin-top:8px;font:12.5px var(--mono);color:var(--muted);flex-wrap:wrap}
.sub b{font-weight:600}
.action{margin-left:auto;text-align:right}
.action .verb{font:700 34px/1 Inter;letter-spacing:-.02em;transition:color .2s}
.action .meta{font:12px var(--mono);color:var(--muted);margin-top:8px}
.statebox{font:11.5px/1.7 var(--mono);color:var(--muted);background:rgba(6,8,12,.55);border:1px solid var(--line);border-radius:10px;padding:7px 11px;
display:flex;flex-wrap:wrap;gap:2px 16px}
.statebox span b{color:var(--fg);font-weight:500}
.statebox .lbl{color:var(--jev);font-weight:600}
.chart{position:relative;min-height:0}
.chart svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.ax{fill:var(--dim);font:10.5px var(--mono)} .gl{stroke:var(--line);stroke-width:1}
.dots{display:flex;gap:3px;align-items:center;height:22px;overflow:hidden}
.dots i{flex:0 0 7px;height:16px;border-radius:3px;opacity:.9}
.foot{display:flex;justify-content:space-between;font:11px var(--mono);color:var(--dim);margin-top:6px}
.side{display:grid;grid-template-rows:auto auto minmax(0,1fr);gap:12px;min-height:0}
.pad{padding:14px 16px}
.eyebrow{font:600 10.5px var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--dim);margin-bottom:8px}
.big{display:flex;align-items:baseline;gap:12px}
.big b{font:800 54px/1 Inter;letter-spacing:-.03em;transition:color .2s}
.big span{font:500 26px var(--mono);color:var(--muted)}
.bars{display:grid;grid-template-columns:36px 1fr 44px;gap:8px 10px;align-items:center;margin-top:12px;font:12px var(--mono);color:var(--muted)}
.bar{height:9px;border-radius:99px;background:var(--line);overflow:hidden}
.bar i{display:block;height:100%;border-radius:99px;transition:width .35s ease}
.bars .v{text-align:right;color:var(--fg)}
.money{display:grid;grid-template-columns:1fr auto;gap:6px 12px;font:13px var(--mono);align-items:baseline}
.money .k{color:var(--muted)} .money .v{text-align:right;font-weight:600}
.money .net{font:800 28px var(--mono);letter-spacing:-.03em}
.money hr{grid-column:1/-1;border:0;border-top:1px solid var(--line);margin:4px 0}
.spark{height:62px;margin-top:10px;position:relative}
.spark svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.feed{display:flex;flex-direction:column;min-height:0}
.feed .eyebrow{padding:14px 16px 0}
.tbl{flex:1;min-height:0;overflow:hidden;padding:0 10px 10px}
table{width:100%;border-collapse:collapse;font:12px var(--mono)}
th{color:var(--dim);font-weight:400;text-align:left;padding:5px 6px;border-bottom:1px solid var(--line)}
td{padding:5px 6px;white-space:nowrap;border-bottom:1px solid rgba(26,34,45,.55)}
td.r,th.r{text-align:right}
tr.buy td{background:rgba(63,214,138,.045)} tr.sell td{background:rgba(255,93,108,.045)} tr.hold td{background:rgba(245,181,61,.05)}
tr.fresh td{animation:fresh .8s ease-out}
@keyframes fresh{from{background:rgba(139,123,255,.28)}}
.g{color:var(--good)} .r{color:var(--bad)} .a{color:var(--late)} .d{color:var(--dim)}
body.clean .moneycard,body.clean .pnl{display:none!important}
.act-trade{color:var(--jev);font-weight:700} .act-hold{color:var(--dim)}
.brainline{margin-top:12px;padding:9px 11px;border-radius:10px;border:1px solid rgba(224,122,85,.4);background:rgba(224,122,85,.08);font:12px/1.45 var(--mono);color:var(--muted)}
.brainline b{color:#f0a283}
.wait{position:absolute;inset:0;display:grid;place-items:center;color:var(--dim);font:13px var(--mono)}
@media (max-width:1050px){.app{height:auto}.grid{grid-template-columns:1fr}.chart{min-height:300px}}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand"><em>Jev</em> Loop</div>
<div class="stats" id="stats"></div>
<div class="spacer"></div>
<div class="pill" id="runPill"><span class="live"></span><span id="runText">paper mode</span></div>
<div class="pill" id="mkt">-</div>
<div class="pill model" id="model">typesafe-ai/jev</div>
<div class="pill" id="uptime">00:00:00</div>
</header>
<div class="grid">
<div class="card main">
<div class="pxrow">
<div>
<div class="price" id="price">-</div>
<div class="sub" id="sub"></div>
</div>
<div class="action"><div class="verb" id="verb">Waiting</div><div class="meta" id="verbMeta">first block…</div></div>
</div>
<div class="statebox" id="statebox"><span class="lbl">what Jev saw</span></div>
<div class="chart" id="chart"><div class="wait">connecting to Hyperliquid…</div></div>
<div>
<div class="dots" id="dots"></div>
<div class="foot"><span>one dot per decision · <span class="g">buy</span> · <span class="r">sell</span> · <span class="a">late</span> · ◆ = trade filled</span><span id="blockNote"></span></div>
</div>
</div>
<div class="side">
<div class="card pad">
<div class="eyebrow">Jev's latest call</div>
<div class="big"><b id="bigSide">-</b><span id="bigConf"></span></div>
<div class="brainline" id="brainLine" style="display:none"></div>
<div class="bars">
<span>buy</span><div class="bar"><i id="barBuy" style="width:0;background:var(--good)"></i></div><span class="v" id="pBuy">-</span>
<span>sell</span><div class="bar"><i id="barSell" style="width:0;background:var(--bad)"></i></div><span class="v" id="pSell">-</span>
</div>
</div>
<div class="card pad moneycard">
<div class="eyebrow" id="moneyTitle">Paper account · $1,000 position</div>
<div class="money">
<span class="k" id="balLabel">Paper balance</span><span class="v net" id="mBal">$1,000.00</span>
<hr>
<span class="k">Before fees</span><span class="v" id="mGross">-</span>
<span class="k">Fees paid <span class="d" id="mTrades"></span></span><span class="v" id="mFees">-</span>
<hr>
<span class="k">After fees</span><span class="v" id="mNet">-</span>
<span class="k">Jev calls → trades</span><span class="v" id="mRatio">-</span>
<span class="k">Direction right</span><span class="v" id="mHit">-</span>
</div>
<div class="spark" id="spark"></div>
</div>
<div class="card feed">
<div class="eyebrow">Feed · every Jev call</div>
<div class="tbl"><table><thead><tr><th>#</th><th>call</th><th>conf</th><th class="r">ms</th><th class="r">action</th></tr></thead><tbody id="feed"></tbody></table></div>
</div>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const WINDOW_S = 60;
let S = null, ticks = [], ws = null, wsCoin = null, lastPx = null, lastSpr = "-", seenBlocks = new Set();
const pad2 = (n) => String(n).padStart(2, "0");
function fmtPx(p) { const d = p >= 1000 ? 1 : p >= 10 ? 3 : 5; return p.toLocaleString(undefined, {minimumFractionDigits: d, maximumFractionDigits: d}); }
const usd = (v) => (v >= 0 ? "+$" : "−$") + Math.abs(v).toFixed(2);
function colorFor(d) { return d.status === "ok" ? (d.side === "buy" ? "var(--good)" : "var(--bad)") : "var(--late)"; }
function niceTicks(lo, hi, k) {
const span = hi - lo || 1; let step = Math.pow(10, Math.floor(Math.log10(span / k)));
for (const m of [1, 2, 5, 10]) { if (span / (step * m) <= k) { step *= m; break; } }
const out = []; for (let v = Math.ceil(lo / step) * step; v <= hi + 1e-9; v += step) out.push(+v.toFixed(10)); return out;
}
// ---- live price: straight from Hyperliquid, ~8 updates a second -------------------------------
function onQuote(bp, ap, bs, as) {
const t = Date.now() / 1000;
const mid = (bs * ap + as * bp) / (bs + as); // microprice: the size-weighted fair price, moves on every book change
ticks.push([t, mid, bp, ap]); if (ticks.length > 6000) ticks = ticks.slice(-4000);
const el = $("price");
if (lastPx !== null && Math.abs(mid - lastPx) > mid * 1e-7) { el.className = "price " + (mid > lastPx ? "up" : "down"); clearTimeout(el._t); el._t = setTimeout(() => el.className = "price", 250); }
lastPx = mid; el.textContent = fmtPx(mid);
lastSpr = (1e4 * (ap - bp) / mid).toFixed(2) + " bps";
if ($("spr")) $("spr").textContent = lastSpr;
}
// live price straight from the exchange the bot trades on: Hyperliquid (paper loop) or Bybit (24/7 bot)
function connect(coin) {
const venue = S.venue || "hyperliquid", key = venue + ":" + coin;
if (wsCoin === key && ws && ws.readyState <= 1) return;
wsCoin = key;
try { ws && ws.close(); } catch (e) {}
if (venue === "bybit") {
ws = new WebSocket("wss://stream.bybit.com/v5/public/linear");
ws.onopen = () => {
ws.send(JSON.stringify({op: "subscribe", args: [`orderbook.1.${S.symbol}`]}));
clearInterval(ws._ping); ws._ping = setInterval(() => ws.readyState === 1 && ws.send(JSON.stringify({op: "ping"})), 18000);
};
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data), d = m.data;
if (!m.topic || !m.topic.startsWith("orderbook.1.") || !d || !d.b?.length || !d.a?.length) return;
onQuote(+d.b[0][0], +d.a[0][0], +d.b[0][1], +d.a[0][1]);
};
} else {
ws = new WebSocket("wss://api.hyperliquid.xyz/ws");
ws.onopen = () => ws.send(JSON.stringify({method: "subscribe", subscription: {type: "bbo", coin}}));
ws.onmessage = (ev) => {
const m = JSON.parse(ev.data);
if (m.channel !== "bbo" || !m.data.bbo || m.data.bbo.includes(null)) return;
const [b, a] = m.data.bbo;
onQuote(+b.px, +a.px, +b.sz, +a.sz);
};
}
ws.onclose = () => setTimeout(() => connect(coin), 1500);
}
async function poll() {
try {
const r = await fetch("/results/loop.json?_=" + Date.now(), {cache: "no-store"});
if (!r.ok) return;
const first = !S; S = await r.json();
if (first || !ticks.length) ticks = S.ticks.concat(ticks.filter((t) => t[0] > (S.ticks.at(-1)?.[0] || 0)));
connect(S.coin);
renderData();
} catch (e) {}
}
const CLEAN = new URLSearchParams(location.search).has("clean");
if (CLEAN) document.body.classList.add("clean");
const answered = () => S.decisions.filter((d) => d.status === "ok" || d.status === "late");
function renderData() {
const c = S.counts, j = S.book;
const up = Math.floor(((S.status === "done" ? S.updated : Date.now() / 1000) - S.started));
$("uptime").textContent = `uptime ${pad2(Math.floor(up / 3600))}:${pad2(Math.floor(up / 60) % 60)}:${pad2(up % 60)}`;
$("mkt").textContent = S.venue_label || `${S.coin}-PERP · Hyperliquid`;
$("model").textContent = S.model;
$("runPill").className = "pill" + (S.status === "done" ? " done" : "");
$("runText").textContent = S.status === "done" ? "run complete" : (S.mode_label || "paper mode");
$("stats").innerHTML = `<span>calls <b>${S.blocks.toLocaleString()}</b></span><span>last <b>${S.last_ms ?? "-"} ms</b></span>` +
`<span>avg <b>${S.avg_ms ?? "-"} ms</b></span><span>pace <b>${S.rate_per_s.toFixed(1)}/s</b></span><span>trades <b>${j.trades}</b></span>` +
`<span>late <b>${c.late}</b></span><span>throttled <b>${c.throttled}</b></span>`;
$("blockNote").textContent = `your strategy: ${S.strategy_note} · ${S.execution === "maker" ? "limit orders" : "market orders"}`;
const d = answered().at(-1), fill = S.fills.at(-1), nowS = Date.now() / 1000;
if (fill && nowS - fill.t < 3) {
$("verb").textContent = fill.side === "buy" ? "Bought" : "Sold"; $("verb").style.color = fill.side === "buy" ? "var(--good)" : "var(--bad)";
$("verbMeta").textContent = `${fill.kind} fill @ ${fmtPx(fill.px)}${fill.wait_s ? ` · rested ${fill.wait_s}s` : ""}`;
} else if (d && d.side) {
$("verb").textContent = d.side === "buy" ? "BUY" : "SELL"; $("verb").style.color = d.side === "buy" ? "var(--good)" : "var(--bad)";
$("verbMeta").textContent = `Jev · ${Math.round(100 * d.conf)}% · ${d.ms} ms`;
}
if (d) {
const last = S.decisions.slice().reverse().find((x) => x.side);
if (last) {
const pb = last.side === "buy" ? last.conf : 1 - last.conf;
$("bigSide").textContent = last.side.toUpperCase(); $("bigSide").style.color = last.side === "buy" ? "var(--good)" : "var(--bad)";
$("bigConf").textContent = Math.round(100 * last.conf) + "%";
$("barBuy").style.width = (100 * pb) + "%"; $("barSell").style.width = (100 * (1 - pb)) + "%";
$("pBuy").textContent = Math.round(100 * pb) + "%"; $("pSell").textContent = Math.round(100 * (1 - pb)) + "%";
}
const st = d.state || {};
const lab = {spread_bps: "spread", microprice_vs_mid_bps: "micro", top_of_book_imbalance: "imb", depth5_imbalance: "depth5",
aggressor_buy_share_5s: "agg buy 5s", aggressor_buy_share_30s: "agg buy 30s", trades_last_5s: "trades/5s",
return_5s_bps: "ret 5s", return_30s_bps: "ret 30s", tick_volatility_60s_bps: "vol 60s"};
$("statebox").innerHTML = `<span class="lbl">what Jev saw · ${Object.keys(st).length} fields</span>` +
Object.entries(lab).filter(([k]) => k in st).map(([k, l]) => `<span>${l} <b>${st[k]}</b></span>`).join("");
}
const pos = j.pos > 0 ? `<b class="g">LONG $1,000</b>` : j.pos < 0 ? `<b class="r">SHORT $1,000</b>` : `<b>FLAT</b>`;
const working = S.order ? `<span>limit ${(S.order.buying ?? S.order.target > 0) ? "buy" : "sell"} <b>${fmtPx(S.order.px)}</b> working</span>` : "";
$("sub").innerHTML = `<span>${pos}</span>${working}<span class="pnl">balance <b class="${j.net > 0 ? "g" : ""}">$${(1000 + j.net).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})}</b></span><span>spread <b id="spr">${lastSpr}</b></span>`;
const acct = {demo: "Bybit demo account", live: "Bybit LIVE account"}[S.mode] || "Paper account";
$("balLabel").textContent = S.venue === "bybit" && S.mode !== "dry" ? "This session (from $1,000)" : "Paper balance";
$("moneyTitle").textContent = `${acct} · $${Math.round(S.notional || 1000).toLocaleString()} position · ${S.execution === "maker" ? `limit orders, ${S.maker_fee_bps.toFixed(1)} bps` : `market orders, ${S.taker_fee_bps.toFixed(1)} bps + spread`}`;
const tone = (v) => (v > 0 ? "g" : ""); // gains in green, everything else neutral
$("mBal").textContent = "$" + (1000 + j.net).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2});
$("mBal").className = "v net " + tone(j.net);
$("mGross").textContent = usd(j.gross); $("mGross").className = "v " + tone(j.gross);
$("mFees").textContent = "−$" + j.fees.toFixed(2); $("mTrades").textContent = `(${j.trades} trades)`;
$("mNet").textContent = usd(j.net); $("mNet").className = "v " + tone(j.net);
$("mRatio").textContent = `${S.blocks} → ${j.trades}`;
$("mHit").textContent = S.scored ? `${Math.round(100 * S.hits / S.scored)}% of ${S.scored}` : "-";
if (S.brain) {
const b = S.brain, ago = b.t ? Math.round((Date.now() / 1000 - b.t) / 60) + " min ago" : "thinking…";
$("brainLine").style.display = "block";
$("brainLine").innerHTML = `<b>Claude's bias: ${(b.bias || "-").toUpperCase()}</b>${b.confidence != null ? ` · ${Math.round(100 * b.confidence)}%` : ""} · ${ago}<br>${b.reason || ""}`;
}
renderSpark(); renderDots(); renderFeed();
}
function renderSpark() {
const el = $("spark"), j = S.book.equity;
if (j.length < 2) { el.innerHTML = ""; return; }
const W = el.clientWidth, H = el.clientHeight, all = j.flatMap((p) => [p[1], p[2]]).concat([0]);
const lo = Math.min(...all), hi = Math.max(...all), t0 = j[0][0], t1 = j.at(-1)[0];
const X = (t) => (t - t0) / Math.max(1, t1 - t0) * (W - 80), Y = (v) => 4 + (hi - v) / ((hi - lo) || 1) * (H - 8);
const line = (pts, i) => pts.map((p, k) => `${k ? "L" : "M"}${X(p[0]).toFixed(1)},${Y(p[i]).toFixed(1)}`).join("");
const yb = Y(j.at(-1)[1]), ya = Y(j.at(-1)[2]), sep = Math.abs(yb - ya) < 12 ? (ya >= yb ? 6 : -6) : 0;
el.innerHTML = `<svg viewBox="0 0 ${W} ${H}"><line x1="0" x2="${W - 80}" y1="${Y(0)}" y2="${Y(0)}" stroke="#2e3949" stroke-dasharray="3 3"/>` +
`<path d="${line(j, 1)}" fill="none" stroke="var(--jev)" stroke-width="1.5" stroke-dasharray="4 3" opacity=".6"/>` +
`<path d="${line(j, 2)}" fill="none" stroke="var(--jev)" stroke-width="2.6"/>` +
`<text x="${W - 74}" y="${yb + 4 - sep}" style="fill:var(--muted);font:600 10.5px var(--mono)">before fees</text>` +
`<text x="${W - 74}" y="${ya + 4 + sep}" style="fill:var(--jev);font:600 10.5px var(--mono)">after fees</text></svg>`;
}
function renderDots() {
const el = $("dots"), n = Math.max(20, Math.floor(el.clientWidth / 10));
el.innerHTML = answered().slice(-n).map((d) => `<i style="background:${colorFor(d)}"></i>`).join("") +
(S.status === "running" ? `<i style="background:transparent;border:1.5px solid var(--jev);animation:pulse 1s infinite"></i>` : "");
}
function renderFeed() {
const rows = answered().slice(-22).reverse();
$("feed").innerHTML = rows.map((d, i) => {
const fresh = !seenBlocks.has(d.block); seenBlocks.add(d.block);
const cls = d.status !== "ok" ? "hold" : d.side;
const side = d.side ? `<b class="${d.status === "ok" ? (d.side === "buy" ? "g" : "r") : "a"}">${d.side.toUpperCase()}</b>` : `<span class="a">-</span>`;
const LABEL = {"hold · low conviction": "signal", "hold · limit order working": "order working", "hold · too soon to flip": "cooling down",
"hold · already long": "in position", "hold · already short": "in position", "hold · late": "late"};
const act = LABEL[d.action] || d.action || d.status, trade = /^(limit|market)/.test(act);
return `<tr class="${cls} ${fresh && i === 0 && seenBlocks.size > 1 ? "fresh" : ""}"><td>${d.block.toLocaleString()}</td><td>${side}</td>` +
`<td>${d.conf != null ? d.conf.toFixed(2) : "-"}</td><td class="r">${d.ms ?? "-"}</td>` +
`<td class="r ${trade ? "act-trade" : d.status === "ok" ? "act-hold" : "a"}">${act}</td></tr>`;
}).join("");
}
function renderChart() {
requestAnimationFrame(renderChart);
const nowP = performance.now(); if (renderChart._t && nowP - renderChart._t < 40) return; renderChart._t = nowP;
if (!S || ticks.length < 2) return;
const el = $("chart"), W = el.clientWidth, H = el.clientHeight; if (W < 50 || H < 50) return;
const l = 4, r = 100, t = 12, b = 20;
const tMax = Date.now() / 1000;
const tMin = Math.max(tMax - WINDOW_S, ticks[0][0]);
const span = Math.max(5, tMax - tMin);
const vis = ticks.filter((p) => p[0] >= tMin - 1);
if (vis.length < 2) return;
const decs = answered().filter((d) => d.t >= tMin && d.micro);
const ys = vis.map((p) => p[1]).concat(decs.map((d) => d.micro), S.fills.filter((f) => f.t >= tMin).map((f) => f.px), S.order ? [S.order.px] : []);
let lo = Math.min(...ys), hi = Math.max(...ys); const pad = (hi - lo) * 0.18 || hi * 0.0001; lo -= pad; hi += pad;
const X = (tt) => Math.max(l, l + (tt - tMin) / span * (W - l - r)), Y = (v) => t + (hi - v) / (hi - lo) * (H - t - b);
const clampY = (v) => Math.min(H - b, Math.max(t, Y(v)));
let s = `<svg viewBox="0 0 ${W} ${H}"><defs><linearGradient id="f" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#8b7bff" stop-opacity=".28"/><stop offset="1" stop-color="#8b7bff" stop-opacity="0"/></linearGradient></defs>`;
for (const v of niceTicks(lo, hi, 4)) s += `<line class="gl" x1="${l}" x2="${W - r}" y1="${Y(v)}" y2="${Y(v)}"/><text class="ax" x="${W - r + 8}" y="${Y(v) + 4}">${fmtPx(v)}</text>`;
for (let k = 0; k <= 3; k++) { const tt = tMin + k * span / 3; s += `<text class="ax" x="${X(tt)}" y="${H - 4}" text-anchor="${k === 0 ? "start" : k === 3 ? "end" : "middle"}">${new Date(tt * 1000).toLocaleTimeString([], {hour12: false})}</text>`; }
// the live bid-ask band, clipped to the chart
const top = vis.map((p) => `${X(p[0]).toFixed(1)},${clampY(p[3]).toFixed(1)}`), bot = vis.map((p) => `${X(p[0]).toFixed(1)},${clampY(p[2]).toFixed(1)}`).reverse();
s += `<polygon points="${top.join(" ")} ${X(tMax)},${clampY(vis.at(-1)[3])} ${X(tMax)},${clampY(vis.at(-1)[2])} ${bot.join(" ")}" fill="rgba(139,123,255,.10)"/>`;
// microprice: the price line
const line = vis.map((p) => `${X(p[0]).toFixed(1)},${Y(p[1]).toFixed(1)}`).join(" ") + ` ${X(tMax)},${Y(vis.at(-1)[1])}`;
s += `<polygon points="${X(vis[0][0])},${H - b} ${line} ${X(tMax)},${H - b}" fill="url(#f)"/><polyline points="${line}" fill="none" stroke="#e6ecf3" stroke-width="1.7" stroke-linejoin="round"/>`;
const nowS = Date.now() / 1000;
for (const d of decs) {
const cx = X(d.t), cy = Y(d.micro), age = nowS - d.t, col = colorFor(d);
if (age < 0.9) { const k = age / 0.9; s += `<circle cx="${cx}" cy="${cy}" r="${5 + 18 * k}" fill="none" stroke="${col}" stroke-width="2" opacity="${(1 - k).toFixed(2)}"/>`; }
s += `<circle cx="${cx}" cy="${cy}" r="4.6" fill="${col}" stroke="var(--bg)" stroke-width="1.5"/>`;
}
for (const f of S.fills.filter((f) => f.t >= tMin)) {
const cx = X(f.t), cy = Y(f.px), col = f.side === "buy" ? "var(--good)" : "var(--bad)", age = nowS - f.t;
if (age < 1.5) { const k = age / 1.5; s += `<circle cx="${cx}" cy="${cy}" r="${8 + 30 * k}" fill="none" stroke="${col}" stroke-width="2.5" opacity="${(1 - k).toFixed(2)}"/>`; }
s += `<rect x="${cx - 7}" y="${cy - 7}" width="14" height="14" transform="rotate(45 ${cx} ${cy})" fill="${col}" stroke="#fff" stroke-width="1.8"/>` +
`<text x="${cx}" y="${cy + (f.side === "buy" ? 24 : -15)}" text-anchor="middle" style="fill:${col};font:700 10.5px var(--mono);paint-order:stroke;stroke:var(--bg);stroke-width:4px">${f.side === "buy" ? "BOUGHT" : "SOLD"}</text>`;
}
if (S.order) {
const oy = Y(S.order.px), col = (S.order.buying ?? S.order.target > 0) ? "var(--good)" : "var(--bad)";
s += `<line x1="${X(S.order.t)}" x2="${W - r}" y1="${oy}" y2="${oy}" stroke="${col}" stroke-width="1.5" stroke-dasharray="6 4"/>` +
`<text x="${W - r - 6}" y="${oy - 6}" text-anchor="end" style="fill:${col};font:600 10.5px var(--mono)">limit ${(S.order.buying ?? S.order.target > 0) ? "buy" : "sell"} ${fmtPx(S.order.px)}</text>`;
}
const last = vis.at(-1)[1], ly = Y(last);
s += `<circle cx="${X(tMax)}" cy="${ly}" r="10" fill="#fff" opacity=".12"/><circle cx="${X(tMax)}" cy="${ly}" r="4" fill="#fff"/>` +
`<rect x="${W - r + 2}" y="${ly - 11}" width="${r - 4}" height="22" rx="6" fill="#fff"/><text x="${W - r + 9}" y="${ly + 4}" style="fill:#06080c;font:700 11.5px var(--mono)">${fmtPx(last)}</text>`;
el.innerHTML = s + "</svg>";
}
setInterval(poll, 200); poll(); requestAnimationFrame(renderChart);
setInterval(() => S && renderData(), 1000);
</script>
</body>
</html>jev-starter/dashboard/newsroom.html
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Jev Newsroom</title>
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;800&family=JetBrains+Mono:wght@400;500;700;800&display=swap" rel="stylesheet">
<style>
:root{--bg:#06080c;--bg2:#0c1016;--panel:#0e131a;--line:#1a222d;--line2:#253041;--fg:#e9eef4;--muted:#8a95a5;--dim:#586272;
--jev:#8b7bff;--good:#3fd68a;--bad:#ff5d6c;--amber:#f5b53d;--mono:"JetBrains Mono",ui-monospace,monospace}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;color:var(--fg);font:14px/1.45 Inter,-apple-system,BlinkMacSystemFont,sans-serif;-webkit-font-smoothing:antialiased;
background:radial-gradient(1000px 520px at 90% -15%,rgba(139,123,255,.18),transparent 60%),radial-gradient(900px 500px at -10% 115%,rgba(245,181,61,.05),transparent 60%),var(--bg)}
.app{height:100vh;min-height:680px;display:grid;grid-template-rows:auto minmax(0,1fr) auto;gap:12px;padding:14px 18px 12px;max-width:1760px;margin:0 auto}
header{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.brand{font:800 23px/1 Inter;letter-spacing:-.025em}
.brand em{font-style:normal;background:linear-gradient(95deg,var(--jev),#d6ccff);-webkit-background-clip:text;background-clip:text;color:transparent}
.spacer{flex:1}
.pill{font:500 11.5px var(--mono);padding:6px 10px;border-radius:999px;border:1px solid var(--line2);color:var(--muted);background:rgba(12,16,22,.7);display:flex;align-items:center;gap:7px}
.pill.model{color:#cfc7ff;border-color:rgba(139,123,255,.45);background:rgba(139,123,255,.12)}
.pill.replay{color:var(--amber);border-color:rgba(245,181,61,.4);background:rgba(245,181,61,.08)}
.live{width:7px;height:7px;border-radius:50%;background:var(--amber);box-shadow:0 0 10px var(--amber);animation:pulse 1.3s infinite}
@keyframes pulse{50%{opacity:.35}}
.progress{width:180px;height:6px;border-radius:99px;background:var(--line);overflow:hidden}
.progress i{display:block;height:100%;width:0;background:linear-gradient(90deg,var(--jev),#c7bbff);transition:width .5s}
.grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1.3fr);gap:12px;min-height:0}
.card{background:linear-gradient(180deg,rgba(14,19,26,.95),rgba(12,16,22,.95));border:1px solid var(--line);border-radius:18px;min-height:0;overflow:hidden}
.eyebrow{font:600 10.5px var(--mono);letter-spacing:.16em;text-transform:uppercase;color:var(--dim)}
.wire{display:flex;flex-direction:column}
.wire .eyebrow{padding:14px 16px 10px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between}
.list{flex:1;min-height:0;overflow:hidden;padding:6px 8px}
.item{padding:10px 10px;border-radius:12px;border:1px solid transparent;margin-bottom:4px;transition:background .4s,border-color .4s}
.item.now{background:rgba(139,123,255,.09);border-color:rgba(139,123,255,.45)}
.item.enter{animation:enter .6s ease-out}
@keyframes enter{from{transform:translateY(-10px);opacity:0}}
.meta{display:flex;gap:10px;align-items:center;font:11px var(--mono);color:var(--dim);margin-bottom:4px}
.src{color:var(--muted);text-transform:uppercase;letter-spacing:.08em}
.hl{font-size:14px;line-height:1.35;color:var(--fg)}
.item:not(.now) .hl{color:#b9c2cd}
.tags{display:flex;gap:6px;margin-top:7px;flex-wrap:wrap;align-items:center}
.chip{font:700 10.5px var(--mono);letter-spacing:.06em;padding:3px 7px;border-radius:6px;background:var(--line);color:var(--muted)}
.chip.coin{color:#cfc7ff;background:rgba(139,123,255,.16)}
.chip.bull{color:var(--good);background:rgba(63,214,138,.12)} .chip.bear{color:var(--bad);background:rgba(255,93,108,.12)}
.chip.trade{color:#06080c;background:var(--fg)}
.imp{display:inline-flex;gap:3px} .imp i{width:7px;height:7px;border-radius:2px;background:var(--line2)} .imp i.on{background:var(--amber)}
.reading{font:11px var(--mono);color:var(--jev)} .reading::after{content:"▍";animation:pulse .8s infinite}
.right{display:grid;grid-template-rows:auto minmax(0,1fr);gap:12px;min-height:0}
.read{padding:16px 18px}
.readhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
.lat{font:600 12px var(--mono);color:var(--jev);padding:4px 9px;border-radius:999px;background:rgba(139,123,255,.12)}
.big{font:700 22px/1.3 Inter;letter-spacing:-.015em;min-height:58px}
.big .cursor{display:inline-block;width:10px;height:22px;background:var(--jev);vertical-align:-3px;margin-left:2px;animation:pulse .8s infinite}
.qs{display:grid;grid-template-columns:1.2fr 1fr 1fr;gap:18px;margin-top:14px}
.q h4{margin:0 0 8px;font:600 11px var(--mono);letter-spacing:.12em;text-transform:uppercase;color:var(--dim)}
.bars{display:grid;grid-template-columns:auto 1fr 36px;gap:6px 8px;align-items:center;font:11.5px var(--mono);color:var(--muted)}
.bar{height:8px;border-radius:99px;background:var(--line);overflow:hidden}
.bar i{display:block;height:100%;border-radius:99px;transition:width .45s ease}
.bars .v{text-align:right;color:var(--fg)} .bars .pick{color:var(--fg);font-weight:700}
.meter{display:grid;grid-template-columns:repeat(4,1fr);gap:4px;margin-top:2px}
.meter div{height:26px;border-radius:6px;background:var(--line);display:grid;place-items:center;font:600 10px var(--mono);color:var(--dim);transition:background .4s,color .4s}
.meter div.on{background:rgba(245,181,61,.25);color:var(--amber)} .meter div.top{background:var(--amber);color:#06080c}
.decision{margin-top:14px;padding:11px 14px;border-radius:12px;font:700 15px var(--mono);letter-spacing:.02em;display:flex;justify-content:space-between;align-items:center;
background:var(--line);color:var(--muted);transition:background .3s,color .3s}
.decision.long{background:rgba(63,214,138,.14);color:var(--good);box-shadow:inset 0 0 0 1px rgba(63,214,138,.4)}
.decision.short{background:rgba(255,93,108,.14);color:var(--bad);box-shadow:inset 0 0 0 1px rgba(255,93,108,.4)}
.decision small{font:500 12px var(--mono);color:var(--muted)}
.next{display:flex;flex-direction:column;padding:14px 16px 10px}
.nexthead{display:flex;justify-content:space-between;align-items:center;margin-bottom:6px}
.verdict{font:800 13px var(--mono);letter-spacing:.1em;padding:5px 10px;border-radius:8px}
.verdict.good{color:#06080c;background:var(--good)} .verdict.bad{color:var(--fg);background:var(--line2)} .verdict.pass{color:var(--muted);background:var(--line)}
.chart{position:relative;flex:1;min-height:0}
.chart svg{position:absolute;inset:0;width:100%;height:100%;overflow:visible}
.ax{fill:var(--dim);font:10.5px var(--mono)} .gl{stroke:var(--line)}
.score{display:grid;grid-template-columns:repeat(5,1fr);gap:12px}
.s{background:linear-gradient(180deg,var(--panel),var(--bg2));border:1px solid var(--line);border-radius:14px;padding:10px 14px}
.s span{display:block;font-size:11.5px;color:var(--muted)}
.s b{font:700 24px/1.2 var(--mono);letter-spacing:-.02em}
.g{color:var(--good)} .r{color:var(--bad)}
.wait{position:absolute;inset:0;display:grid;place-items:center;color:var(--dim);font:13px var(--mono)}
</style>
</head>
<body>
<div class="app">
<header>
<div class="brand"><em>Jev</em> Newsroom</div>
<div class="pill replay"><span class="live"></span><span id="replayText">REPLAY · real headlines</span></div>
<div class="spacer"></div>
<div class="pill" id="count">0 / 0</div>
<div class="progress"><i id="prog"></i></div>
<div class="pill model" id="model">typesafe-ai/jev</div>
</header>
<div class="grid">
<div class="card wire">
<div class="eyebrow"><span>Newswire</span><span id="wireNote">cointelegraph · coindesk · decrypt · the block</span></div>
<div class="list" id="list"></div>
</div>
<div class="right">
<div class="card read">
<div class="readhead"><div class="eyebrow">Jev's read</div><div class="lat" id="lat">reading…</div></div>
<div class="big" id="big"></div>
<div class="qs">
<div class="q"><h4>Which coin?</h4><div class="bars" id="qCoin"></div></div>
<div class="q"><h4>Which way?</h4><div class="bars" id="qDir"></div></div>
<div class="q"><h4>How big?</h4><div class="meter" id="qImp"></div><div class="bars" style="margin-top:8px"><span>score</span><span></span><span class="v" id="impVal">-</span></div></div>
</div>
<div class="decision" id="decision">waiting for the first headline…</div>
</div>
<div class="card next">
<div class="nexthead"><div class="eyebrow" id="nextTitle">What the market did next</div><div id="verdict"></div></div>
<div class="chart" id="chart"><div class="wait">the real price move appears once Jev has answered</div></div>
</div>
</div>
</div>
<div class="score">
<div class="s"><span>Headlines read</span><b id="sRead">0</b></div>
<div class="s"><span>Tagged to a coin</span><b id="sTag">0</b></div>
<div class="s"><span>Worth trading (Notable+)</span><b id="sTrades">0</b></div>
<div class="s"><span>Direction right</span><b id="sRight">-</b></div>
<div class="s"><span>After costs, 60-min holds</span><b id="sNet">-</b></div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const COINS = ["BTC", "ETH", "SOL", "whole_crypto_market", "none"];
const COIN_LABEL = {BTC: "BTC", ETH: "ETH", SOL: "SOL", whole_crypto_market: "market", none: "none"};
const IMPACT = ["Noise", "Minor", "Notable", "Major"];
let S = null, seen = new Set(), typed = {key: null, n: 0};
const hhmm = (t) => new Date(t * 1000).toLocaleTimeString([], {hour: "2-digit", minute: "2-digit", hour12: false});
const sgn = (v, d = 1) => (v > 0 ? "+" : v < 0 ? "−" : "") + Math.abs(v).toFixed(d);
async function poll() {
try {
const r = await fetch("/results/newsroom.json?_=" + Date.now(), {cache: "no-store"});
if (r.ok) { S = await r.json(); render(); }
} catch (e) {}
}
function tagsFor(it) {
if (!it.answer) return it.error ? `<span class="chip">skipped</span>` : `<span class="reading">Jev reading</span>`;
const dir = it.direction, imp = Math.round(it.impact);
return (it.coin ? `<span class="chip coin">${COIN_LABEL[it.answer.asset.choice] || it.coin}</span>` : `<span class="chip">no coin</span>`) +
`<span class="chip ${dir === "bullish" ? "bull" : dir === "bearish" ? "bear" : ""}">${dir === "bullish" ? "▲ BULLISH" : dir === "bearish" ? "▼ BEARISH" : "NEUTRAL"}</span>` +
`<span class="imp">${[0, 1, 2, 3].map((i) => `<i class="${i <= imp ? "on" : ""}"></i>`).join("")}</span>` +
(it.trade ? `<span class="chip trade">TRADE</span>` : "");
}
function render() {
const feed = S.feed, cur = feed.at(-1);
$("count").textContent = `${S.index} / ${S.total}`;
$("prog").style.width = (100 * S.index / Math.max(1, S.total)) + "%";
$("model").textContent = S.model;
$("replayText").textContent = `REPLAY${S.round > 1 ? " · round " + S.round : ""} · last ${S.hours}h of real headlines · Jev answers live`;
$("list").innerHTML = feed.slice(-9).reverse().map((it, i) => {
const key = it.headline, fresh = !seen.has(key); seen.add(key);
return `<div class="item ${i === 0 ? "now" : ""} ${fresh ? "enter" : ""}"><div class="meta"><span>${hhmm(it.published)}</span><span class="src">${it.source}</span></div>` +
`<div class="hl">${it.headline}</div><div class="tags">${tagsFor(it)}</div></div>`;
}).join("");
const sc = S.score;
$("sRead").textContent = sc.read; $("sTag").textContent = sc.tagged; $("sTrades").textContent = sc.trades;
$("sRight").textContent = sc.trades ? `${sc.right} of ${sc.trades}` : "-";
$("sNet").textContent = sc.trades ? sgn(sc.net_bps, 0) + " bps" : "-";
$("sNet").className = sc.net_bps > 0 ? "g" : "";
if (cur) renderRead(cur);
}
function bars(el, opts, probs, pick, colour) {
el.innerHTML = opts.map((o) => {
const p = probs ? probs[o] || 0 : 0;
return `<span class="${o === pick ? "pick" : ""}">${COIN_LABEL[o] || o}</span><div class="bar"><i style="width:${(100 * p).toFixed(1)}%;background:${colour(o)}"></i></div><span class="v">${probs ? Math.round(100 * p) + "%" : ""}</span>`;
}).join("");
}
function renderRead(it) {
// type the headline out like it's coming off the wire
if (typed.key !== it.headline) { typed = {key: it.headline, n: 0}; }
typed.n = Math.min(it.headline.length, typed.n + 6);
$("big").innerHTML = it.headline.slice(0, typed.n) + (typed.n < it.headline.length || !it.answer ? `<span class="cursor"></span>` : "");
const a = it.answer;
$("lat").textContent = a ? `answered in ${it.ms} ms` : it.error ? "skipped" : "reading…";
bars($("qCoin"), COINS, a && a.asset.probs, a && a.asset.choice, () => "var(--jev)");
bars($("qDir"), ["bullish", "bearish", "neutral"], a && a.direction.probs, a && a.direction.choice,
(o) => o === "bullish" ? "var(--good)" : o === "bearish" ? "var(--bad)" : "var(--dim)");
const imp = a ? a.impact.score : -1;
$("qImp").innerHTML = IMPACT.map((l, i) => `<div class="${a && i <= Math.round(imp) ? (i === Math.round(imp) ? "top" : "on") : ""}">${l}</div>`).join("");
$("impVal").textContent = a ? imp.toFixed(2) : "-";
const d = $("decision");
if (!a) { d.className = "decision"; d.innerHTML = it.error ? "skipped · Jev didn't answer" : "Jev is reading the headline…"; }
else if (it.trade) {
d.className = "decision " + (it.side > 0 ? "long" : "short");
d.innerHTML = `<span>TRADE · ${it.side > 0 ? "LONG" : "SHORT"} ${it.coin}</span><small>1 min after the news · hold ${S.after_min} min</small>`;
} else {
const why = !it.coin ? "no tradeable coin" : it.direction === "neutral" ? "no clear direction" : "not big enough to trade";
d.className = "decision"; d.innerHTML = `<span>NO TRADE · ${why}</span><small>impact ${IMPACT[Math.round(it.impact)]}</small>`;
}
renderChart(it);
}
function renderChart(it) {
const el = $("chart"), v = $("verdict");
if (!it.answer) { el.innerHTML = `<div class="wait">waiting for Jev…</div>`; v.innerHTML = ""; return; }
const coin = it.coin || "BTC", path = (it.paths || {})[coin];
$("nextTitle").textContent = `What ${coin} did next · actual price, ${S.after_min} minutes after the headline`;
if (!path || path.length < 2) { el.innerHTML = `<div class="wait">no price data for this one</div>`; v.innerHTML = ""; return; }
const W = el.clientWidth, H = el.clientHeight; if (W < 50 || H < 50) return;
const l = 48, r = 70, t = 14, b = 22;
const reveal = Math.min(1, (Date.now() / 1000 - it.answered) / 2.2);
const lastMin = -S.before_min + reveal * (S.after_min + S.before_min);
const shown = path.filter((p) => p[0] <= lastMin);
const vals = path.map((p) => p[1]).concat([0]);
let lo = Math.min(...vals), hi = Math.max(...vals); const pad = (hi - lo) * 0.15 || 5; lo -= pad; hi += pad;
const X = (m) => l + (m + S.before_min) / (S.after_min + S.before_min) * (W - l - r), Y = (bp) => t + (hi - bp) / (hi - lo) * (H - t - b);
let s = `<svg viewBox="0 0 ${W} ${H}"><defs><linearGradient id="g" x1="0" x2="0" y1="0" y2="1"><stop offset="0" stop-color="#8b7bff" stop-opacity=".25"/><stop offset="1" stop-color="#8b7bff" stop-opacity="0"/></linearGradient></defs>`;
const step = hi - lo > 80 ? 25 : hi - lo > 30 ? 10 : 5;
for (let g = Math.ceil(lo / step) * step; g <= hi; g += step)
s += `<line class="${g === 0 ? "gl zero" : "gl"}" x1="${l}" x2="${W - r}" y1="${Y(g)}" y2="${Y(g)}" ${g === 0 ? 'stroke="#3a4556"' : ""}/><text class="ax" x="${l - 8}" y="${Y(g) + 4}" text-anchor="end">${g > 0 ? "+" : ""}${g}</text>`;
for (const m of [-10, 0, 15, 30, 45, 60]) s += `<text class="ax" x="${X(m)}" y="${H - 5}" text-anchor="middle">${m === 0 ? "news" : (m > 0 ? "+" : "") + m + "m"}</text>`;
if (it.trade) s += `<rect x="${X(0)}" y="${t}" width="${X(S.after_min) - X(0)}" height="${H - t - b}" fill="${it.side > 0 ? "rgba(63,214,138,.05)" : "rgba(255,93,108,.05)"}"/>`;
s += `<line x1="${X(0)}" x2="${X(0)}" y1="${t}" y2="${H - b}" stroke="var(--jev)" stroke-width="1.5" stroke-dasharray="4 4"/>`;
if (shown.length > 1) {
const pts = shown.map((p) => `${X(p[0]).toFixed(1)},${Y(p[1]).toFixed(1)}`).join(" ");
s += `<polygon points="${X(shown[0][0])},${H - b} ${pts} ${X(shown.at(-1)[0])},${H - b}" fill="url(#g)"/><polyline points="${pts}" fill="none" stroke="#e6ecf3" stroke-width="2" stroke-linejoin="round"/>`;
const e = shown.at(-1);
s += `<circle cx="${X(e[0])}" cy="${Y(e[1])}" r="4.5" fill="#fff"/><text x="${X(e[0]) + 9}" y="${Y(e[1]) + 4}" style="fill:var(--fg);font:700 12px var(--mono);paint-order:stroke;stroke:var(--bg);stroke-width:4px">${sgn(e[1])} bps</text>`;
}
el.innerHTML = s + "</svg>";
if (reveal < 1) { v.innerHTML = ""; return; }
const move = path.find((p) => p[0] === S.after_min)?.[1] ?? path.at(-1)[1];
if (it.trade) {
const right = it.side * move > 0;
v.innerHTML = `<span class="verdict ${right ? "good" : "bad"}">${right ? "RIGHT" : "WRONG"} · ${sgn(it.net_bps ?? move)} bps after costs</span>`;
} else {
v.innerHTML = `<span class="verdict pass">JEV PASSED · move was ${sgn(move)} bps</span>`;
}
}
setInterval(poll, 250); poll();
setInterval(() => S && S.feed.length && renderRead(S.feed.at(-1)), 60);
</script>
</body>
</html>