Skip to content

Commit 2d92ea0

Browse files
committed
fix: web search, navbar balance display, and dev reload
- Switch search from broken Gamma API _q to /public-search endpoint - Display Portfolio + Cash in navbar (matching Polymarket UI) - Replace staleness dot with 60s auto-polling balance - Fix search input Enter key not triggering search - Add --reload flag to pm-trader web for dev auto-restart
1 parent 56bda67 commit 2d92ea0

7 files changed

Lines changed: 87 additions & 48 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ All notable changes to `polymarket-on-paper` are documented here.
66
77
## Unreleased
88

9+
### Fixed
10+
- **Search**: switch from broken Gamma API `_q` param to official `/public-search` endpoint — search now returns relevant results
11+
- **Web navbar**: display both Portfolio (total value) and Cash, matching Polymarket's UI
12+
13+
### Changed
14+
- **Web navbar**: replace staleness dot indicator with 60-second auto-polling for balance
15+
- **Web search**: replace `<form>` submit with `onKeyDown` handler to fix Enter key not triggering search
16+
17+
### Added
18+
- **`pm-trader web --reload`**: auto-reload backend on Python file changes during development
19+
- **Web search button**: visual Search button alongside Enter-to-search
20+
921
## [0.1.7] - 2026-03-01
1022

1123
### Added

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ mcp_server.py → engine.py (trading tools, 30 MCP tools)
7777
- **Fee formula**: `(bps/10000) * min(price, 1-price) * shares` — matches Polymarket exactly
7878
- **FOK** (fill-or-kill): all or nothing. **FAK** (fill-and-kill): partial fills ok
7979
- **Limit orders**: GTC (rest until filled/cancelled) or GTD (expire at timestamp)
80+
- **Search**: uses Gamma API `/public-search?q=` endpoint (not `_q` on `/markets`, which is broken). Default limits: MCP 10, Web 20, api.py 10.
8081
- **No price/book caching**: always live from API. Market metadata cached 5 min.
8182
- **Multi-account**: separate SQLite databases at `~/.pm-trader/<account>/paper.db`
8283

frontend/src/components/Layout.tsx

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,17 @@ export default function Layout() {
77
const [accounts, setAccounts] = useState<string[]>([])
88
const [selected, setSelected] = useState(getAccount())
99
const [balance, setBalance] = useState<Balance | null>(null)
10-
const [lastFetch, setLastFetch] = useState(Date.now())
11-
const [staleness, setStaleness] = useState(0)
12-
1310
useEffect(() => {
1411
fetchAccounts().then(setAccounts).catch(() => {})
1512
}, [])
1613

1714
useEffect(() => {
1815
setAccount(selected)
19-
fetchBalance().then(b => { setBalance(b); setLastFetch(Date.now()) }).catch(() => {})
20-
}, [selected])
21-
22-
// Data freshness indicator — update every second
23-
useEffect(() => {
24-
const interval = setInterval(() => setStaleness(Math.floor((Date.now() - lastFetch) / 1000)), 1000)
16+
const refresh = () => fetchBalance().then(setBalance).catch(() => {})
17+
refresh()
18+
const interval = setInterval(refresh, 60_000)
2519
return () => clearInterval(interval)
26-
}, [lastFetch])
20+
}, [selected])
2721

