Switch to a Michaelis-Menten saturating curve. search bar filtering. mobile improvements
Build Images and Deploy / Update-PROD-Stack (push) Successful in 1m50s

This commit is contained in:
2026-03-24 01:44:11 -04:00
parent 100f149c53
commit 15378c1eec
5 changed files with 56 additions and 24 deletions
+13 -8
View File
@@ -152,21 +152,26 @@ All variables are documented in `.env.example`. Key ones:
## Pricing Formula ## 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 | | Posts/hr | Price |
|---|---| |---|---|
| 1 | $0.25 | | 1 | ~$0.25 |
| 10 | $2.50 | | 10 | ~$2.48 |
| 100 | $25.00 | | 100 | ~$23.32 |
| 1,000 | $250.00 | | 1,000 | ~$145 |
| 12,000 (e.g. #happynewyear at midnight) | $3,000.00 | | 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.
--- ---
+14 -4
View File
@@ -71,12 +71,22 @@ export default function AboutPage() {
<Section title="How Prices Work" icon={Coins}> <Section title="How Prices Work" icon={Coins}>
<div className="space-y-2 text-sm text-slate-300"> <div className="space-y-2 text-sm text-slate-300">
<p> <p>
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
{' '}<span className="text-white font-medium">saturating curve</span> prices rise quickly at
low activity and flatten at high activity so a single viral tag can&apos;t dominate the market.
</p> </p>
<div className="bg-surface-card border border-surface-border rounded-lg px-4 py-3 font-mono text-center text-indigo-300"> <div className="bg-surface-card border border-surface-border rounded-lg px-4 py-3 font-mono text-center text-indigo-300 text-xs">
price = max($0.25, posts_per_hour × $0.25) price = (0.25 × pph) / (1 + k × pph) &nbsp;·&nbsp; floor $0.25
</div> </div>
<p> <div className="grid grid-cols-2 gap-x-4 text-xs mt-1">
<span className="text-slate-400">1 post / hr</span><span>~$0.25</span>
<span className="text-slate-400">10 posts / hr</span><span>~$2.48</span>
<span className="text-slate-400">100 posts / hr</span><span>~$23</span>
<span className="text-slate-400">1,000 posts / hr</span><span>~$145</span>
<span className="text-slate-400">3,600 posts / hr (1/sec)</span><span>~$250</span>
<span className="text-slate-400"> (asymptote)</span><span>~$346</span>
</div>
<p className="text-xs text-slate-400">
Prices update on a regular cycle. A hashtag that goes completely quiet for long enough will be Prices update on a regular cycle. A hashtag that goes completely quiet for long enough will be
automatically <span className="text-orange-400">deactivated</span> you&apos;ll get a warning on the automatically <span className="text-orange-400">deactivated</span> you&apos;ll get a warning on the
home page if any of your positions are at risk. Research it again to reactivate it. home page if any of your positions are at risk. Research it again to reactivate it.
+4 -1
View File
@@ -85,7 +85,10 @@ export default async function AdminStocksPage({ searchParams }: Props) {
{hashtags.map((h) => ( {hashtags.map((h) => (
<tr key={h.id} className="hover:bg-surface-hover"> <tr key={h.id} className="hover:bg-surface-hover">
<td className="px-4 py-3"> <td className="px-4 py-3">
<a href={`/hashtag/${h.tag}`} className="hover:text-indigo-300"> <a
href={`/hashtag/${h.tag}`}
className={`hover:text-indigo-300 ${!h.isActive && !h.isBanned ? 'text-slate-500' : ''}`}
>
#{h.displayTag} #{h.displayTag}
</a> </a>
</td> </td>
+19 -9
View File
@@ -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. * Formula: price = base * pph / (1 + k * pph)
* Examples: * where k is chosen so the curve hits $250 at 3 600 PPH.
* 1 post/hr → $0.25 *
* 10 posts/hr → $2.50 * Anchor points:
* 100 → $25.00 * 1 post/hr → ~$0.25
* 1000 → $250.00 * 10 posts/hr~$2.48
* 12 000 (viral #happynewyear) → $3 000 * 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 { export function calcPrice(postsPerHour: number): number {
if (postsPerHour <= 0) return 0.25 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)
} }
/** /**
+6 -2
View File
@@ -41,7 +41,11 @@ export function pnlColor(value: number): string {
return 'text-slate-400' 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 { export function normalizeTag(raw: string): string {
return raw.trim().replace(/^#+/, '').toLowerCase() return raw
.replace(/^#+/, '') // strip leading #
.replace(/[\s]/g, '') // remove all whitespace
.toLowerCase()
} }