Outcome Research
Outcome Research
Scoring a token is only half the loop. The OutcomeResearcher closes it — it measures what actually happened to each scored token afterwards and writes a labelled outcome that the self-learning model trains on. It is the agent that turns Seshat from a static scorer into a system that gets better.
What it picks up
Every 2 hours it pulls up to 25 composite scores that are at least 2 hours old, have a market cap recorded at scoring time (the baseline), and don't yet have an outcome row. For each, it fetches current market data and computes the multiple vs the baseline.
// Candidate query (simplified):
SELECT cs.id, cs.token_address, cs.composite_score,
cs.fee_opportunity_score, cs.developer_score,
cs.tech_research_score, cs.novelty_score,
cs.market_cap_usd_at_scoring AS mcap_at_score,
<hours since scored> AS hours_elapsed
FROM composite_scores cs
WHERE cs.market_cap_usd_at_scoring > 0
AND <hours since scored> >= 2
AND NOT EXISTS ( -- not already labelled
SELECT 1 FROM learning_outcomes lo WHERE lo.composite_score_id = cs.id
)
ORDER BY cs.scored_at ASC
LIMIT 25;
const ratio = currentMcap / mcap_at_score; // the core signalTwo tiers
The agent is cost-aware: obvious outcomes are decided instantly from market data, and only genuinely ambiguous cases spend a Claude call.
Tier 1 — obvious (no AI, instant)
Three rules catch the clear-cut cases without any model. They never call Claude.
// checkTier1() — returns a label immediately, or null to escalate // 1. Dead — no market data 24h+ after scoring if (currentMcap <= 0 && hoursElapsed >= 24) → 'dump' // presumed dead/abandoned // 2. Fast rug — lost >80% within the first 4 hours if (ratio > 0 && ratio <= 0.2 && hoursElapsed <= 4) → 'dump' // 3. Undeniable winner — 5× or more if (ratio >= 5.0) → 'strong_positive' // Everything else (≈ 0.2× to 5×) returns null → goes to Tier 2.
Tier 2 — ambiguous (Claude researches with tools)
The middle ground is where context matters — a token that 10×'d then settled at 4× is not a dump; a flat token with an active dev and growing holders is not neutral-bad. So Seshat hands the case to claude-haiku-4-5 with three tools and a bounded reasoning loop (up to 6 tool rounds, then a forced final answer).
get_price_trajectory
Current mcap, multiple vs baseline, 24h volume, and a liquidity-health verdict (critical / low / healthy). Re-reads Dexscreener.
search_developer_activity
Scrapes DuckDuckGo for recent mentions of the developer's handle + token name to gauge whether the dev stayed active or went quiet.
get_holder_data
Unique holder count via Basescan (when a key is configured) — growing vs fleeing community. Capped at the 100-per-page API limit.
Claude returns structured JSON — the label, a trajectory classification, a narrative explaining why, and key factors. The trajectory vocabulary is part of what makes the labels richer than a price check.
// Required JSON from the Tier-2 agent:
{
"outcome_label": "strong_positive|mild_positive|neutral|mild_negative|dump",
"trajectory": "steady_rise|peak_then_settle|spike_and_crash|
slow_decline|flat|immediate_dump|unknown",
"peak_multiple_estimate": <number|null>,
"narrative": "3-5 sentences: WHY this label? dev? community? liquidity?",
"key_factors": ["factor1", "factor2", "factor3"],
"confidence": "low|medium|high"
}The outcome labels
Both tiers produce one of five labels. These are the categories the self-learning model maps to numeric targets when it retrains.
Clear sustained upside — dev active, liquidity stable, holders growing.
Positive but not exceptional, or peaked high then settled above baseline.
Trading flat, or mixed signals (some growth, some decline).
Declining but not dead — liquidity still present, some activity.
Liquidity pulled, >70% loss maintained, or developer abandoned the project.
Graceful degradation
If no Anthropic API key is configured, Tier 2 doesn't stall — it falls back to a deterministic ratio-based label, bucketed by hours elapsed, so the learning loop keeps producing training data with zero model calls. The same fallback catches a malformed Claude response.
// ratioLabel() — the no-AI fallback (bucketed by age)
if (hoursElapsed < 24) {
if (ratio >= 3.0) return 'strong_positive';
if (ratio >= 1.5) return 'mild_positive';
if (ratio >= 0.7) return 'neutral';
if (ratio >= 0.3) return 'mild_negative';
return 'dump';
}
// older than 24h — wider bands (more time to move)
if (ratio >= 5.0) return 'strong_positive';
if (ratio >= 2.0) return 'mild_positive';
if (ratio >= 0.5) return 'neutral';
if (ratio >= 0.2) return 'mild_negative';
return 'dump';What gets written
Each outcome is inserted into learning_outcomes, linked back to the exact composite score it grades. The narrative, trajectory and key factors are bundled into signal_scores_at_alert as JSON for later audit. INSERT OR IGNORE keeps it idempotent — a re-run never double-labels a score.
INSERT OR IGNORE INTO learning_outcomes ( token_address, composite_score_id, composite_score_at_alert, signal_scores_at_alert, -- JSON: scores + narrative + trajectory + key_factors price_at_alert, measured_at, hours_elapsed, price_at_measure, price_change_pct, outcome_label, outcome_magnitude -- the ratio (current / baseline) ) VALUES (...);
Where it sits in the loop
Composite scoring records a baseline mcap → the OutcomeResearcher grades each score 2h+ later → the weekly ridge regression in the self-learning model trains on the accumulated labels → dimension weights update → the next scores are sharper. Operators can also seed known historical outcomes via POST /api/admin/bootstrap to warm-start the loop. See Self-Learning for the training side.