Market Activity Badge Implementation Guide¶
Overview¶
This guide provides practical implementation instructions for integrating the market activity badge system into the FairWins and ClearPath platforms.
Prerequisites¶
- Familiarity with React and TypeScript
- Understanding of the existing codebase structure
- Access to market data and metrics
- Understanding of the badge specification (market-activity-badges.md)
Architecture¶
Component Structure¶
frontend/src/
├── components/
│ ├── ui/
│ │ └── Badge.jsx (existing)
│ ├── fairwins/
│ │ └── MarketTile.jsx (existing)
│ └── badges/
│ ├── MarketBadge.tsx (new)
│ └── BadgeCalculator.ts (new)
├── hooks/
│ └── useBadges.ts (new)
├── contexts/
│ └── BadgeContext.tsx (new)
├── utils/
│ └── badgeUtils.ts (new)
└── types/
└── badges.ts (new)
Data Flow¶
Step 1: Type Definitions¶
Copy the type definitions from badge-types.ts to your project:
Or create the file manually with the types from the reference documentation.
Step 2: Badge Calculation Utility¶
Create frontend/src/utils/badgeUtils.ts:
import { Badge, BadgeType, MarketMetrics, BadgeConfig, DEFAULT_BADGE_CONFIG } from '../types/badges';
import { getBadgeIcon, getBadgeVariant, getBadgePriority, formatBadgeText } from '../types/badges';
/**
* Calculate all eligible badges for a market
*/
export function calculateBadges(
market: MarketMetrics,
config: BadgeConfig = DEFAULT_BADGE_CONFIG
): Badge[] {
const badges: Badge[] = [];
const now = new Date();
// Time-based badges
if (config.enabled[BadgeType.CLOSING_SOON]) {
const closingBadge = checkClosingSoon(market, config, now);
if (closingBadge) badges.push(closingBadge);
}
if (config.enabled[BadgeType.JUST_OPENED]) {
const openedBadge = checkJustOpened(market, config, now);
if (openedBadge) badges.push(openedBadge);
}
if (config.enabled[BadgeType.RESOLVING_SOON]) {
const resolvingBadge = checkResolvingSoon(market, config, now);
if (resolvingBadge) badges.push(resolvingBadge);
}
// Activity badges
if (config.enabled[BadgeType.VOLUME_SURGE]) {
const surgeBadge = checkVolumeSurge(market, config);
if (surgeBadge) badges.push(surgeBadge);
}
if (config.enabled[BadgeType.UNUSUAL_INACTIVITY]) {
const inactivityBadge = checkUnusualInactivity(market, config, now);
if (inactivityBadge) badges.push(inactivityBadge);
}
// Price badges
if (config.enabled[BadgeType.PRICE_VOLATILITY]) {
const volatilityBadge = checkPriceVolatility(market, config);
if (volatilityBadge) badges.push(volatilityBadge);
}
if (config.enabled[BadgeType.HIGH_CONFIDENCE]) {
const confidenceBadge = checkHighConfidence(market, config);
if (confidenceBadge) badges.push(confidenceBadge);
}
if (config.enabled[BadgeType.LONG_SHOT]) {
const longShotBadge = checkLongShot(market, config);
if (longShotBadge) badges.push(longShotBadge);
}
// Health badges
if (config.enabled[BadgeType.LOW_LIQUIDITY]) {
const liquidityBadge = checkLowLiquidity(market, config);
if (liquidityBadge) badges.push(liquidityBadge);
}
if (config.enabled[BadgeType.DISPUTED]) {
const disputedBadge = checkDisputed(market);
if (disputedBadge) badges.push(disputedBadge);
}
// Lifecycle badges
if (config.enabled[BadgeType.RECENTLY_RESOLVED]) {
const resolvedBadge = checkRecentlyResolved(market, config, now);
if (resolvedBadge) badges.push(resolvedBadge);
}
if (config.enabled[BadgeType.AWAITING_ORACLE]) {
const oracleBadge = checkAwaitingOracle(market, config, now);
if (oracleBadge) badges.push(oracleBadge);
}
// Social badges
if (config.enabled[BadgeType.TRENDING]) {
const trendingBadge = checkTrending(market, config);
if (trendingBadge) badges.push(trendingBadge);
}
return badges;
}
/**
* Filter and sort badges for display
*/
export function selectBadgesForDisplay(
badges: Badge[],
maxBadges: number,
isMobile: boolean = false
): Badge[] {
if (badges.length === 0) return [];
// Apply mobile limit
const limit = isMobile ? 1 : maxBadges;
// Sort by priority
const sorted = badges.sort((a, b) => {
const priorityOrder = ['critical', 'high', 'medium', 'low'];
return priorityOrder.indexOf(a.priority) - priorityOrder.indexOf(b.priority);
});
// Apply mutual exclusivity rules
const filtered = applyMutualExclusivityRules(sorted);
// Take top N badges
return filtered.slice(0, limit);
}
/**
* Apply mutual exclusivity rules
*/
function applyMutualExclusivityRules(badges: Badge[]): Badge[] {
const mutuallyExclusive: Record<BadgeType, BadgeType[]> = {
[BadgeType.CLOSING_SOON]: [BadgeType.JUST_OPENED],
[BadgeType.DISPUTED]: [BadgeType.RECENTLY_RESOLVED],
[BadgeType.VOLUME_SURGE]: [BadgeType.UNUSUAL_INACTIVITY],
};
const result: Badge[] = [];
const seenTypes = new Set<BadgeType>();
for (const badge of badges) {
// Check if this badge type conflicts with already selected badges
const conflicts = mutuallyExclusive[badge.type] || [];
const hasConflict = conflicts.some(type => seenTypes.has(type));
if (!hasConflict) {
result.push(badge);
seenTypes.add(badge.type);
}
}
return result;
}
// Individual badge check functions
function checkClosingSoon(
market: MarketMetrics,
config: BadgeConfig,
now: Date
): Badge | null {
if (!market.isActive) return null;
if (market.uniqueTraders < config.thresholds.minimumUniqueTraders) return null;
const endTime = new Date(market.tradingEndTime);
const hoursRemaining = (endTime.getTime() - now.getTime()) / (1000 * 60 * 60);
if (hoursRemaining <= config.thresholds.closingSoonHours && hoursRemaining > 0) {
const minutesRemaining = Math.floor((endTime.getTime() - now.getTime()) / (1000 * 60));
return {
type: BadgeType.CLOSING_SOON,
priority: getBadgePriority(BadgeType.CLOSING_SOON),
variant: getBadgeVariant(BadgeType.CLOSING_SOON),
icon: getBadgeIcon(BadgeType.CLOSING_SOON),
text: formatBadgeText(BadgeType.CLOSING_SOON, {
hoursRemaining: Math.floor(hoursRemaining),
minutesRemaining
}),
ariaLabel: `This market closes in ${Math.floor(hoursRemaining)} hours`,
expiresAt: market.tradingEndTime,
metadata: { hoursRemaining: Math.floor(hoursRemaining), minutesRemaining },
calculatedAt: now.toISOString()
};
}
return null;
}
function checkJustOpened(
market: MarketMetrics,
config: BadgeConfig,
now: Date
): Badge | null {
const createdAt = new Date(market.createdAt);
const hoursOpen = (now.getTime() - createdAt.getTime()) / (1000 * 60 * 60);
if (hoursOpen <= config.thresholds.justOpenedHours && market.isActive) {
return {
type: BadgeType.JUST_OPENED,
priority: getBadgePriority(BadgeType.JUST_OPENED),
variant: getBadgeVariant(BadgeType.JUST_OPENED),
icon: getBadgeIcon(BadgeType.JUST_OPENED),
text: formatBadgeText(BadgeType.JUST_OPENED),
ariaLabel: 'This is a newly created market',
expiresAt: new Date(createdAt.getTime() + 24 * 60 * 60 * 1000).toISOString(),
calculatedAt: now.toISOString()
};
}
return null;
}
function checkResolvingSoon(
market: MarketMetrics,
config: BadgeConfig,
now: Date
): Badge | null {
if (!market.resolutionTime || market.isResolved || market.isActive) return null;
const resolutionTime = new Date(market.resolutionTime);
const hoursUntilResolution = (resolutionTime.getTime() - now.getTime()) / (1000 * 60 * 60);
if (hoursUntilResolution <= config.thresholds.resolvingSoonHours && hoursUntilResolution > 0) {
const daysRemaining = Math.floor(hoursUntilResolution / 24);
return {
type: BadgeType.RESOLVING_SOON,
priority: getBadgePriority(BadgeType.RESOLVING_SOON),
variant: getBadgeVariant(BadgeType.RESOLVING_SOON),
icon: getBadgeIcon(BadgeType.RESOLVING_SOON),
text: formatBadgeText(BadgeType.RESOLVING_SOON, {
hoursRemaining: Math.floor(hoursUntilResolution),
daysRemaining
}),
ariaLabel: `This market resolves in ${daysRemaining > 0 ? daysRemaining + ' days' : Math.floor(hoursUntilResolution) + ' hours'}`,
expiresAt: market.resolutionTime,
metadata: { hoursRemaining: Math.floor(hoursUntilResolution), daysRemaining },
calculatedAt: now.toISOString()
};
}
return null;
}
function checkVolumeSurge(
market: MarketMetrics,
config: BadgeConfig
): Badge | null {
if (!market.isActive) return null;
if (market.recentTraders1Hour < config.thresholds.minimumUniqueTraders) return null;
const volumeRatio = market.currentHourVolume / market.hourlyVolumeAverage24h;
if (
volumeRatio >= config.thresholds.volumeSurgeMultiplier &&
market.currentHourVolume >= config.thresholds.volumeSurgeMinimum
) {
return {
type: BadgeType.VOLUME_SURGE,
priority: getBadgePriority(BadgeType.VOLUME_SURGE),
variant: getBadgeVariant(BadgeType.VOLUME_SURGE),
icon: getBadgeIcon(BadgeType.VOLUME_SURGE),
text: formatBadgeText(BadgeType.VOLUME_SURGE),
ariaLabel: 'This market is experiencing high trading activity',
metadata: {
volumeMultiplier: volumeRatio,
currentVolume: market.currentHourVolume
},
calculatedAt: new Date().toISOString()
};
}
return null;
}
function checkUnusualInactivity(
market: MarketMetrics,
config: BadgeConfig,
now: Date
): Badge | null {
if (!market.isActive || !market.lastTradeAt) return null;
const lastTrade = new Date(market.lastTradeAt);
const hoursSinceLastTrade = (now.getTime() - lastTrade.getTime()) / (1000 * 60 * 60);
const tradingEndTime = new Date(market.tradingEndTime);
const daysUntilClose = (tradingEndTime.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
if (
hoursSinceLastTrade >= config.thresholds.inactivityHours &&
daysUntilClose > 7 &&
market.totalLiquidity >= 1000 &&
market.hourlyVolumeAverage24h > 0 // Previously had activity
) {
return {
type: BadgeType.UNUSUAL_INACTIVITY,
priority: getBadgePriority(BadgeType.UNUSUAL_INACTIVITY),
variant: getBadgeVariant(BadgeType.UNUSUAL_INACTIVITY),
icon: getBadgeIcon(BadgeType.UNUSUAL_INACTIVITY),
text: formatBadgeText(BadgeType.UNUSUAL_INACTIVITY),
ariaLabel: 'This market has been quiet recently',
calculatedAt: now.toISOString()
};
}
return null;
}
function checkPriceVolatility(
market: MarketMetrics,
config: BadgeConfig
): Badge | null {
if (!market.isActive) return null;
const priceChange3h = Math.abs(market.currentPrice - market.twap3hour) / market.twap3hour;
const priceChange24h = Math.abs(market.currentPrice - market.twap24hour) / market.twap24hour;
if (
priceChange3h >= config.thresholds.priceVolatility3Hours ||
priceChange24h >= config.thresholds.priceVolatility24Hours
) {
const direction = market.currentPrice > market.twap3hour ? 'up' : 'down';
const percentChange = Math.round(priceChange3h * 100);
return {
type: BadgeType.PRICE_VOLATILITY,
priority: getBadgePriority(BadgeType.PRICE_VOLATILITY),
variant: getBadgeVariant(BadgeType.PRICE_VOLATILITY),
icon: getBadgeIcon(BadgeType.PRICE_VOLATILITY),
text: formatBadgeText(BadgeType.PRICE_VOLATILITY, {
direction,
priceChangePercent: percentChange
}),
ariaLabel: `Price has moved ${direction} by ${percentChange}%`,
metadata: { direction, priceChangePercent: percentChange },
calculatedAt: new Date().toISOString()
};
}
return null;
}
function checkHighConfidence(
market: MarketMetrics,
config: BadgeConfig
): Badge | null {
if (!market.isActive) return null;
const tradingEndTime = new Date(market.tradingEndTime);
const hoursUntilClose = (tradingEndTime.getTime() - Date.now()) / (1000 * 60 * 60);
if (
market.currentPrice >= config.thresholds.highConfidenceThreshold &&
hoursUntilClose > 24
) {
return {
type: BadgeType.HIGH_CONFIDENCE,
priority: getBadgePriority(BadgeType.HIGH_CONFIDENCE),
variant: getBadgeVariant(BadgeType.HIGH_CONFIDENCE),
icon: getBadgeIcon(BadgeType.HIGH_CONFIDENCE),
text: formatBadgeText(BadgeType.HIGH_CONFIDENCE),
ariaLabel: 'Market shows high confidence in YES outcome',
calculatedAt: new Date().toISOString()
};
}
return null;
}
function checkLongShot(
market: MarketMetrics,
config: BadgeConfig
): Badge | null {
if (!market.isActive) return null;
const tradingEndTime = new Date(market.tradingEndTime);
const hoursUntilClose = (tradingEndTime.getTime() - Date.now()) / (1000 * 60 * 60);
if (
market.currentPrice <= config.thresholds.longShotThreshold &&
hoursUntilClose > 24 &&
market.totalLiquidity >= 500
) {
return {
type: BadgeType.LONG_SHOT,
priority: getBadgePriority(BadgeType.LONG_SHOT),
variant: getBadgeVariant(BadgeType.LONG_SHOT),
icon: getBadgeIcon(BadgeType.LONG_SHOT),
text: formatBadgeText(BadgeType.LONG_SHOT),
ariaLabel: 'Low probability outcome with potential high returns',
calculatedAt: new Date().toISOString()
};
}
return null;
}
function checkLowLiquidity(
market: MarketMetrics,
config: BadgeConfig
): Badge | null {
const createdAt = new Date(market.createdAt);
const hoursOpen = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60);
const tradingEndTime = new Date(market.tradingEndTime);
const hoursUntilClose = (tradingEndTime.getTime() - Date.now()) / (1000 * 60 * 60);
if (
market.totalLiquidity < config.thresholds.lowLiquidityThreshold &&
hoursOpen > 48 &&
hoursUntilClose > 48
) {
return {
type: BadgeType.LOW_LIQUIDITY,
priority: getBadgePriority(BadgeType.LOW_LIQUIDITY),
variant: getBadgeVariant(BadgeType.LOW_LIQUIDITY),
icon: getBadgeIcon(BadgeType.LOW_LIQUIDITY),
text: formatBadgeText(BadgeType.LOW_LIQUIDITY),
ariaLabel: 'This market has low liquidity, which may result in high slippage',
metadata: { liquidityAmount: market.totalLiquidity },
calculatedAt: new Date().toISOString()
};
}
return null;
}
function checkDisputed(market: MarketMetrics): Badge | null {
if (market.isDisputed) {
return {
type: BadgeType.DISPUTED,
priority: getBadgePriority(BadgeType.DISPUTED),
variant: getBadgeVariant(BadgeType.DISPUTED),
icon: getBadgeIcon(BadgeType.DISPUTED),
text: formatBadgeText(BadgeType.DISPUTED),
ariaLabel: 'This market resolution is currently disputed',
calculatedAt: new Date().toISOString()
};
}
return null;
}
function checkRecentlyResolved(
market: MarketMetrics,
config: BadgeConfig,
now: Date
): Badge | null {
if (!market.isResolved || !market.resolvedAt || market.isDisputed) return null;
const resolvedAt = new Date(market.resolvedAt);
const hoursSinceResolution = (now.getTime() - resolvedAt.getTime()) / (1000 * 60 * 60);
if (hoursSinceResolution <= 48) {
return {
type: BadgeType.RECENTLY_RESOLVED,
priority: getBadgePriority(BadgeType.RECENTLY_RESOLVED),
variant: getBadgeVariant(BadgeType.RECENTLY_RESOLVED),
icon: getBadgeIcon(BadgeType.RECENTLY_RESOLVED),
text: formatBadgeText(BadgeType.RECENTLY_RESOLVED),
ariaLabel: 'This market was recently resolved',
calculatedAt: now.toISOString()
};
}
return null;
}
function checkAwaitingOracle(
market: MarketMetrics,
config: BadgeConfig,
now: Date
): Badge | null {
if (!market.resolutionTime || market.hasOracleReport || market.isResolved) return null;
const resolutionTime = new Date(market.resolutionTime);
const hoursOverdue = (now.getTime() - resolutionTime.getTime()) / (1000 * 60 * 60);
if (hoursOverdue > 6) {
return {
type: BadgeType.AWAITING_ORACLE,
priority: getBadgePriority(BadgeType.AWAITING_ORACLE),
variant: getBadgeVariant(BadgeType.AWAITING_ORACLE),
icon: getBadgeIcon(BadgeType.AWAITING_ORACLE),
text: formatBadgeText(BadgeType.AWAITING_ORACLE),
ariaLabel: 'This market is awaiting oracle resolution report',
calculatedAt: now.toISOString()
};
}
return null;
}
function checkTrending(
market: MarketMetrics,
config: BadgeConfig
): Badge | null {
if (!market.isActive) return null;
const createdAt = new Date(market.createdAt);
const hoursOpen = (Date.now() - createdAt.getTime()) / (1000 * 60 * 60);
const tradingEndTime = new Date(market.tradingEndTime);
const hoursUntilClose = (tradingEndTime.getTime() - Date.now()) / (1000 * 60 * 60);
if (
hoursOpen > 48 &&
hoursUntilClose > 24 &&
market.traderCountPercentile !== undefined &&
market.volumePercentile !== undefined &&
market.traderCountPercentile >= config.thresholds.trendingTraderPercentile &&
market.volumePercentile >= config.thresholds.trendingVolumePercentile
) {
return {
type: BadgeType.TRENDING,
priority: getBadgePriority(BadgeType.TRENDING),
variant: getBadgeVariant(BadgeType.TRENDING),
icon: getBadgeIcon(BadgeType.TRENDING),
text: formatBadgeText(BadgeType.TRENDING),
ariaLabel: 'This is a popular trending market',
metadata: {
traderCount: market.uniqueTraders,
percentile: market.traderCountPercentile
},
calculatedAt: new Date().toISOString()
};
}
return null;
}
Step 3: React Hook¶
Create frontend/src/hooks/useBadges.ts:
import { useState, useEffect, useMemo } from 'react';
import { Badge, MarketMetrics } from '../types/badges';
import { calculateBadges, selectBadgesForDisplay } from '../utils/badgeUtils';
interface UseBadgesOptions {
maxBadges?: number;
isMobile?: boolean;
refreshInterval?: number; // ms
}
export function useBadges(
market: MarketMetrics | null,
options: UseBadgesOptions = {}
) {
const {
maxBadges = 2,
isMobile = false,
refreshInterval = 60000 // 1 minute default
} = options;
const [badges, setBadges] = useState<Badge[]>([]);
const [isCalculating, setIsCalculating] = useState(false);
// Calculate badges
const calculateMarketBadges = useMemo(() => {
if (!market) return [];
setIsCalculating(true);
const allBadges = calculateBadges(market);
const displayBadges = selectBadgesForDisplay(allBadges, maxBadges, isMobile);
setIsCalculating(false);
return displayBadges;
}, [market, maxBadges, isMobile]);
// Update badges when market changes
useEffect(() => {
setBadges(calculateMarketBadges);
}, [calculateMarketBadges]);
// Refresh badges periodically
useEffect(() => {
if (!refreshInterval || !market) return;
const interval = setInterval(() => {
const allBadges = calculateBadges(market);
const displayBadges = selectBadgesForDisplay(allBadges, maxBadges, isMobile);
setBadges(displayBadges);
}, refreshInterval);
return () => clearInterval(interval);
}, [market, maxBadges, isMobile, refreshInterval]);
return {
badges,
isCalculating,
hasBadges: badges.length > 0
};
}
Step 4: Badge Component¶
Create frontend/src/components/badges/MarketBadge.tsx:
import React from 'react';
import { Badge as BadgeType } from '../../types/badges';
import Badge from '../ui/Badge';
import './MarketBadge.css';
interface MarketBadgeProps {
badge: BadgeType;
compact?: boolean;
}
const MarketBadge: React.FC<MarketBadgeProps> = ({ badge, compact = false }) => {
// Map badge variant to UI Badge component variant
const getUIVariant = (variant: string) => {
switch (variant) {
case 'critical':
return 'danger';
case 'positive':
return 'success';
case 'warning':
return 'warning';
case 'neutral':
default:
return 'neutral';
}
};
return (
<Badge
variant={getUIVariant(badge.variant)}
icon={badge.icon}
className={`market-badge market-badge-${badge.priority} ${compact ? 'compact' : ''}`}
aria-label={badge.ariaLabel}
>
{badge.text}
</Badge>
);
};
export default MarketBadge;
Create frontend/src/components/badges/MarketBadge.css:
.market-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.875rem;
font-weight: 500;
padding: 4px 8px;
border-radius: 4px;
white-space: nowrap;
}
.market-badge.compact {
font-size: 0.75rem;
padding: 2px 6px;
}
.market-badge-critical {
animation: pulse-subtle 2s ease-in-out infinite;
}
@keyframes pulse-subtle {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.85;
}
}
/* Mobile optimizations */
@media (max-width: 768px) {
.market-badge {
font-size: 0.75rem;
padding: 3px 6px;
}
}
Step 5: Integration with MarketTile¶
Update frontend/src/components/fairwins/MarketTile.jsx:
import { useBadges } from '../../hooks/useBadges';
import MarketBadge from '../badges/MarketBadge';
import './MarketTile.css';
function MarketTile({ market, onClick, isActive = false, compact = false }) {
const { formatPrice } = usePrice();
// Convert market data to MarketMetrics format
const marketMetrics = {
marketId: market.id,
createdAt: market.createdAt,
tradingEndTime: market.tradingEndTime,
resolutionTime: market.resolutionTime,
resolvedAt: market.resolvedAt,
lastTradeAt: market.lastTradeAt,
currentHourVolume: market.currentHourVolume || 0,
hourlyVolumeAverage24h: market.hourlyVolumeAverage24h || 0,
totalVolume: market.totalVolume || 0,
tradeCount: market.tradeCount || 0,
currentPrice: parseFloat(market.passTokenPrice),
twap30min: market.twap30min || parseFloat(market.passTokenPrice),
twap3hour: market.twap3hour || parseFloat(market.passTokenPrice),
twap24hour: market.twap24hour || parseFloat(market.passTokenPrice),
priceHistory: market.priceHistory || [],
totalLiquidity: parseFloat(market.totalLiquidity),
liquidityDepth5Percent: market.liquidityDepth5Percent || 0,
uniqueTraders: market.uniqueTraders || 0,
recentTraders1Hour: market.recentTraders1Hour || 0,
isActive: market.status === 'active',
isResolved: market.status === 'resolved',
isDisputed: market.isDisputed || false,
hasOracleReport: market.hasOracleReport || false,
traderCountPercentile: market.traderCountPercentile,
volumePercentile: market.volumePercentile,
};
const { badges, hasBadges } = useBadges(marketMetrics, {
maxBadges: compact ? 1 : 2,
isMobile: window.innerWidth < 768
});
// ... existing code ...
return (
<div
className={`market-tile ${isActive ? 'active' : ''} ${compact ? 'compact' : ''}`}
onClick={handleClick}
onKeyDown={handleKeyDown}
role="button"
tabIndex="0"
aria-label={`View market: ${market.proposalTitle}`}
aria-pressed={isActive}
>
<div className="tile-header">
<div className="header-left">
<div className="market-value">
<span className="market-value-label">Market Value</span>
<span className="market-value-amount">{formatPrice(market.totalLiquidity, { compact: true })}</span>
</div>
</div>
<div className="header-right">
{hasBadges && (
<div className="badge-container">
{badges.map((badge, index) => (
<MarketBadge key={`${badge.type}-${index}`} badge={badge} compact={compact} />
))}
</div>
)}
<span className="moneyline-label">Moneyline</span>
<div className="probability-bar">
<div
className="probability-fill"
style={{ width: `${calculateImpliedProbability(market.passTokenPrice)}%` }}
aria-hidden="true"
/>
</div>
</div>
</div>
{/* ... rest of existing code ... */}
</div>
);
}
export default MarketTile;
Update frontend/src/components/fairwins/MarketTile.css:
/* Add to existing styles */
.badge-container {
display: flex;
gap: 6px;
margin-bottom: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
@media (max-width: 768px) {
.badge-container {
gap: 4px;
margin-bottom: 4px;
}
}
Step 6: Backend API (Optional)¶
If implementing server-side badge calculation:
// backend/routes/badges.ts
import express from 'express';
import { calculateBadges, selectBadgesForDisplay } from '../utils/badgeUtils';
import { getMarketMetrics } from '../services/marketService';
const router = express.Router();
// Get badges for a single market
router.get('/markets/:marketId/badges', async (req, res) => {
try {
const { marketId } = req.params;
const metrics = await getMarketMetrics(marketId);
const badges = calculateBadges(metrics);
const displayBadges = selectBadgesForDisplay(badges, 2);
res.json({
badges: displayBadges,
calculatedAt: new Date().toISOString(),
cacheUntil: new Date(Date.now() + 5 * 60 * 1000).toISOString() // 5 min cache
});
} catch (error) {
res.status(500).json({ error: 'Failed to calculate badges' });
}
});
// Batch badge calculation
router.post('/markets/badges/batch', async (req, res) => {
try {
const { marketIds } = req.body;
const results: Record<string, any[]> = {};
await Promise.all(
marketIds.map(async (id: string) => {
const metrics = await getMarketMetrics(id);
const badges = calculateBadges(metrics);
results[id] = selectBadgesForDisplay(badges, 2);
})
);
res.json({
badges: results,
calculatedAt: new Date().toISOString()
});
} catch (error) {
res.status(500).json({ error: 'Failed to calculate badges' });
}
});
export default router;
Step 7: Testing¶
Create test file frontend/src/utils/__tests__/badgeUtils.test.ts:
import { calculateBadges, selectBadgesForDisplay } from '../badgeUtils';
import { BadgeType, MarketMetrics } from '../../types/badges';
describe('badgeUtils', () => {
const mockMarket: MarketMetrics = {
marketId: 'test-1',
createdAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
tradingEndTime: new Date(Date.now() + 4 * 60 * 60 * 1000).toISOString(), // 4 hours from now
currentPrice: 0.65,
twap30min: 0.63,
twap3hour: 0.60,
twap24hour: 0.58,
priceHistory: [],
totalLiquidity: 5000,
liquidityDepth5Percent: 500,
uniqueTraders: 25,
recentTraders1Hour: 8,
currentHourVolume: 1200,
hourlyVolumeAverage24h: 300,
totalVolume: 8000,
tradeCount: 150,
isActive: true,
isResolved: false,
isDisputed: false,
hasOracleReport: false
};
describe('calculateBadges', () => {
it('should detect closing soon badge', () => {
const badges = calculateBadges(mockMarket);
const closingSoon = badges.find(b => b.type === BadgeType.CLOSING_SOON);
expect(closingSoon).toBeDefined();
});
it('should detect volume surge', () => {
const badges = calculateBadges(mockMarket);
const volumeSurge = badges.find(b => b.type === BadgeType.VOLUME_SURGE);
expect(volumeSurge).toBeDefined();
});
it('should not show badges for inactive markets', () => {
const inactiveMarket = { ...mockMarket, isActive: false };
const badges = calculateBadges(inactiveMarket);
expect(badges.length).toBe(0);
});
});
describe('selectBadgesForDisplay', () => {
it('should respect maximum badge count', () => {
const badges = calculateBadges(mockMarket);
const selected = selectBadgesForDisplay(badges, 1, false);
expect(selected.length).toBeLessThanOrEqual(1);
});
it('should prioritize critical badges', () => {
const badges = calculateBadges(mockMarket);
const selected = selectBadgesForDisplay(badges, 2, false);
if (selected.length > 0) {
expect(['critical', 'high']).toContain(selected[0].priority);
}
});
});
});
Step 8: Configuration¶
Create a configuration file to easily adjust thresholds:
// frontend/src/config/badges.ts
import { BadgeConfig } from '../types/badges';
export const BADGE_CONFIG: BadgeConfig = {
thresholds: {
closingSoonHours: 6,
justOpenedHours: 24,
resolvingSoonHours: 48,
volumeSurgeMultiplier: 3.0,
volumeSurgeMinimum: 100,
inactivityHours: 12,
priceVolatility3Hours: 0.15,
priceVolatility24Hours: 0.25,
highConfidenceThreshold: 0.90,
longShotThreshold: 0.10,
lowLiquidityThreshold: 100,
trendingTraderPercentile: 90,
trendingVolumePercentile: 75,
minimumUniqueTraders: 3,
minimumTradeSize: 5,
selfTradeWindowMinutes: 15
},
enabled: {
CLOSING_SOON: true,
JUST_OPENED: true,
RESOLVING_SOON: true,
VOLUME_SURGE: true,
UNUSUAL_INACTIVITY: true,
PRICE_VOLATILITY: true,
HIGH_CONFIDENCE: true,
LONG_SHOT: true,
LOW_LIQUIDITY: true,
DISPUTED: true,
RECENTLY_RESOLVED: true,
AWAITING_ORACLE: true,
TRENDING: true,
RELATED_MARKET: false
},
maxBadgesPerCard: 2,
maxBadgesOnMobile: 1,
cacheTTL: 300
};
Deployment Checklist¶
- Copy type definitions to project
- Implement badge calculation utilities
- Create React hook for badge management
- Create MarketBadge component
- Integrate badges into MarketTile component
- Add CSS styling for badges
- Implement caching strategy
- Add unit tests
- Add integration tests
- Test on mobile devices
- Monitor badge distribution (should be 5-15% of markets)
- Set up monitoring for badge effectiveness
- Document any custom thresholds
- Add A/B testing if desired
Monitoring & Refinement¶
After deployment, track these metrics:
- Badge Distribution: % of markets showing badges
- Engagement: Click-through rate on badged vs unbadged markets
- User Feedback: Surveys on badge usefulness
- Performance: Badge calculation time
- False Positives: User reports of inappropriate badges
Adjust thresholds monthly based on data.
Support¶
For questions or issues with implementation:
- Review the specification: market-activity-badges.md
- Check type definitions: badge-types.ts
- See existing Badge component: frontend/src/components/ui/Badge.jsx