Skip to content

Commit 2fc77f9

Browse files
committed
feat(txodds): add sharp-movement research watcher and match grading
Watches /api/board for odds moves via agent/market.ts's select1x2Market, queues sharp-movement WANTs for event-mode buyers, and grades delivered predictions against TxLINE's live score snapshots once matches end.
1 parent f444dfa commit 2fc77f9

24 files changed

Lines changed: 933 additions & 26 deletions

coral-agents/seller-agent/README.md

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ In arbiter mode, the seller verifies the escrow buyer as the vault PDA from `DEP
2222
|---|---|
2323
| `src/index.ts` | Coral market loop and funding verification. |
2424
| `src/escrow.ts` | Read-only escrow funding check. |
25-
| `src/service.ts` | Service delivery for `txline` and `freelance`. |
25+
| `src/service.ts` | Service delivery for `txline`, `sharp-movement`, and `freelance`. |
2626

2727
## Harness Adapter
2828

@@ -72,6 +72,22 @@ whole point is judging the first loop's output with no visibility into its reaso
7272
merge above doesn't apply to. Off by default: it doubles the LLM calls per bid decision. See `API.md`
7373
for the harness-runtime environment reference.
7474

75+
## Sharp-Movement Analysis (opt-in)
76+
77+
Set `SERVICES=txline,sharp-movement` to let this seller also bid on `sharp-movement` WANTs — a report
78+
on a fixture's *current* market state (magnitude/confidence from the leading outcome's spread, plus
79+
an LLM read via the same colorful `liveReadOrFallback()` prompt `txline`'s edge action uses) sold in
80+
response to a WANT the research watcher raised because a real odds move already happened. The seller
81+
doesn't re-detect the move itself — by the time the WANT exists, `examples/txodds/research/watcher.ts`
82+
already confirmed one — it just delivers a rich analysis of the fixture as it stands now. Delivered
83+
payload: `{service, fixtureId, magnitude, confidence, spreadPct, leadingLabel, market, analysis}`.
84+
`leadingLabel` (`part1` | `x` | `part2`) is what `examples/txodds/research/grade.ts` later grades
85+
against the real final score — see `research/GRADING.md`.
86+
87+
Normally paired with `examples/txodds/coral/round.ts`'s `SHARP_MOVEMENT_ENABLED=1`, which sets this
88+
`SERVICES` value automatically and points the buyer's `WANT_FEED_URL` at the watcher's queue instead
89+
of a fixed rotating arg.
90+
7591
## Optional Upstream Procurement
7692

7793
Set `PROCURE_RAIL=x402` to have the seller buy an upstream resource for real, paid over x402, after
@@ -101,9 +117,9 @@ Variables:
101117
|---|---|
102118
| `SELLER_WALLET` | Payout address. |
103119
| `AGENT_NAME` | Agent/persona name. |
104-
| `SERVICES` | Comma-separated supported services. Current coded services are `txline` and `freelance`. |
120+
| `SERVICES` | Comma-separated supported services. Current coded services are `txline`, `sharp-movement`, and `freelance`. |
105121
| `FLOOR_SOL` | Base business floor in SOL for a deterministic service; the real floor for an `LLM_DELIVERY_TOKENS`-listed service adds a derived surcharge on top — see Bid Decision Loop above. |
106-
| `LLM_DELIVERY_TOKENS` | JSON map of service → max_tokens, e.g. `{"txline":260}` — services this seller's delivery code actually calls an LLM for, so that service's floor is derived from real cost instead of `FLOOR_SOL` alone. |
122+
| `LLM_DELIVERY_TOKENS` | JSON map of service → max_tokens, e.g. `{"txline":260,"sharp-movement":260}` — services this seller's delivery code actually calls an LLM for, so that service's floor is derived from real cost instead of `FLOOR_SOL` alone. |
107123
| `PERSONA` | Persona label/config. |
108124
| `STRATEGY` | Pricing posture once `REPUTATION_URL` clearing data is available: `undercut` \| `premium` \| `balanced` (default). No-op without `REPUTATION_URL`. |
109125
| `BID_REVIEW_ENABLED` | Set to `1` to run a second, adversarial review loop before posting a bid — see above. |

coral-agents/seller-agent/coral-agent.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ edition = 4
77
[agent]
88
name = "seller-agent"
99
version = "0.1.0"
10-
description = "TxODDS marketplace seller. Bids on txline WANTs, verifies arbiter-funded escrow, and delivers a fair-line read."
10+
description = "TxODDS marketplace seller. Bids on txline WANTs, verifies arbiter-funded escrow, and delivers a fair-line read. SERVICES=txline,sharp-movement also lets it analyze fixtures the research watcher flags as having moved."
1111
summary = "TypeScript TxODDS seller that competes in CoralOS and settles through arbiter-gated escrow."
1212
readme = "Bids in the shared market thread (WANT -> BID), issues ESCROW_REQUIRED with settlement=arbiter, verifies the funded vault escrow on DEPOSITED, then delivers the TxODDS read."
1313

@@ -18,11 +18,11 @@ expression = "MIT"
1818
[options]
1919
SELLER_WALLET = { type = "string", description = "Devnet wallet pubkey that receives payments" }
2020
AGENT_NAME = { type = "string", default = "seller-agent", description = "Market identity for this seller persona" }
21-
SERVICES = { type = "string", default = "txline", description = "Inventory; the TxODDS demo uses txline" }
21+
SERVICES = { type = "string", default = "txline", description = "Inventory - default demo uses txline; set to txline,sharp-movement (with SHARP_MOVEMENT_ENABLED=1 on the round launcher) to also analyze research-watcher-flagged moves" }
2222
FLOOR_SOL = { type = "f64", default = 0.0005, description = "Minimum bid in SOL" }
2323
PERSONA = { type = "string", default = "a TxODDS specialist selling verified fair-line reads", description = "Bidding persona" }
2424
STRATEGY = { type = "string", default = "", description = "Pricing posture once REPUTATION_URL clearing data is available: undercut | premium | balanced (default)" }
25-
LLM_DELIVERY_TOKENS = { type = "string", default = "", description = "JSON map of service -> max_tokens for services this seller's delivery code calls an LLM for, e.g. {\"txline\":260} - derives that service's floor from real LLM cost instead of FLOOR_SOL alone" }
25+
LLM_DELIVERY_TOKENS = { type = "string", default = "", description = "JSON map of service -> max_tokens for services this seller's delivery code calls an LLM for, e.g. {\"txline\":260,\"sharp-movement\":260} - derives that service's floor from real LLM cost instead of FLOOR_SOL alone" }
2626
BID_REVIEW_ENABLED = { type = "string", default = "0", description = "Set to 1 to run a second, adversarial review loop before posting a bid" }
2727
REPUTATION_URL = { type = "string", default = "", description = "The feed's /api/reputation - when set, gates whether this seller bids at all and feeds clearing-price awareness into pricing" }
2828
SERVICE = { type = "string", default = "txline", description = "Delivery service; only txline is routed" }

coral-agents/seller-agent/src/service.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('deliverService routing', () => {
2323
expect(out).toEqual({
2424
error: 'unsupported service',
2525
service: 'coingecko',
26-
supported: ['txline', 'freelance'],
26+
supported: ['txline', 'freelance', 'sharp-movement'],
2727
})
2828
})
2929

@@ -110,6 +110,40 @@ describe('deliverService routing', () => {
110110
expect(out.analysis.note).toContain('deterministic fallback')
111111
})
112112

113+
it('delivers a sharp-movement report with magnitude/confidence/leadingLabel', async () => {
114+
global.fetch = vi.fn(async (url: string) => {
115+
if (url.endsWith('/auth/guest/start')) return { ok: true, json: async () => ({ token: 'jwt' }) }
116+
if (url.includes('/api/odds/snapshot/123')) {
117+
return {
118+
ok: true,
119+
json: async () => ([{
120+
SuperOddsType: '1X2',
121+
PriceNames: ['part1', 'x', 'part2'],
122+
Pct: ['70', '20', '10'], // spread(top two) = 50pp -> extreme, confidence clamps to 1
123+
}]),
124+
}
125+
}
126+
return { ok: true, json: async () => ([{ FixtureId: 123, Participant1: 'A', Participant2: 'B' }]) }
127+
}) as unknown as typeof fetch
128+
129+
const out = JSON.parse(await deliverService('sharp-movement 123'))
130+
expect(out).toMatchObject({
131+
service: 'sharp-movement', fixtureId: '123', magnitude: 'extreme', confidence: 1,
132+
spreadPct: 50, leadingLabel: 'part1',
133+
})
134+
})
135+
136+
it('sharp-movement returns an honest error payload when no priced market exists', async () => {
137+
global.fetch = vi.fn(async (url: string) => {
138+
if (url.endsWith('/auth/guest/start')) return { ok: true, json: async () => ({ token: 'jwt' }) }
139+
if (url.includes('/api/odds/snapshot/')) return { ok: true, json: async () => ([]) }
140+
return { ok: true, json: async () => ([]) }
141+
}) as unknown as typeof fetch
142+
143+
const out = JSON.parse(await deliverService('sharp-movement 999'))
144+
expect(out).toEqual({ service: 'sharp-movement', fixtureId: '999', error: 'no priced 1X2 market available' })
145+
})
146+
113147
it('reports delivery LLM fallback metadata for txline edge analysis', async () => {
114148
global.fetch = vi.fn(async (url: string) => {
115149
if (url.endsWith('/auth/guest/start')) return { ok: true, json: async () => ({ token: 'jwt' }) }

coral-agents/seller-agent/src/service.ts

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,19 @@
22
* Seller services.
33
*
44
* `txline` - verified TxLINE fair-line reads for a fixture.
5+
* `sharp-movement` - a report on the CURRENT state of a fixture's 1X2 market (magnitude/confidence
6+
* from the leader's spread over the field, plus an LLM read), sold in response to a WANT the
7+
* research watcher raised because a real move already happened. The seller doesn't re-detect the
8+
* move itself - by the time this WANT exists, examples/txodds/research/watcher.ts already confirmed
9+
* one - it just delivers a rich analysis of the fixture as it stands now.
510
* `freelance` - the generic LLM worker (the freelancer market's baseline seller): the brief goes to
611
* the LLM, the deliverable comes back as JSON. Without an LLM key it returns an error payload, which
712
* the verifier fails and the buyer refuses to pay for - no-capability sellers don't get released.
813
*/
914
import { complete, llmRuntimeInfo, parseJsonReply, sha256Hex, type LlmUse } from '@pay/agent-runtime'
1015

1116
const TXLINE_BASE = process.env.TXLINE_BASE_URL || 'https://txline-dev.txodds.com'
12-
const SUPPORTED_SERVICES = ['txline', 'freelance']
17+
const SUPPORTED_SERVICES = ['txline', 'freelance', 'sharp-movement']
1318
type DeliveryOrder = { round?: number }
1419

1520
export interface ServiceDelivery {
@@ -51,6 +56,7 @@ export async function deliverServiceResult(request: string, order?: DeliveryOrde
5156
const [first, ...rest] = request.trim().split(/\s+/).filter(Boolean)
5257
const service = (first ?? 'txline').toLowerCase()
5358
if (service === 'freelance') return freelanceService(rest.join(' '), order)
59+
if (service === 'sharp-movement') return sharpMovementService(rest.join(' '), order)
5460
if (service !== 'txline') {
5561
return {
5662
payload: JSON.stringify({ error: 'unsupported service', service, supported: SUPPORTED_SERVICES }),
@@ -60,6 +66,67 @@ export async function deliverServiceResult(request: string, order?: DeliveryOrde
6066
return txlineService(rest.join(' '), order)
6167
}
6268

69+
function fixtureIdFrom(request: string): string | undefined {
70+
return request.trim().split(/\s+/).find((token) => /^\d+$/.test(token))
71+
}
72+
73+
function hasFinitePrice(market: Record<string, unknown> | undefined): boolean {
74+
const pct = (market?.Pct ?? []) as Array<string | number>
75+
return pct.some((p) => Number.isFinite(Number(p)))
76+
}
77+
78+
/** Same selection examples/txodds/agent/market.ts uses for the watcher - kept as a local copy since
79+
* this package is a separate npm workspace with no import path to that one. */
80+
function select1x2Market(odds: unknown): Record<string, unknown> | undefined {
81+
if (!Array.isArray(odds)) return undefined
82+
const markets = odds as Array<Record<string, unknown>>
83+
return markets.find((m) => String(m.SuperOddsType ?? '').includes('1X2') && hasFinitePrice(m))
84+
?? markets.find(hasFinitePrice)
85+
}
86+
87+
async function sharpMovementService(request: string, order?: DeliveryOrder): Promise<ServiceDelivery> {
88+
const fixtureId = fixtureIdFrom(request) ?? request.trim()
89+
const [odds, fixtures] = await Promise.all([
90+
txlineGet(`/api/odds/snapshot/${fixtureId}`),
91+
txlineGet('/api/fixtures/snapshot'),
92+
])
93+
const market = select1x2Market(odds)
94+
const fx = Array.isArray(fixtures)
95+
? (fixtures as Array<Record<string, unknown>>).find((f) => String(f.FixtureId) === String(fixtureId))
96+
: undefined
97+
const teams = fx ? { home: fx.Participant1, away: fx.Participant2, competition: fx.Competition } : undefined
98+
const matchup = teams ? `${teams.home} v ${teams.away}` : `fixture ${fixtureId}`
99+
100+
if (!market) {
101+
return {
102+
payload: JSON.stringify({ service: 'sharp-movement', fixtureId, error: 'no priced 1X2 market available' }),
103+
llm: [deliveryLlm(order, 'skipped', 'no priced market to analyze', 'deterministic market check', { includeModel: false })],
104+
}
105+
}
106+
107+
// Magnitude/confidence come from the CURRENT market's decisiveness (spread between the top two
108+
// outcomes), not a delta against history - the watcher already confirmed a real move happened
109+
// before this WANT existed, so the seller reports the fixture as it stands now rather than
110+
// duplicating the watcher's own before/after diffing.
111+
const pctNums = ((market.Pct ?? []) as Array<string | number>).map(Number).filter(Number.isFinite)
112+
const sorted = [...pctNums].sort((a, b) => b - a)
113+
const spread = sorted.length >= 2 ? sorted[0] - sorted[1] : 0
114+
const magnitude: 'moderate' | 'sharp' | 'extreme' = spread >= 30 ? 'extreme' : spread >= 15 ? 'sharp' : 'moderate'
115+
const confidence = Number(Math.max(0, Math.min(1, spread / 40)).toFixed(2))
116+
const names = (market.PriceNames ?? []) as string[]
117+
const leaderIndex = pctNums.indexOf(Math.max(...pctNums))
118+
const leadingLabel = names[leaderIndex] ?? String(leaderIndex)
119+
120+
const analysis = await liveReadOrFallback(matchup, odds, market, teams, order)
121+
return {
122+
payload: JSON.stringify({
123+
service: 'sharp-movement', fixtureId, magnitude, confidence,
124+
spreadPct: Number(spread.toFixed(1)), leadingLabel, market, analysis: analysis.value,
125+
}),
126+
llm: [analysis.llm],
127+
}
128+
}
129+
63130
async function freelanceService(brief: string, order?: DeliveryOrder): Promise<ServiceDelivery> {
64131
try {
65132
const system =

coral-agents/verifier-agent/src/verify-tools.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ describe('assessScrutiny', () => {
3030
expect(assessScrutiny({ payload })).toBe('low')
3131
})
3232

33+
it('is low for a well-formed sharp-movement delivery', () => {
34+
const payload = JSON.stringify({ service: 'sharp-movement', fixtureId: '1', magnitude: 'sharp', confidence: 0.8, spreadPct: 20, leadingLabel: 'part1', market: {}, analysis: {} })
35+
expect(assessScrutiny({ payload })).toBe('low')
36+
})
37+
3338
it('is high for a recognized service missing expected keys', () => {
3439
const payload = JSON.stringify({ service: 'txline-edge', analysis: {} })
3540
expect(assessScrutiny({ payload })).toBe('high')

coral-agents/verifier-agent/src/verify-tools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const KNOWN_SHAPES: Record<string, string[]> = {
5858
'txline-edge': ['teams', 'market', 'analysis'],
5959
'txline-odds': ['fixtureId', 'odds'],
6060
'txline-fixtures': ['count', 'fixtures'],
61+
'sharp-movement': ['fixtureId', 'magnitude', 'confidence', 'analysis'],
6162
freelance: ['result'],
6263
}
6364

examples/txodds/README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,32 @@ Requirements:
107107

108108
See `coral/README.md`.
109109

110+
## Research Watcher
111+
112+
The watcher polls `/api/board` every 60 seconds, extracts each fixture's 1X2 market with
113+
`agent/market.ts`'s `select1x2Market` (the proxy's board attaches `.odds` as an *array* of markets —
114+
this is the step that turns it into the single `{PriceNames,Pct}` object `research/detect.ts`'s
115+
`detectEvents` actually needs; skipping it, as this file used to, means no move is ever detected),
116+
diffs snapshots, and queues jobs when verified odds appear or implied probability moves beyond
117+
`MOVE_PCT`.
118+
119+
```sh
120+
npm run proxy
121+
npm run watch
122+
```
123+
124+
Watcher endpoints:
125+
126+
| Endpoint | Purpose |
127+
|---|---|
128+
| `GET /api/health` | Health check. |
129+
| `GET /queue` | Current queued events. |
130+
| `GET /next` | Pop the next event for event-mode buyer — `odds-move` events request `sharp-movement`, `new-fixture` events request `txline`. |
131+
132+
`buyer-agent`'s event-mode consumes this queue through `WANT_FEED_URL` (see
133+
`coral-agents/buyer-agent/src/feed/wantFeed.ts`), wired into the CoralOS round by
134+
`SHARP_MOVEMENT_ENABLED=1` — see `coral/README.md`'s Sharp-Movement Round section.
135+
110136
## TxODDS Notes
111137

112138
| Item | Value |
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { hasFinitePrice, select1x2Market } from './market.js'
3+
4+
describe('hasFinitePrice', () => {
5+
it('is true when at least one Pct value parses as a finite number', () => {
6+
expect(hasFinitePrice({ Pct: ['12.5', 'NA', '30'] })).toBe(true)
7+
})
8+
9+
it('is false when every Pct value is missing or unparseable', () => {
10+
expect(hasFinitePrice({ Pct: ['NA', 'NA'] })).toBe(false)
11+
expect(hasFinitePrice({})).toBe(false)
12+
expect(hasFinitePrice(undefined)).toBe(false)
13+
})
14+
})
15+
16+
describe('select1x2Market', () => {
17+
it('prefers a priced 1X2 market over other priced markets', () => {
18+
const odds = [
19+
{ SuperOddsType: 'OVER_UNDER', Pct: ['50', '50'] },
20+
{ SuperOddsType: '1X2_PARTICIPANT_RESULT', Pct: ['40', '30', '30'] },
21+
]
22+
expect(select1x2Market(odds)).toBe(odds[1])
23+
})
24+
25+
it('falls back to any priced market when no 1X2 market is priced', () => {
26+
const odds = [
27+
{ SuperOddsType: '1X2_PARTICIPANT_RESULT', Pct: ['NA', 'NA', 'NA'] },
28+
{ SuperOddsType: 'OVER_UNDER', Pct: ['55', '45'] },
29+
]
30+
expect(select1x2Market(odds)).toBe(odds[1])
31+
})
32+
33+
it('returns undefined for a non-array or an all-unpriced odds payload', () => {
34+
expect(select1x2Market(undefined)).toBeUndefined()
35+
expect(select1x2Market([{ SuperOddsType: '1X2', Pct: ['NA'] }])).toBeUndefined()
36+
})
37+
})

examples/txodds/agent/market.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/**
2+
* Shared market-selection helper for TxLINE odds arrays — `/api/odds/snapshot/{id}` returns an
3+
* array of markets (1X2, over/under, etc.); this picks the one match-winner (1X2) market, requiring
4+
* an actual priced outcome. Used by `research/watcher.ts` (to build `detect.ts`'s `BoardFixture`
5+
* shape correctly — see the module comment there for the bug this fixes) and mirrored as a local
6+
* copy in `coral-agents/seller-agent/src/service.ts`, a separate npm workspace with no import path
7+
* to this package.
8+
*/
9+
10+
export function hasFinitePrice(market: Record<string, unknown> | undefined): boolean {
11+
const pct = (market?.Pct ?? []) as Array<string | number>
12+
return pct.some((p) => Number.isFinite(Number(p)))
13+
}
14+
15+
/** The 1X2 (match-winner) market, preferred; any other priced market as a fallback. */
16+
export function select1x2Market(odds: unknown): Record<string, unknown> | undefined {
17+
if (!Array.isArray(odds)) return undefined
18+
const markets = odds as Array<Record<string, unknown>>
19+
return markets.find((m) => String(m.SuperOddsType ?? '').includes('1X2') && hasFinitePrice(m))
20+
?? markets.find(hasFinitePrice)
21+
}

examples/txodds/agent/txline.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,11 @@ export class TxLineClient {
7878
odds(fixtureId: number): Promise<unknown> {
7979
return this.get<unknown>(`/api/odds/snapshot/${fixtureId}`)
8080
}
81+
82+
/** Score events snapshot for one fixture. */
83+
scores(fixtureId: number): Promise<unknown> {
84+
return this.get<unknown>(`/api/scores/snapshot/${fixtureId}`)
85+
}
8186
}
8287

8388
/** International Friendlies — the highest-volume free-tier competition (verified in the catalog). */

0 commit comments

Comments
 (0)