2822
return (
2923
<div style={{ minHeight: '100vh', background: '#fafafa' }}>
@@ -44,17 +38,18 @@ export default function Layout() {
4438
</div>
4539
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
4640
{balance && (
47-
<span style={{ fontSize: '0.9em', color: '#333' }}>
48-
${balance.cash.toLocaleString(undefined, { minimumFractionDigits: 2 })}
49-
</span>
50-
)}
51-
{staleness <= 5 ? null : (
52-
<span style={{
53-
width: 8, height: 8, borderRadius: '50%', display: 'inline-block',
54-
background: staleness <= 10 ? '#eab308' : '#dc2626',
55-
}} title={`Data ${staleness}s old`} />
41+
<div style={{ display: 'flex', gap: 12, alignItems: 'center', fontSize: '0.9em' }}>
42+
<span style={{ color: '#333' }}>
43+
<span style={{ color: '#888', marginRight: 4 }}>Portfolio</span>
44+
${balance.total_value.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
45+
</span>
46+
<span style={{ color: '#333' }}>
47+
<span style={{ color: '#888', marginRight: 4 }}>Cash</span>
48+
${balance.cash.toLocaleString(undefined, { minimumFractionDigits: 2 })}
49+
</span>
50+
</div>
5651
)}
57-
<select
52+
<select
5853
value={selected}
5954
onChange={e => setSelected(e.target.value)}
6055
style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', fontSize: '0.85em' }}

frontend/src/pages/Markets.tsx

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState } from 'react'
1+
import { useEffect, useState, useCallback } from 'react'
22
import { fetchMarkets } from '../api/client'
33
import type { MarketItem } from '../api/types'
44
import MarketList from '../components/MarketList'
@@ -23,41 +23,47 @@ export default function Markets() {
2323
const [activeTag, setActiveTag] = useState<string | null>(null)
2424
const [sort, setSort] = useState('volume')
2525

26-
const loadMarkets = (q?: string, tag?: string) => {
27-
fetchMarkets(q || undefined, tag || undefined, sort).then(setMarkets).catch(() => {})
28-
}
26+
const doSearch = useCallback((q?: string, tag?: string, s?: string) => {
27+
fetchMarkets(q || undefined, tag || undefined, s || sort)
28+
.then(setMarkets)
29+
.catch(err => console.error('Search failed:', err))
30+
}, [sort])
2931

3032
useEffect(() => {
31-
loadMarkets()
33+
doSearch()
3234
}, [])
3335

34-
const handleSearch = (e: React.FormEvent) => {
35-
e.preventDefault()
36+
const handleSearch = useCallback(() => {
3637
setActiveTag(null)
37-
loadMarkets(query)
38-
}
38+
doSearch(query)
39+
}, [query, doSearch])
3940

4041
const handleTagClick = (slug: string) => {
4142
const next = activeTag === slug ? null : slug
4243
setActiveTag(next)
4344
setQuery('')
44-
loadMarkets(undefined, next || undefined)
45+
doSearch(undefined, next || undefined)
4546
}
4647

4748
return (
4849
<div>
4950
{/* Search */}
50-
<form onSubmit={handleSearch} style={{ marginBottom: 16 }}>
51+
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
5152
<input
5253
value={query} onChange={e => setQuery(e.target.value)}
54+
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); handleSearch() } }}
5355
placeholder="Search markets... e.g. 'Trump', 'Bitcoin', 'Fed rate'"
54-
style={{ width: '100%', padding: '10px 16px', fontSize: '0.95em', borderRadius: 8, border: '1px solid #ddd', boxSizing: 'border-box' }}
56+
style={{ flex: 1, padding: '10px 16px', fontSize: '0.95em', borderRadius: 8, border: '1px solid #ddd', boxSizing: 'border-box' }}
5557
/>
56-
</form>
58+
<button onClick={handleSearch} style={{
59+
padding: '10px 20px', borderRadius: 8, border: '1px solid #ddd', background: '#111', color: 'white',
60+
fontSize: '0.9em', cursor: 'pointer',
61+
}}>Search</button>
62+
</div>
5763

