Fan profile & signals
How Club scores a fan's RFM/lifecycle, resolves who sent them, and layers a second self-reported "discovery" signal on top of it — the one place every fan screen gets these labels from.
Every fan-facing screen — the fan card, the chat context panel, the Fans grid, the source leaderboard's drills — renders the same small set of computed labels. This page is the mental model for where those labels come from. The how-to pages under How-to cover the screens that show them.
RFM, lifecycle, and value tier
core/fan/profile.py's compute_fan_profile is a pure function: no DB, no network, no clock
of its own — a caller hands it a fan's OnlyFans-native subscription state plus their transaction
rows, and it returns the same dict every time for the same inputs. It computes, all at once:
| Field | What it is |
|---|---|
lifecycle | One of New, Active, Regular, Returned, Leaving, Lapsed — see below. |
value_tier / value_label | free / small / medium / large / whale, plus a real dollar label (e.g. 🐳 $610). |
tenure_bucket / tenure_days | Days since first subscribe, bucketed fresh → og. |
rfm_r / rfm_f / rfm_m / rfm_segment | Recency/Frequency/Monetary, each 1–5, folded into one of 7 named segments. |
recency_token | The one short chip a card shows — "3d" for a New fan's tenure, "dormant 8mo" for a Returned fan's lapse, "−5d" counting down to a Leaving fan's expiry. |
Lifecycle is a strict priority order, so a fan is never in two states at once: Lapsed (sub
expired) beats Leaving beats Returned (came back after a gap) beats Regular (≥90 days
tenure, ≥2 paid cycles) beats New (single cycle, under 30 days) — everything else is Active.
Leaving itself fires on either of two conditions, not just one: the sub ends within 7 days
with auto-renew off, or the fan has already set a cancellation date at all — even a fan who
cancels on day one of a 30-day cycle shows as Leaving immediately, not just in the final week.
RFM segments resolve in this order, best fan wins first:
| Segment | Rule |
|---|---|
| Champion | recency ≥4 and money ≥4 |
| Big spender | money ≥4 |
| Loyal | frequency ≥4 and recency ≥3 |
| Promising | recency ≥4 and frequency ≤2 |
| At risk | recency ≤2 and money ≥3 |
| Dormant | recency ≤2, money ≤2, has paid at least once |
| Lost | everything else |
Thresholds are owner-configurable, not hardcoded
The dollar bands behind value_tier and the RFM money axis come from owner_config
(value_tier_cents, rfm_m_cents), resolved once per request and passed in — the classifier
itself never reads settings. Two accounts can see a "whale" at different dollar amounts.
Dormancy (return_recency, lapse_days) is only computed for a Returned fan, and it degrades
honestly: a real gap between transactions wins; with no transaction before the return, tenure is
used as a flagged estimate (lapse_estimated: true); with neither, the field is left blank
rather than invented.
build_fan_profiles is the DB-aware wrapper — one pass over of_transactions + of_fans,
optionally restricted to a fan-id set — that most callers actually use.
Where a fan's source comes from
core/fan/classify.py's resolve_fan_source walks a fixed precedence and stops at the first
match, mirroring the leaderboard's own resolution:
- cid first-touch — a proven click through one of your domains.
- stored link (
fan_source.kind == 'link') — a SourceLink rule already matched. - campaign — the OF tracking link the fan came through resolved to a known campaign; folds
in an additional flag-gated D3b code lookup when
OF_D3B_FORWARD=1. - stored llm (
fan_source.kind == 'llm') — a DM-scan guess, gap-filled below every OF-native signal. - stored pending — traffic is known, the source isn't yet.
- organic — the honest remainder.
Two implementations, not one
classify.py's resolve_fan_source is its own pure implementation, built to "mirror" the
money leaderboard's ladder — its own docstring says so. The fan card itself (GET /fan/{fan_id})
and the money leaderboard resolve through a different function,
source_leaderboard.resolve_fan_attribution. Both are meant to agree; this page describes what
classify.py computes, not a proof that the two never diverge. For the leaderboard's own,
longer ladder (it adds a checking state ahead of organic), see
Source attribution.
A resolved fan's klass is organic or ours — a pending fan is klass: 'ours' with the
label Pending attribution, not folded into organic.
Exact vs. approximate origin
core/fan/origin.py renders a resolved source into the card's {kind, label, source, domain, country, precision, link_ref} contract. Its one hard rule: precision: 'exact' only for
kind: 'cid' — a proven per-click domain, source, and country. Every other kind (link,
campaign, llm, pending, organic) is 'approx', and country is left blank for all of them
except cid — a link or a DM guess carries no proven per-fan geography. Never render a
calculation as if it were a click.
The origin stamp: frozen once, not re-guessed
core/fan/origin_stamp.py freezes a fan's origin into FanOriginStamp the first time it
resolves to a hard kind (cid, link, or campaign — never llm, pending, or organic,
which would lock in a weak signal and block a later real determination). Once frozen:
- A soft resolution (llm/pending/organic) never touches an existing stamp.
- An
exactstamp (cid) is never overwritten by anapproxcalculation. - A later hard determination can still refresh a diverged stamp — but only when the source identity actually moved, or when it upgrades approx → exact; a bare kind/link_ref flip from resolver churn is not a refresh.
- Country is carried forward independently: an empty incoming country never erases a country the stamp already had.
GET /fan/{fan_id} prefers the frozen stamp when one exists, and only falls back to a live
resolution for an unstamped fan.
The discovery axis: what the fan said about themselves
core/fan/discovery.py is a deliberately second, independent signal — not a replacement for
fan_source. A fan can subscribe through your Instagram link and mention Reddit in chat; both
facts are true, and the card shows both. Discovery is built straight from FanAttribution
(status == 'scanned'), and is only surfaced on the card once its confidence clears 0.8 — a
weaker self-report stays in the data but off the card, because a low-confidence signal about
what a person said is a strong claim to attach a name to.
Two different confidence thresholds — don't conflate them
Discovery's display threshold (0.8) is unrelated to the DM-scan's own gap-fill threshold (0.6, below) — one gates whether a chat-derived hint is shown at all, the other gates whether that same scan is allowed to write the fan's canonical source.
The DM-scan attribution engine
core/fan/attribution.py is the product half of enriching an organic fan: an LLM reads a fan's
already-captured DM history in two focused turns (source/country, then a 13-field dossier) and
writes FanAttribution. The precedence discipline is the important part:
- The scan always writes
FanAttribution(tags and evidence land regardless of confidence). - It only gap-fills the canonical
FanSourcewhen confidence is ≥ 0.6 and the fan has no hard signal yet (no row, orkindinpending/organic). - A
cidorlinkfan is never touched — hard signals always win, first-seen-wins.
The scan makes zero OnlyFans/OFAPI/engine network calls of its own — it only reads a locally captured feed file and runs one BYO-key LLM call over already-captured text. The operational side (starting a sweep, reading progress, what a stuck job means) is Run a DM-history scan.
Segments, audience, and churn risk
A dynamic fan segment (core/fan/segments.py) is a saved filters_json rule recomputed at
read time — members are never materialized. Its filter set covers subscription state, spend
windows, purchase recency, chargeback count, and source/country — money fields import the same
_REVENUE_STATUS allow-list the fan card uses, so a segment and a fan's own card can never
disagree about what counts as spend.
The audience resolver (core/audience_service.AudienceSpec / resolve_audience) is the one
engine behind both broadcast targeting and churn risk (core/fan/insights.churn_risk — high
lifetime spenders inactive ≥N days, including fans who never wrote in at all, ranked by spend). A
single recipient cap applies to both selection modes, and it's measured on what would actually
be sent, not on how many fans matched before the cap.
Churn risk has no dashboard screen today
churn_risk is only reachable as an AI-assistant (MCP) tool right now — no dashboard screen in
the fans area calls it. If you need it, read it through the MCP tool rather than expecting a
panel for it in the product yet.
Fan economics
core/fan/metrics.py computes ARPPU, APC, APV, and ARPNU from money rows plus a cohort
first-seen map — the same functions behind revenue.fans / revenue.fans_account on the
attribution leaderboard. A $0 trial subscriber counts as a new fan in the cohort denominator from
the moment they subscribe, before they've paid a cent. The full formulas and response shape are
documented once, in Source attribution — this
page only notes that the fans area reads the same numbers, not a second copy of them.
Full lifetime value by source
core/full_ltv.py attributes a fan's entire lifetime net — not just new-traffic revenue — to
their acquisition source, with the same invariant as the leaderboard: attributed + organic always
equals the total, and a fan with no acquisition row is organic, never retro-guessed. It's a pure
function consumed by the source leaderboard, not by any screen in this area directly.
What's next
- Open a fan's card — where most of these labels actually render.
- Run a DM-history scan — the operational side of the attribution engine above.
- Source attribution — the money leaderboard's own, longer resolution ladder.