@ak--47/dungeon-master 1.5.0 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/create-dungeon/SKILL.md +139 -46
- package/.claude/skills/verify-dungeon/references/counting-semantics.md +31 -6
- package/.claude/skills/verify-dungeon/references/sql-recipes.md +44 -25
- package/.claude/skills/write-hooks/SKILL.md +31 -3
- package/CHANGELOG.md +85 -0
- package/HOOKS.md +13 -0
- package/dungeons/technical/ad-spend.js +41 -49
- package/dungeons/technical/anonymous-users.js +38 -36
- package/dungeons/technical/array-of-object-lookup.js +136 -153
- package/dungeons/technical/datagen-v15-verify.js +24 -11
- package/dungeons/technical/experiments.js +42 -40
- package/dungeons/technical/foobar.js +114 -118
- package/dungeons/technical/group-analytics.js +42 -40
- package/dungeons/technical/hook-helpers-verify.js +69 -50
- package/dungeons/technical/identity-model-verify.js +22 -12
- package/dungeons/technical/mirror-strategies.js +37 -39
- package/dungeons/technical/nested-objects.js +119 -118
- package/dungeons/technical/pattern-aggregate-by-bin.js +21 -8
- package/dungeons/technical/pattern-attributed-by-source.js +23 -9
- package/dungeons/technical/pattern-frequency-by-frequency.js +21 -8
- package/dungeons/technical/pattern-funnel-frequency.js +30 -15
- package/dungeons/technical/pattern-ttc-by-segment.js +21 -8
- package/dungeons/technical/retention-cadence.js +115 -112
- package/dungeons/technical/sanity.js +86 -80
- package/dungeons/technical/scale-test.js +34 -38
- package/dungeons/technical/scd.js +111 -128
- package/dungeons/technical/simple.js +134 -141
- package/dungeons/technical/simplest.js +54 -62
- package/dungeons/technical/text-generation.js +110 -146
- package/dungeons/vertical/ai-platform.js +296 -333
- package/dungeons/vertical/community.js +284 -255
- package/dungeons/vertical/crypto.js +395 -391
- package/dungeons/vertical/dating.js +411 -378
- package/dungeons/vertical/devtools.js +336 -298
- package/dungeons/vertical/ecommerce.js +316 -394
- package/dungeons/vertical/education.js +369 -325
- package/dungeons/vertical/fintech.js +358 -325
- package/dungeons/vertical/fitness.js +335 -291
- package/dungeons/vertical/food-delivery.js +343 -307
- package/dungeons/vertical/gaming.js +480 -444
- package/dungeons/vertical/healthcare.js +306 -262
- package/dungeons/vertical/insurance-application.js +427 -409
- package/dungeons/vertical/logistics.js +271 -252
- package/dungeons/vertical/marketplace.js +333 -323
- package/dungeons/vertical/media.js +382 -335
- package/dungeons/vertical/real-estate.js +395 -346
- package/dungeons/vertical/sass.js +319 -333
- package/dungeons/vertical/social.js +368 -316
- package/dungeons/vertical/travel.js +297 -295
- package/index.js +46 -4
- package/lib/core/config-validator.js +126 -28
- package/lib/generators/funnels.js +4 -1
- package/lib/orchestrators/mixpanel-sender.js +7 -0
- package/lib/orchestrators/user-loop.js +132 -31
- package/lib/templates/defaults.js +59 -59
- package/lib/templates/macro-presets.js +14 -2
- package/lib/utils/dataset-context.js +103 -0
- package/lib/utils/retention-curve.js +140 -0
- package/lib/utils/utils.js +149 -38
- package/lib/verify/counting.js +40 -0
- package/lib/verify/emulate-breakdown.js +20 -1
- package/lib/verify/index.js +1 -0
- package/lib/verify/schema-validator.js +3 -1
- package/package.json +11 -2
- package/scripts/run-dungeon.mjs +12 -1
- package/types.d.ts +117 -1
|
@@ -1,58 +1,41 @@
|
|
|
1
|
-
// ──
|
|
2
|
-
const SEED = "coinnest";
|
|
3
|
-
const num_days = 120;
|
|
4
|
-
const num_users = 10_000;
|
|
5
|
-
const avg_events_per_user_per_day = 0.83;
|
|
6
|
-
let token = "your-mixpanel-token";
|
|
7
|
-
|
|
8
|
-
// ── env overrides ──
|
|
9
|
-
if (process.env.MP_TOKEN) token = process.env.MP_TOKEN;
|
|
10
|
-
|
|
1
|
+
// ── IMPORTS ──
|
|
11
2
|
import dayjs from "dayjs";
|
|
12
3
|
import utc from "dayjs/plugin/utc.js";
|
|
4
|
+
dayjs.extend(utc);
|
|
13
5
|
import "dotenv/config";
|
|
14
6
|
import * as u from "../../lib/utils/utils.js";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* Core loop: wallet connected → KYC → deposit → swap → stake/unstake
|
|
35
|
-
* → portfolio viewed → withdrawal
|
|
36
|
-
*
|
|
37
|
-
* Funnels:
|
|
7
|
+
/** @typedef {import("../../types").Dungeon} Config */
|
|
8
|
+
|
|
9
|
+
// ── OVERVIEW ──
|
|
10
|
+
/*
|
|
11
|
+
* NAME: CoinNest
|
|
12
|
+
* APP: DeFi cryptocurrency exchange platform. Users connect wallets,
|
|
13
|
+
* swap tokens, stake for yield, mint NFTs, claim airdrops, and
|
|
14
|
+
* trade. Features KYC verification, pro trading tiers, and
|
|
15
|
+
* portfolio tracking.
|
|
16
|
+
* SCALE: 10,000 users, ~600K events, 121 days (2026-01-01 → 2026-05-01)
|
|
17
|
+
* CORE LOOP: wallet connected → KYC → deposit → swap → stake/unstake → portfolio viewed → withdrawal
|
|
18
|
+
*
|
|
19
|
+
* EVENTS (16):
|
|
20
|
+
* swap (10) > portfolio viewed (8) > deposit (5) > stake (4) > withdrawal (3)
|
|
21
|
+
* > price alert set (3) > limit order placed (3) > transfer (3) > kyc started (2)
|
|
22
|
+
* > unstake (2) > claim airdrop (2) > nft mint (2) > referral sent (2)
|
|
23
|
+
* > wallet connected (1) > kyc completed (1)
|
|
24
|
+
*
|
|
25
|
+
* FUNNELS (3):
|
|
38
26
|
* - Onboarding: wallet connected → kyc started → deposit (60%)
|
|
39
|
-
* - Trading:
|
|
40
|
-
* - DeFi:
|
|
27
|
+
* - Trading: deposit → swap → withdrawal (70%)
|
|
28
|
+
* - DeFi: swap → stake → claim airdrop (35%)
|
|
41
29
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
30
|
+
* USER PROPS: trading_tier, preferred_chain, wallet_type, total_trade_volume, portfolio_value, kyc_status
|
|
31
|
+
* SUPER PROPS: trading_tier, preferred_chain, wallet_type
|
|
32
|
+
* SCD PROPS: trading_tier (Standard/Pro, monthly fuzzy, max 4)
|
|
33
|
+
* GROUPS: none
|
|
45
34
|
*/
|
|
46
35
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
*
|
|
50
|
-
* ===================================================================
|
|
51
|
-
*
|
|
52
|
-
* NOTE: All cohort effects are HIDDEN — no flag stamping. Discoverable
|
|
53
|
-
* via behavioral cohorts (count event per user, observe trade_amount
|
|
54
|
-
* distribution) or raw-prop breakdowns (token_pair, day, trading_tier).
|
|
55
|
-
*
|
|
36
|
+
// ── HOOK STORIES ──
|
|
37
|
+
/*
|
|
38
|
+
* ---------------------------------------------------------------
|
|
56
39
|
* 1. WHALE WALLETS (everything)
|
|
57
40
|
*
|
|
58
41
|
* PATTERN: 2% of wallets (deterministic via id hash) get swap
|
|
@@ -314,7 +297,66 @@ const chance = u.initChance(SEED);
|
|
|
314
297
|
* Early Staker Retain | post-d40 events (non) | 1x | 0.4x | -60%
|
|
315
298
|
*/
|
|
316
299
|
|
|
317
|
-
//
|
|
300
|
+
// ── SCALE ──
|
|
301
|
+
const SEED = "coinnest";
|
|
302
|
+
const NUM_USERS = 10_000;
|
|
303
|
+
const DATASET_START = "2026-01-01T00:00:00Z";
|
|
304
|
+
const DATASET_END = "2026-05-01T23:59:59Z";
|
|
305
|
+
const EVENTS_PER_DAY = 0.83;
|
|
306
|
+
const token = process.env.MP_TOKEN || "your-mixpanel-token";
|
|
307
|
+
|
|
308
|
+
const chance = u.initChance(SEED);
|
|
309
|
+
|
|
310
|
+
// ── KNOBS (tweak these to reshape stories) ──
|
|
311
|
+
const WHALE_HASH_MOD = 50;
|
|
312
|
+
const WHALE_AMOUNT_MULT = 50;
|
|
313
|
+
|
|
314
|
+
const GAS_SPIKE_START_DAY = 35;
|
|
315
|
+
const GAS_SPIKE_END_DAY = 38;
|
|
316
|
+
const GAS_SPIKE_MULT = 10;
|
|
317
|
+
const GAS_SPIKE_FAIL_LIKELIHOOD = 40;
|
|
318
|
+
|
|
319
|
+
const MOON_LAUNCH_DAY = 50;
|
|
320
|
+
const MOON_FLIP_LIKELIHOOD = 25;
|
|
321
|
+
const MOON_CLONE_COUNT = 4;
|
|
322
|
+
|
|
323
|
+
const AIRDROP_BOT_HASH_MOD = 25;
|
|
324
|
+
const AIRDROP_DROP_LIKELIHOOD = 95;
|
|
325
|
+
|
|
326
|
+
const KYC_DEPOSIT_MULT = 4;
|
|
327
|
+
const KYC_SWAP_CLONE_COUNT = 7;
|
|
328
|
+
|
|
329
|
+
const STAKE_EARLY_WINDOW_DAYS = 14;
|
|
330
|
+
const STAKE_RETENTION_CUTOFF_DAYS = 60;
|
|
331
|
+
const STAKE_NON_STAKER_DROP_LIKELIHOOD = 85;
|
|
332
|
+
const STAKE_INJECT_SWAP_COUNT = 8;
|
|
333
|
+
const STAKE_INJECT_PORTFOLIO_COUNT = 4;
|
|
334
|
+
|
|
335
|
+
const PRO_MAKER_FEE = 0.05;
|
|
336
|
+
const STANDARD_MAKER_FEE = 0.30;
|
|
337
|
+
const PRO_SWAP_CLONE_COUNT = 5;
|
|
338
|
+
|
|
339
|
+
const SCAM_WINDOW_START_DAY = 10;
|
|
340
|
+
const SCAM_WINDOW_END_DAY = 70;
|
|
341
|
+
const SCAM_FLIP_LIKELIHOOD = 2;
|
|
342
|
+
const SCAM_DROP_LIKELIHOOD = 80;
|
|
343
|
+
|
|
344
|
+
const FUNNEL_TTC_PRO = 0.7;
|
|
345
|
+
const FUNNEL_TTC_STANDARD = 1.3;
|
|
346
|
+
|
|
347
|
+
const SWAP_SWEET_MIN = 8;
|
|
348
|
+
const SWAP_SWEET_MAX = 20;
|
|
349
|
+
const SWAP_OVER_THRESHOLD = 21;
|
|
350
|
+
const SWAP_STAKE_BOOST = 1.3;
|
|
351
|
+
const SWAP_OVER_PORTFOLIO_DROP_LIKELIHOOD = 75;
|
|
352
|
+
const SWAP_OVER_PORTFOLIO_VALUE_FACTOR = 0.5;
|
|
353
|
+
|
|
354
|
+
const EARLY_STAKE_MIN = 2;
|
|
355
|
+
const EARLY_STAKE_WINDOW_DAYS = 10;
|
|
356
|
+
const EARLY_STAKE_CUTOFF_DAYS = 40;
|
|
357
|
+
const EARLY_STAKE_DROP_LIKELIHOOD = 60;
|
|
358
|
+
|
|
359
|
+
// ── DATA ARRAYS ──
|
|
318
360
|
const TOKEN_PAIRS = [
|
|
319
361
|
"ETH/USDC", "ETH/USDT", "BTC/USDC", "SOL/USDC",
|
|
320
362
|
"MATIC/ETH", "ARB/ETH", "OP/USDC", "AVAX/USDC",
|
|
@@ -335,31 +377,314 @@ const NFT_COLLECTIONS = [
|
|
|
335
377
|
"CryptoPunks", "Art Blocks"
|
|
336
378
|
];
|
|
337
379
|
|
|
380
|
+
// ── HELPER FUNCTIONS ──
|
|
381
|
+
function handleFunnelPostHooks(record, meta) {
|
|
382
|
+
// H9: Trading funnel TTC scaled by tier (Pro faster, Standard slower)
|
|
383
|
+
const segment = meta?.profile?.trading_tier;
|
|
384
|
+
if (Array.isArray(record) && record.length > 1) {
|
|
385
|
+
const factor = (
|
|
386
|
+
segment === "Pro" ? FUNNEL_TTC_PRO :
|
|
387
|
+
segment === "Standard" ? FUNNEL_TTC_STANDARD :
|
|
388
|
+
1.0
|
|
389
|
+
);
|
|
390
|
+
if (factor !== 1.0) {
|
|
391
|
+
for (let i = 1; i < record.length; i++) {
|
|
392
|
+
const prev = dayjs(record[i - 1].time);
|
|
393
|
+
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
394
|
+
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return record;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function handleEverythingHooks(record, meta) {
|
|
402
|
+
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
403
|
+
const userEvents = record;
|
|
404
|
+
const profile = meta.profile;
|
|
405
|
+
|
|
406
|
+
// Stamp superProps from profile for consistency
|
|
407
|
+
userEvents.forEach(e => {
|
|
408
|
+
e.trading_tier = profile.trading_tier;
|
|
409
|
+
e.preferred_chain = profile.preferred_chain;
|
|
410
|
+
e.wallet_type = profile.wallet_type;
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
// H1: Whale wallets — 2% of users (charCodeAt(0) % 50 === 0) get 50x swap volume
|
|
414
|
+
const userId = userEvents.length > 0 ? (userEvents[0].user_id || userEvents[0].distinct_id || "") : "";
|
|
415
|
+
const isWhale = userId.length > 0 && userId.charCodeAt(0) % WHALE_HASH_MOD === 0;
|
|
416
|
+
if (isWhale) {
|
|
417
|
+
userEvents.forEach(e => {
|
|
418
|
+
if (e.event === "swap") {
|
|
419
|
+
e.trade_amount_usd = Math.round((e.trade_amount_usd || 200) * WHALE_AMOUNT_MULT);
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// H3: Token launch surge — after d50, 25% of swaps flip token_pair to MOON
|
|
425
|
+
const moonLaunchDay = datasetStart.add(MOON_LAUNCH_DAY, "days");
|
|
426
|
+
userEvents.forEach(e => {
|
|
427
|
+
if (e.event === "swap" && dayjs(e.time).isAfter(moonLaunchDay) && chance.bool({ likelihood: MOON_FLIP_LIKELIHOOD })) {
|
|
428
|
+
e.token_pair = chance.pickone(["MOON/USDC", "MOON/ETH", "ETH/MOON"]);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// H3 (cont): Clone 4 extra MOON-pair swaps per existing MOON swap with unique offset
|
|
433
|
+
const moonSwaps = userEvents.filter(e =>
|
|
434
|
+
e.event === "swap" &&
|
|
435
|
+
typeof e.token_pair === "string" &&
|
|
436
|
+
e.token_pair.includes("MOON")
|
|
437
|
+
);
|
|
438
|
+
if (moonSwaps.length > 0) {
|
|
439
|
+
const clonedMoonEvents = [];
|
|
440
|
+
moonSwaps.forEach(moonEvt => {
|
|
441
|
+
const moonTime = dayjs(moonEvt.time);
|
|
442
|
+
for (let i = 0; i < MOON_CLONE_COUNT; i++) {
|
|
443
|
+
clonedMoonEvents.push({
|
|
444
|
+
...moonEvt,
|
|
445
|
+
time: moonTime.add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
|
|
446
|
+
user_id: moonEvt.user_id,
|
|
447
|
+
trade_amount_usd: Math.round((moonEvt.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 2.0 })),
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
userEvents.push(...clonedMoonEvents);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// H4: Airdrop hunter churn — 4% of users (charCodeAt(1) % 25 === 0) who claim
|
|
455
|
+
// an airdrop lose 95% of subsequent events after first claim
|
|
456
|
+
const isAirdropBot = userId.length > 1 && userId.charCodeAt(1) % AIRDROP_BOT_HASH_MOD === 0;
|
|
457
|
+
const hasAirdropClaim = userEvents.some(e => e.event === "claim airdrop");
|
|
458
|
+
if (isAirdropBot && hasAirdropClaim) {
|
|
459
|
+
const firstClaim = userEvents.find(e => e.event === "claim airdrop");
|
|
460
|
+
if (firstClaim) {
|
|
461
|
+
const claimTime = dayjs(firstClaim.time);
|
|
462
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
463
|
+
const evt = userEvents[i];
|
|
464
|
+
if (dayjs(evt.time).isAfter(claimTime) && evt.event !== "claim airdrop") {
|
|
465
|
+
if (chance.bool({ likelihood: AIRDROP_DROP_LIKELIHOOD })) {
|
|
466
|
+
userEvents.splice(i, 1);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// H5: KYC funnel completion — post-KYC deposits 4x boost + 7 extra cloned swaps
|
|
474
|
+
const kycCompleted = userEvents.find(e => e.event === "kyc completed");
|
|
475
|
+
if (kycCompleted) {
|
|
476
|
+
const kycTime = dayjs(kycCompleted.time);
|
|
477
|
+
userEvents.forEach(e => {
|
|
478
|
+
if (dayjs(e.time).isAfter(kycTime) && e.event === "deposit") {
|
|
479
|
+
e.deposit_amount_usd = Math.round((e.deposit_amount_usd || 500) * KYC_DEPOSIT_MULT);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
const postKycSwaps = userEvents.filter(e =>
|
|
484
|
+
e.event === "swap" && dayjs(e.time).isAfter(kycTime)
|
|
485
|
+
);
|
|
486
|
+
const clonedKycSwaps = [];
|
|
487
|
+
postKycSwaps.forEach(swapEvt => {
|
|
488
|
+
const swapTime = dayjs(swapEvt.time);
|
|
489
|
+
for (let i = 0; i < KYC_SWAP_CLONE_COUNT; i++) {
|
|
490
|
+
clonedKycSwaps.push({
|
|
491
|
+
...swapEvt,
|
|
492
|
+
time: swapTime.add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
493
|
+
user_id: swapEvt.user_id,
|
|
494
|
+
trade_amount_usd: Math.round((swapEvt.trade_amount_usd || 200) * chance.floating({ min: 0.8, max: 1.5 })),
|
|
495
|
+
});
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
userEvents.push(...clonedKycSwaps);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// H6: Stake-to-retain — early stakers get post-D60 events injected; non-stakers lose 85%
|
|
502
|
+
const earlyStakeDay = datasetStart.add(STAKE_EARLY_WINDOW_DAYS, "days");
|
|
503
|
+
const stakeRetentionCutoff = datasetStart.add(STAKE_RETENTION_CUTOFF_DAYS, "days");
|
|
504
|
+
const stakedEarly = userEvents.some(e =>
|
|
505
|
+
e.event === "stake" && dayjs(e.time).isBefore(earlyStakeDay)
|
|
506
|
+
);
|
|
507
|
+
if (stakedEarly) {
|
|
508
|
+
const postCutoffEvents = userEvents.filter(e => dayjs(e.time).isAfter(stakeRetentionCutoff));
|
|
509
|
+
if (postCutoffEvents.length < 5) {
|
|
510
|
+
const swapTemplate = userEvents.find(e => e.event === "swap");
|
|
511
|
+
const portfolioTemplate = userEvents.find(e => e.event === "portfolio viewed");
|
|
512
|
+
if (swapTemplate) {
|
|
513
|
+
for (let i = 0; i < STAKE_INJECT_SWAP_COUNT; i++) {
|
|
514
|
+
userEvents.push({
|
|
515
|
+
...swapTemplate,
|
|
516
|
+
time: stakeRetentionCutoff.add(chance.integer({ min: 1, max: 55 }), "days").toISOString(),
|
|
517
|
+
user_id: swapTemplate.user_id,
|
|
518
|
+
trade_amount_usd: Math.round((swapTemplate.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 2.0 })),
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
if (portfolioTemplate) {
|
|
523
|
+
for (let i = 0; i < STAKE_INJECT_PORTFOLIO_COUNT; i++) {
|
|
524
|
+
userEvents.push({
|
|
525
|
+
...portfolioTemplate,
|
|
526
|
+
time: stakeRetentionCutoff.add(chance.integer({ min: 1, max: 55 }), "days").toISOString(),
|
|
527
|
+
user_id: portfolioTemplate.user_id,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
} else {
|
|
533
|
+
// Non-stakers: drop 85% of post-D60 events to simulate ~15% retention
|
|
534
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
535
|
+
const evt = userEvents[i];
|
|
536
|
+
if (dayjs(evt.time).isAfter(stakeRetentionCutoff)) {
|
|
537
|
+
if (chance.bool({ likelihood: STAKE_NON_STAKER_DROP_LIKELIHOOD })) {
|
|
538
|
+
userEvents.splice(i, 1);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// H7: Pro tier maker fees — Pro=0.05, Standard=0.30; Pro gets 5 extra cloned swaps
|
|
545
|
+
if (profile.trading_tier === "Pro") {
|
|
546
|
+
userEvents.forEach(e => {
|
|
547
|
+
if (e.event === "swap") e.maker_fee_pct = PRO_MAKER_FEE;
|
|
548
|
+
});
|
|
549
|
+
const proSwaps = userEvents.filter(e => e.event === "swap");
|
|
550
|
+
const clonedProSwaps = [];
|
|
551
|
+
proSwaps.forEach(swapEvt => {
|
|
552
|
+
const swapTime = dayjs(swapEvt.time);
|
|
553
|
+
for (let i = 0; i < PRO_SWAP_CLONE_COUNT; i++) {
|
|
554
|
+
clonedProSwaps.push({
|
|
555
|
+
...swapEvt,
|
|
556
|
+
time: swapTime.add(chance.integer({ min: 1, max: 96 }), "hours").toISOString(),
|
|
557
|
+
user_id: swapEvt.user_id,
|
|
558
|
+
trade_amount_usd: Math.round((swapEvt.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 3.0 })),
|
|
559
|
+
maker_fee_pct: PRO_MAKER_FEE,
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
});
|
|
563
|
+
userEvents.push(...clonedProSwaps);
|
|
564
|
+
} else {
|
|
565
|
+
userEvents.forEach(e => {
|
|
566
|
+
if (e.event === "swap") e.maker_fee_pct = STANDARD_MAKER_FEE;
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// H8: Rug-pull aftermath — ~2% of pre-day-70 swaps flipped to SCAM; victims lose 80% post-D70
|
|
571
|
+
const scamCutoff = datasetStart.add(SCAM_WINDOW_END_DAY, "days");
|
|
572
|
+
let hadScam = false;
|
|
573
|
+
userEvents.forEach(e => {
|
|
574
|
+
if (e.event === "swap") {
|
|
575
|
+
const swapDay = dayjs(e.time).diff(datasetStart, "day");
|
|
576
|
+
if (swapDay >= SCAM_WINDOW_START_DAY && swapDay < SCAM_WINDOW_END_DAY && chance.bool({ likelihood: SCAM_FLIP_LIKELIHOOD })) {
|
|
577
|
+
e.token_pair = chance.pickone(["SCAM/USDC", "SCAM/ETH", "ETH/SCAM"]);
|
|
578
|
+
hadScam = true;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
if (hadScam) {
|
|
583
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
584
|
+
const evt = userEvents[i];
|
|
585
|
+
if (dayjs(evt.time).isAfter(scamCutoff) && chance.bool({ likelihood: SCAM_DROP_LIKELIHOOD })) {
|
|
586
|
+
userEvents.splice(i, 1);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// H10: Swap-count magic number — sweet 8-20 boosts stake; over 21+ drops portfolio views
|
|
592
|
+
const swapCount = userEvents.filter(e => e.event === "swap").length;
|
|
593
|
+
if (swapCount >= SWAP_SWEET_MIN && swapCount <= SWAP_SWEET_MAX) {
|
|
594
|
+
const stakeTemplate = userEvents.find(e => e.event === "stake");
|
|
595
|
+
if (stakeTemplate) {
|
|
596
|
+
const stakes = userEvents.filter(e => e.event === "stake");
|
|
597
|
+
stakes.forEach(s => {
|
|
598
|
+
if (typeof s.amount_usd === "number") s.amount_usd = Math.round(s.amount_usd * SWAP_STAKE_BOOST);
|
|
599
|
+
const extras = chance.integer({ min: 1, max: 2 });
|
|
600
|
+
for (let k = 0; k < extras; k++) {
|
|
601
|
+
userEvents.push({
|
|
602
|
+
...s,
|
|
603
|
+
time: dayjs(s.time).add(chance.integer({ min: 5, max: 360 }), "minutes").toISOString(),
|
|
604
|
+
user_id: s.user_id,
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
} else if (swapCount >= SWAP_OVER_THRESHOLD) {
|
|
610
|
+
// Over-active traders ignore portfolio review: drop 75%, halve total_value_usd on survivors
|
|
611
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
612
|
+
if (userEvents[i].event === "portfolio viewed") {
|
|
613
|
+
if (chance.bool({ likelihood: SWAP_OVER_PORTFOLIO_DROP_LIKELIHOOD })) {
|
|
614
|
+
userEvents.splice(i, 1);
|
|
615
|
+
} else {
|
|
616
|
+
userEvents[i].total_value_usd = Math.round((userEvents[i].total_value_usd || 5000) * SWAP_OVER_PORTFOLIO_VALUE_FACTOR);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// H11: Early staker retention — born-in users with <2 early stakes lose 60% post-D40
|
|
623
|
+
if (meta.userIsBornInDataset) {
|
|
624
|
+
const firstT = userEvents[0]?.time;
|
|
625
|
+
if (firstT) {
|
|
626
|
+
const window10 = dayjs(firstT).add(EARLY_STAKE_WINDOW_DAYS, "days").toISOString();
|
|
627
|
+
const earlyStakes = userEvents.filter(e => e.event === "stake" && e.time <= window10).length;
|
|
628
|
+
if (earlyStakes < EARLY_STAKE_MIN) {
|
|
629
|
+
const cutoff = dayjs(firstT).add(EARLY_STAKE_CUTOFF_DAYS, "days");
|
|
630
|
+
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
631
|
+
if (dayjs(userEvents[i].time).isAfter(cutoff) && chance.bool({ likelihood: EARLY_STAKE_DROP_LIKELIHOOD })) {
|
|
632
|
+
userEvents.splice(i, 1);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// H2: Gas price spike — days 35-37 swap+transfer gas_fee_usd 10x; 40% of swaps fail.
|
|
640
|
+
// Runs AFTER all cloning to catch events that land in the spike window from clone offsets.
|
|
641
|
+
const gasSpikeStart = datasetStart.add(GAS_SPIKE_START_DAY, "days");
|
|
642
|
+
const gasSpikeEnd = datasetStart.add(GAS_SPIKE_END_DAY, "days");
|
|
643
|
+
userEvents.forEach(e => {
|
|
644
|
+
if (e.event !== "swap" && e.event !== "transfer") return;
|
|
645
|
+
const t = dayjs(e.time);
|
|
646
|
+
if (t.isAfter(gasSpikeStart) && t.isBefore(gasSpikeEnd)) {
|
|
647
|
+
e.gas_fee_usd = Math.round((e.gas_fee_usd || 5) * GAS_SPIKE_MULT);
|
|
648
|
+
if (e.event === "swap" && chance.bool({ likelihood: GAS_SPIKE_FAIL_LIKELIHOOD })) {
|
|
649
|
+
e.swap_status = "failed";
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
655
|
+
return record;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── CONFIG ──
|
|
338
659
|
/** @type {Config} */
|
|
339
660
|
const config = {
|
|
340
661
|
version: 2,
|
|
341
|
-
token,
|
|
342
662
|
seed: SEED,
|
|
343
|
-
datasetStart:
|
|
344
|
-
datasetEnd:
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
numUsers: num_users,
|
|
348
|
-
hasAnonIds: true,
|
|
349
|
-
avgDevicePerUser: 2,
|
|
350
|
-
hasSessionIds: true,
|
|
663
|
+
datasetStart: DATASET_START,
|
|
664
|
+
datasetEnd: DATASET_END,
|
|
665
|
+
avgEventsPerUserPerDay: EVENTS_PER_DAY,
|
|
666
|
+
numUsers: NUM_USERS,
|
|
351
667
|
format: "json",
|
|
352
668
|
gzip: true,
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
669
|
+
credentials: {
|
|
670
|
+
token,
|
|
671
|
+
},
|
|
672
|
+
switches: {
|
|
673
|
+
hasSessionIds: true,
|
|
674
|
+
alsoInferFunnels: false,
|
|
675
|
+
hasLocation: true,
|
|
676
|
+
hasAndroidDevices: false,
|
|
677
|
+
hasIOSDevices: true,
|
|
678
|
+
hasDesktopDevices: true,
|
|
679
|
+
hasBrowser: true,
|
|
680
|
+
hasCampaigns: false,
|
|
681
|
+
isAnonymous: false,
|
|
682
|
+
hasAdSpend: false,
|
|
683
|
+
hasAvatar: true,
|
|
684
|
+
},
|
|
685
|
+
identity: {
|
|
686
|
+
avgDevicePerUser: 2,
|
|
687
|
+
},
|
|
363
688
|
|
|
364
689
|
concurrency: 1,
|
|
365
690
|
writeToDisk: false,
|
|
@@ -560,330 +885,9 @@ const config = {
|
|
|
560
885
|
groupProps: {},
|
|
561
886
|
lookupTables: [],
|
|
562
887
|
|
|
563
|
-
hook
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
// causing day-window tags to leak across the boundary.
|
|
567
|
-
|
|
568
|
-
// HOOK 9 (T2C): TRADING FUNNEL TIME-TO-CONVERT (funnel-post)
|
|
569
|
-
// Pro tier users complete deposit→swap→withdrawal funnel 1.4x faster
|
|
570
|
-
// (factor 0.7 on inter-event gaps); Standard 1.3x slower (factor 1.3).
|
|
571
|
-
if (type === "funnel-post") {
|
|
572
|
-
const segment = meta?.profile?.trading_tier;
|
|
573
|
-
if (Array.isArray(record) && record.length > 1) {
|
|
574
|
-
const factor = (
|
|
575
|
-
segment === "Pro" ? 0.7 :
|
|
576
|
-
segment === "Standard" ? 1.3 :
|
|
577
|
-
1.0
|
|
578
|
-
);
|
|
579
|
-
if (factor !== 1.0) {
|
|
580
|
-
for (let i = 1; i < record.length; i++) {
|
|
581
|
-
const prev = dayjs(record[i - 1].time);
|
|
582
|
-
const newGap = Math.round(dayjs(record[i].time).diff(prev) * factor);
|
|
583
|
-
record[i].time = prev.add(newGap, "milliseconds").toISOString();
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
if (type === "everything") {
|
|
590
|
-
const datasetStart = dayjs.unix(meta.datasetStart);
|
|
591
|
-
const userEvents = record;
|
|
592
|
-
const profile = meta.profile;
|
|
593
|
-
|
|
594
|
-
// Stamp superProps from profile for consistency
|
|
595
|
-
userEvents.forEach(e => {
|
|
596
|
-
e.trading_tier = profile.trading_tier;
|
|
597
|
-
e.preferred_chain = profile.preferred_chain;
|
|
598
|
-
e.wallet_type = profile.wallet_type;
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
const firstEventTime = userEvents.length > 0 ? dayjs(userEvents[0].time) : null;
|
|
602
|
-
|
|
603
|
-
// HOOK 1: WHALE WALLETS — 2% of users (charCodeAt(0) % 50 === 0)
|
|
604
|
-
// get swap trade_amount_usd boosted 50x. No flag.
|
|
605
|
-
const userId = userEvents.length > 0 ? (userEvents[0].user_id || userEvents[0].distinct_id || "") : "";
|
|
606
|
-
const isWhale = userId.length > 0 && userId.charCodeAt(0) % 50 === 0;
|
|
607
|
-
|
|
608
|
-
if (isWhale) {
|
|
609
|
-
userEvents.forEach(e => {
|
|
610
|
-
if (e.event === "swap") {
|
|
611
|
-
e.trade_amount_usd = Math.round((e.trade_amount_usd || 200) * 50);
|
|
612
|
-
}
|
|
613
|
-
});
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// HOOK 2 moved to end (after all cloning) for full coverage
|
|
617
|
-
|
|
618
|
-
// HOOK 3: TOKEN LAUNCH SURGE — after d50, 25% of swaps flip token_pair to MOON.
|
|
619
|
-
const day50 = datasetStart.add(50, "days");
|
|
620
|
-
userEvents.forEach(e => {
|
|
621
|
-
if (e.event === "swap" && dayjs(e.time).isAfter(day50) && chance.bool({ likelihood: 25 })) {
|
|
622
|
-
e.token_pair = chance.pickone(["MOON/USDC", "MOON/ETH", "ETH/MOON"]);
|
|
623
|
-
}
|
|
624
|
-
});
|
|
625
|
-
|
|
626
|
-
// HOOK 3 (cont): TOKEN LAUNCH SURGE — clone 4 extra MOON-pair
|
|
627
|
-
// swap events per existing MOON swap. Cloned with unique offset.
|
|
628
|
-
const moonSwaps = userEvents.filter(e =>
|
|
629
|
-
e.event === "swap" &&
|
|
630
|
-
typeof e.token_pair === "string" &&
|
|
631
|
-
e.token_pair.includes("MOON")
|
|
632
|
-
);
|
|
633
|
-
if (moonSwaps.length > 0) {
|
|
634
|
-
const clonedMoonEvents = [];
|
|
635
|
-
moonSwaps.forEach(moonEvt => {
|
|
636
|
-
const moonTime = dayjs(moonEvt.time);
|
|
637
|
-
for (let i = 0; i < 4; i++) {
|
|
638
|
-
clonedMoonEvents.push({
|
|
639
|
-
...moonEvt,
|
|
640
|
-
time: moonTime.add(chance.integer({ min: 1, max: 72 }), "hours").toISOString(),
|
|
641
|
-
user_id: moonEvt.user_id,
|
|
642
|
-
trade_amount_usd: Math.round((moonEvt.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 2.0 })),
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
});
|
|
646
|
-
userEvents.push(...clonedMoonEvents);
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// -----------------------------------------------------------
|
|
650
|
-
// Hook #4: AIRDROP HUNTER CHURN
|
|
651
|
-
// 4% of users are deterministically tagged as airdrop-farming bots
|
|
652
|
-
// via charCodeAt(1) % 25 === 0. Bots that have ever claimed an airdrop
|
|
653
|
-
// lose 95% of events after their first claim. (Original cohort
|
|
654
|
-
// "claimed-but-never-swapped" was empty because every claimer also
|
|
655
|
-
// has organic swap events at this scale.)
|
|
656
|
-
// -----------------------------------------------------------
|
|
657
|
-
const isAirdropBot = userId.length > 1 && userId.charCodeAt(1) % 25 === 0;
|
|
658
|
-
const hasAirdropClaim = userEvents.some(e => e.event === "claim airdrop");
|
|
659
|
-
|
|
660
|
-
if (isAirdropBot && hasAirdropClaim) {
|
|
661
|
-
const firstClaim = userEvents.find(e => e.event === "claim airdrop");
|
|
662
|
-
if (firstClaim) {
|
|
663
|
-
const claimTime = dayjs(firstClaim.time);
|
|
664
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
665
|
-
const evt = userEvents[i];
|
|
666
|
-
if (dayjs(evt.time).isAfter(claimTime) && evt.event !== "claim airdrop") {
|
|
667
|
-
if (chance.bool({ likelihood: 95 })) {
|
|
668
|
-
userEvents.splice(i, 1);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
// HOOK 5: KYC FUNNEL COMPLETION — users with kyc-completed
|
|
676
|
-
// get post-KYC deposit_amount_usd boosted 4x and 7 extra cloned
|
|
677
|
-
// swaps per existing post-KYC swap. No flag.
|
|
678
|
-
const kycCompleted = userEvents.find(e => e.event === "kyc completed");
|
|
679
|
-
if (kycCompleted) {
|
|
680
|
-
const kycTime = dayjs(kycCompleted.time);
|
|
681
|
-
|
|
682
|
-
userEvents.forEach(e => {
|
|
683
|
-
if (dayjs(e.time).isAfter(kycTime) && e.event === "deposit") {
|
|
684
|
-
e.deposit_amount_usd = Math.round((e.deposit_amount_usd || 500) * 4);
|
|
685
|
-
}
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
const postKycSwaps = userEvents.filter(e =>
|
|
689
|
-
e.event === "swap" && dayjs(e.time).isAfter(kycTime)
|
|
690
|
-
);
|
|
691
|
-
const clonedKycSwaps = [];
|
|
692
|
-
postKycSwaps.forEach(swapEvt => {
|
|
693
|
-
const swapTime = dayjs(swapEvt.time);
|
|
694
|
-
for (let i = 0; i < 7; i++) {
|
|
695
|
-
clonedKycSwaps.push({
|
|
696
|
-
...swapEvt,
|
|
697
|
-
time: swapTime.add(chance.integer({ min: 1, max: 48 }), "hours").toISOString(),
|
|
698
|
-
user_id: swapEvt.user_id,
|
|
699
|
-
trade_amount_usd: Math.round((swapEvt.trade_amount_usd || 200) * chance.floating({ min: 0.8, max: 1.5 })),
|
|
700
|
-
});
|
|
701
|
-
}
|
|
702
|
-
});
|
|
703
|
-
userEvents.push(...clonedKycSwaps);
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
// -----------------------------------------------------------
|
|
707
|
-
// Hook #6: STAKE-TO-RETAIN
|
|
708
|
-
// Users who stake any token within the first 14 days of the
|
|
709
|
-
// dataset have 70% D60 retention (events injected beyond D60).
|
|
710
|
-
// Non-stakers lose events after day 60 to simulate 15% retention.
|
|
711
|
-
// -----------------------------------------------------------
|
|
712
|
-
const day14 = datasetStart.add(14, "days");
|
|
713
|
-
const day60 = datasetStart.add(60, "days");
|
|
714
|
-
|
|
715
|
-
const stakedEarly = userEvents.some(e =>
|
|
716
|
-
e.event === "stake" && dayjs(e.time).isBefore(day14)
|
|
717
|
-
);
|
|
718
|
-
|
|
719
|
-
if (stakedEarly) {
|
|
720
|
-
// 70% retention: inject late-stage events for stakers (no flag)
|
|
721
|
-
const postD60Events = userEvents.filter(e => dayjs(e.time).isAfter(day60));
|
|
722
|
-
if (postD60Events.length < 5) {
|
|
723
|
-
const swapTemplate = userEvents.find(e => e.event === "swap");
|
|
724
|
-
const portfolioTemplate = userEvents.find(e => e.event === "portfolio viewed");
|
|
725
|
-
if (swapTemplate) {
|
|
726
|
-
for (let i = 0; i < 8; i++) {
|
|
727
|
-
userEvents.push({
|
|
728
|
-
...swapTemplate,
|
|
729
|
-
time: day60.add(chance.integer({ min: 1, max: 55 }), "days").toISOString(),
|
|
730
|
-
user_id: swapTemplate.user_id,
|
|
731
|
-
trade_amount_usd: Math.round((swapTemplate.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 2.0 })),
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
if (portfolioTemplate) {
|
|
736
|
-
for (let i = 0; i < 4; i++) {
|
|
737
|
-
userEvents.push({
|
|
738
|
-
...portfolioTemplate,
|
|
739
|
-
time: day60.add(chance.integer({ min: 1, max: 55 }), "days").toISOString(),
|
|
740
|
-
user_id: portfolioTemplate.user_id,
|
|
741
|
-
});
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
}
|
|
745
|
-
} else {
|
|
746
|
-
// Non-stakers: 15% retention past D60 — remove 85% of post-D60 events
|
|
747
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
748
|
-
const evt = userEvents[i];
|
|
749
|
-
if (dayjs(evt.time).isAfter(day60)) {
|
|
750
|
-
if (chance.bool({ likelihood: 85 })) {
|
|
751
|
-
userEvents.splice(i, 1);
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
// HOOK 7: PRO TIER MAKER FEES — Pro users pay maker_fee_pct=0.05
|
|
758
|
-
// (vs Standard 0.30) and get 5 extra cloned swaps. Mutates raw
|
|
759
|
-
// maker_fee_pct prop. Reads trading_tier from profile.
|
|
760
|
-
if (profile.trading_tier === "Pro") {
|
|
761
|
-
userEvents.forEach(e => {
|
|
762
|
-
if (e.event === "swap") e.maker_fee_pct = 0.05;
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
const proSwaps = userEvents.filter(e => e.event === "swap");
|
|
766
|
-
const clonedProSwaps = [];
|
|
767
|
-
proSwaps.forEach(swapEvt => {
|
|
768
|
-
const swapTime = dayjs(swapEvt.time);
|
|
769
|
-
for (let i = 0; i < 5; i++) {
|
|
770
|
-
clonedProSwaps.push({
|
|
771
|
-
...swapEvt,
|
|
772
|
-
time: swapTime.add(chance.integer({ min: 1, max: 96 }), "hours").toISOString(),
|
|
773
|
-
user_id: swapEvt.user_id,
|
|
774
|
-
trade_amount_usd: Math.round((swapEvt.trade_amount_usd || 200) * chance.floating({ min: 0.5, max: 3.0 })),
|
|
775
|
-
maker_fee_pct: 0.05,
|
|
776
|
-
});
|
|
777
|
-
}
|
|
778
|
-
});
|
|
779
|
-
userEvents.push(...clonedProSwaps);
|
|
780
|
-
} else {
|
|
781
|
-
userEvents.forEach(e => {
|
|
782
|
-
if (e.event === "swap") e.maker_fee_pct = 0.30;
|
|
783
|
-
});
|
|
784
|
-
}
|
|
785
|
-
|
|
786
|
-
// -----------------------------------------------------------
|
|
787
|
-
// Hook #8: RUG-PULL AFTERMATH
|
|
788
|
-
// Day 70, "SCAM" token rugs. Users who swapped SCAM token
|
|
789
|
-
// before day 70 lose 80% of events after day 70. No flag —
|
|
790
|
-
// derive cohort by detecting users with token_pair containing "SCAM".
|
|
791
|
-
// -----------------------------------------------------------
|
|
792
|
-
const day70 = datasetStart.add(70, "days");
|
|
793
|
-
|
|
794
|
-
let hadScam = false;
|
|
795
|
-
userEvents.forEach(e => {
|
|
796
|
-
if (e.event === "swap") {
|
|
797
|
-
const swapDay = dayjs(e.time).diff(datasetStart, "day");
|
|
798
|
-
if (swapDay >= 10 && swapDay < 70 && chance.bool({ likelihood: 2 })) {
|
|
799
|
-
e.token_pair = chance.pickone(["SCAM/USDC", "SCAM/ETH", "ETH/SCAM"]);
|
|
800
|
-
hadScam = true;
|
|
801
|
-
}
|
|
802
|
-
}
|
|
803
|
-
});
|
|
804
|
-
|
|
805
|
-
if (hadScam) {
|
|
806
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
807
|
-
const evt = userEvents[i];
|
|
808
|
-
if (dayjs(evt.time).isAfter(day70) && chance.bool({ likelihood: 80 })) {
|
|
809
|
-
userEvents.splice(i, 1);
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// HOOK 10: SWAP-COUNT MAGIC NUMBER (no flags)
|
|
815
|
-
// Sweet 8-20 swaps → +30% on stake amount_usd; clone 1-2 extra
|
|
816
|
-
// stake events per existing. Over 21+ → drop 75% of portfolio viewed events and halve
|
|
817
|
-
// total_value_usd on survivors (overcomes H6 injection overlap).
|
|
818
|
-
const swapCount = userEvents.filter(e => e.event === "swap").length;
|
|
819
|
-
if (swapCount >= 8 && swapCount <= 20) {
|
|
820
|
-
const stakeTemplate = userEvents.find(e => e.event === "stake");
|
|
821
|
-
if (stakeTemplate) {
|
|
822
|
-
const stakes = userEvents.filter(e => e.event === "stake");
|
|
823
|
-
stakes.forEach(s => {
|
|
824
|
-
if (typeof s.amount_usd === "number") s.amount_usd = Math.round(s.amount_usd * 1.3);
|
|
825
|
-
const extras = chance.integer({ min: 1, max: 2 });
|
|
826
|
-
for (let k = 0; k < extras; k++) {
|
|
827
|
-
userEvents.push({
|
|
828
|
-
...s,
|
|
829
|
-
time: dayjs(s.time).add(chance.integer({ min: 5, max: 360 }), "minutes").toISOString(),
|
|
830
|
-
user_id: s.user_id,
|
|
831
|
-
});
|
|
832
|
-
}
|
|
833
|
-
});
|
|
834
|
-
}
|
|
835
|
-
} else if (swapCount >= 21) {
|
|
836
|
-
// Over-active traders ignore portfolio review.
|
|
837
|
-
// Drop aggressively (75%) to overcome H6 stake-to-retain injection,
|
|
838
|
-
// and halve total_value_usd on survivors to sharpen the signal.
|
|
839
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
840
|
-
if (userEvents[i].event === "portfolio viewed") {
|
|
841
|
-
if (chance.bool({ likelihood: 75 })) {
|
|
842
|
-
userEvents.splice(i, 1);
|
|
843
|
-
} else {
|
|
844
|
-
userEvents[i].total_value_usd = Math.round((userEvents[i].total_value_usd || 5000) * 0.5);
|
|
845
|
-
}
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// HOOK 11: EARLY STAKER RETENTION — users with 2+ "stake" events in first
|
|
851
|
-
// 10 days retain; others lose 60% of post-day-40 events.
|
|
852
|
-
if (meta.userIsBornInDataset) {
|
|
853
|
-
const firstT = userEvents[0]?.time;
|
|
854
|
-
if (firstT) {
|
|
855
|
-
const window10 = dayjs(firstT).add(10, 'days').toISOString();
|
|
856
|
-
const earlyStakes = userEvents.filter(e => e.event === 'stake' && e.time <= window10).length;
|
|
857
|
-
if (earlyStakes < 2) {
|
|
858
|
-
const cutoff = dayjs(firstT).add(40, 'days');
|
|
859
|
-
for (let i = userEvents.length - 1; i >= 0; i--) {
|
|
860
|
-
if (dayjs(userEvents[i].time).isAfter(cutoff) && chance.bool({ likelihood: 60 })) {
|
|
861
|
-
userEvents.splice(i, 1);
|
|
862
|
-
}
|
|
863
|
-
}
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
// HOOK 2: GAS PRICE SPIKE — days 35-37 swap+transfer gas_fee_usd 10x,
|
|
869
|
-
// 40% of swaps get swap_status=failed. Runs AFTER all cloning to
|
|
870
|
-
// catch events that land in the spike window from clone offsets.
|
|
871
|
-
const day35 = datasetStart.add(35, "days");
|
|
872
|
-
const day38 = datasetStart.add(38, "days");
|
|
873
|
-
userEvents.forEach(e => {
|
|
874
|
-
if (e.event !== "swap" && e.event !== "transfer") return;
|
|
875
|
-
const t = dayjs(e.time);
|
|
876
|
-
if (t.isAfter(day35) && t.isBefore(day38)) {
|
|
877
|
-
e.gas_fee_usd = Math.round((e.gas_fee_usd || 5) * 10);
|
|
878
|
-
if (e.event === "swap" && chance.bool({ likelihood: 40 })) {
|
|
879
|
-
e.swap_status = "failed";
|
|
880
|
-
}
|
|
881
|
-
}
|
|
882
|
-
});
|
|
883
|
-
|
|
884
|
-
userEvents.sort((a, b) => new Date(a.time) - new Date(b.time));
|
|
885
|
-
}
|
|
886
|
-
|
|
888
|
+
hook(record, type, meta) {
|
|
889
|
+
if (type === "funnel-post") return handleFunnelPostHooks(record, meta);
|
|
890
|
+
if (type === "everything") return handleEverythingHooks(record, meta);
|
|
887
891
|
return record;
|
|
888
892
|
}
|
|
889
893
|
};
|