5864
{/* Sort + Categories */}
5965
<div style={{ display: 'flex', gap: 8, marginBottom: 20, flexWrap: 'wrap', alignItems: 'center' }}>
60-
<select value={sort} onChange={e => { setSort(e.target.value); loadMarkets(query || undefined, activeTag || undefined) }}
66+
<select value={sort} onChange={e => { setSort(e.target.value); doSearch(query || undefined, activeTag || undefined, e.target.value) }}
6167
style={{ padding: '4px 10px', borderRadius: 16, border: '1px solid #ddd', fontSize: '0.8em' }}>
6268
<option value="volume">Sort: Volume</option>
6369
<option value="liquidity">Sort: Liquidity</option>
@@ -77,7 +83,7 @@ export default function Markets() {
7783
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
7884
<MarketList markets={markets} selected={selected} onSelect={setSelected} />
7985
{selected ? (
80-
<TradePanel market={selected} onTradeComplete={() => loadMarkets(query || undefined, activeTag || undefined)} />
86+
<TradePanel market={selected} onTradeComplete={() => doSearch(query || undefined, activeTag || undefined)} />
8187
) : (
8288
<div style={{ background: 'white', borderRadius: 8, padding: 40, textAlign: 'center', color: '#888', boxShadow: '0 1px 3px rgba(0,0,0,0.08)' }}>
8389
Select a market to trade

pm_trader/api.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,22 @@ def list_markets(
175175
return self._parse_market_list(self._gamma_get("/markets", params=params))
176176

177177
def search_markets(self, query: str, *, limit: int = 10) -> list[Market]:
178-
"""Search markets by text query."""
179-
return self._parse_market_list(
180-
self._gamma_get("/markets", params={"_q": query, "limit": limit})
181-
)
178+
"""Search markets by text query via /public-search endpoint."""
179+
data = self._gamma_get("/public-search", params={
180+
"q": query,
181+
"limit_per_type": limit,
182+
})
183+
if not isinstance(data, dict):
184+
return []
185+
markets: list[Market] = []
186+
for event in data.get("events", []):
187+
for m in event.get("markets", []):
188+
if _has_condition_id(m):
189+
try:
190+
markets.append(_parse_market(m))
191+
except (KeyError, TypeError):
192+
continue
193+
return markets[:limit]
182194

183195
def get_tags(self) -> list[dict]:
184196
"""Fetch all market tags/categories from Gamma API. Cached 5 min."""

pm_trader/cli.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -806,13 +806,16 @@ def watch(ctx: click.Context, slugs_or_ids: tuple[str, ...], outcomes: tuple[str
806806
@main.command()
807807
@click.option("--host", default="127.0.0.1", help="Host to bind")
808808
@click.option("--port", default=8000, type=int, help="Port to bind")
809-
def web(host: str, port: int) -> None:
809+
@click.option("--reload", is_flag=True, default=False, help="Auto-reload on code changes")
810+
def web(host: str, port: int, reload: bool) -> None:
810811
"""Start the web dashboard server."""
811812
import uvicorn
812-
from pm_trader.web.app import create_app
813-
app = create_app()
814813
click.echo(f"Starting dashboard at http://{host}:{port}")
815-
uvicorn.run(app, host=host, port=port)
814+
if reload:
815+
uvicorn.run("pm_trader.web.app:create_app", factory=True, host=host, port=port, reload=True)
816+
else:
817+
from pm_trader.web.app import create_app
818+
uvicorn.run(create_app(), host=host, port=port)
816819

817820

818821
# ---------------------------------------------------------------------------

tests/test_api.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,10 @@ def test_list_markets_empty(self, client: PolymarketClient, httpx_mock):
265265

266266
class TestSearchMarkets:
267267
def test_search(self, client: PolymarketClient, httpx_mock):
268-
httpx_mock.add_response(json=[SAMPLE_GAMMA_MARKET])
268+
httpx_mock.add_response(json={
269+
"events": [{"markets": [SAMPLE_GAMMA_MARKET]}],
270+
"pagination": {},
271+
})
269272
results = client.search_markets("bitcoin")
270273
assert len(results) == 1
271274
assert "bitcoin" in results[0].slug
@@ -668,12 +671,11 @@ def test_string_response_returns_empty(
668671
# ---------------------------------------------------------------------------
669672

670673
class TestSearchMarketsNonList:
671-
def test_non_list_response_returns_empty(
674+
def test_non_dict_response_returns_empty(
672675
self, client: PolymarketClient, httpx_mock
673676
):
674-
"""When _gamma_get returns a dict instead of a list,
675-
search_markets should return [] (line 179)."""
676-
httpx_mock.add_response(json={"error": "unexpected format"})
677+
"""When _gamma_get returns a non-dict, search_markets should return []."""
678+
httpx_mock.add_response(json="unexpected")
677679
result = client.search_markets("bitcoin")
678680
assert result == []
679681

@@ -685,6 +687,14 @@ def test_none_response_returns_empty(
685687
result = client.search_markets("bitcoin")
686688
assert result == []
687689

690+
def test_empty_events_returns_empty(
691+
self, client: PolymarketClient, httpx_mock
692+
):
693+
"""When events list is empty, search_markets should return []."""
694+
httpx_mock.add_response(json={"events": [], "pagination": {}})
695+
result = client.search_markets("bitcoin")
696+
assert result == []
697+
688698

689699
# ---------------------------------------------------------------------------
690700
# Line 213: get_tick_size cache hit path

0 commit comments

Comments
 (0)