Strasmore Research
Deep Dives · Matt ConnorBy Matt Connor ·

הטיית שרידות בנתוני מניות: הסבר ומדידה

הטיית שרידות מסתירה מניות שנמחקו מהמסחר או נרכשו ברוב מאגרי הנתונים. למדו כיצד הפער הזה מעוות תוצאות של Backtest ואיך למדוד את יקום המניות המלא בצורה מדויקת.

הטיית שרידות (Survivorship bias) בנתוני מניות היא הפער שנוצר כאשר קובץ נתונים כולל רק את הטיקרים שנסחרים נכון להיום. כל חברה שנמחקה מהמסחר, נרכשה, שינתה את שמה או הפסיקה את פעילותה נעדרת מהקובץ באופן שקט. כתוצאה מכך, הרצת Backtest על יקום נתונים כזה מודדת מדגם מסונן של ההיסטוריה, ולא את השוק כפי שהיה באמת. בקובץ עצמו שום דבר לא נראה שגוי; כל שורה מכילה מחיר אמיתי, והבעיה היא בשורות שאיש לא כתב.

מהי הטיית שרידות בנתוני מניות?

חשבו כיצד מורכב מאגר נתונים של "כל מניות ארה"ב". תהליך כלשהו פונה לספק נתונים לקבלת רשימת סימולים, ואז מושך את היסטוריית המחירים עבור כל שם ברשימה. רשימה זו היא עדכנית מעצם הגדרתה. היא עונה על השאלה "מה נסחר כעת", וכל סימול שהפסיק להיסחר לפני ביצוע הבקשה נעדר מהתשובה, יחד עם כל ההיסטוריה שלו.

המונח נגזר מעבודתו של אברהם ואלד משנת 1943 על מטוסי קרב שחזרו מהקרב: המיגון היה נחוץ במקומות שבהם למטוסים ששרדו לא היו חורים, שכן המטוסים שנפגעו באותם מקומות מעולם לא חזרו כדי שניתן יהיה למדוד אותם. מאגר מניות שנבנה על בסיס הרישומים של היום הוא בעל צורה זהה. המדגם שניתן לראות הוא המדגם שהצליח "לחזור הביתה", והחברות בו נקבעת על ידי אותה תוצאה בדיוק שה-Backtest מנסה למדוד.

כמה טיקרים נעלמים מהמסחר בכל שנה?

נתחיל בגודל החור בנתונים. הלוח להלן מקבץ כל סימול בטייפ המניות היומי לפי שנת הקלנדרית של הבר האחרון שלו, תוך שמירה רק על סימולים שכבר הפסיקו להיסחר לפני ינואר 2026.

שאילתהמניות שמחקו מסחר לפי שנה
ה-SQL המדויק מאחורי כל מספר
SELECT
    toString(toYear(last_bar))  AS year,
    count()                     AS gone_dark_count,
    round(avg(bars_on_tape))    AS avg_bars_on_tape
FROM
(
    SELECT
        ticker,
        max(date) AS last_bar,
        count()   AS bars_on_tape
    FROM global_markets.stocks_daily_aggs
    WHERE date >= '2015-01-01'
      AND ticker NOT IN ('SPCX')
    GROUP BY ticker
    HAVING max(date) >= toDate('2016-01-01')
       AND max(date) <  toDate('2026-01-01')
)
GROUP BY year
ORDER BY year
Run this yourself

ב-2025, 1283 סימולים הדפיסו בר יומי אחרון ומעולם לא חזרו. אלו אינם שלדים בני שבוע: קבוצה זו הציגה ממוצע של 999 ברים יומיים מאז 2015 ואילך. לאורך ה-10 שנים בלוח, המספר משתנה, ואף שנה אינה קרובה לאפס. סימולים עוזבים מסיבות רגילות: רכישה שנסגרת, קרן שנסגרת, מיזוג שמאחד רישום אחד לאחר, או אי-עמידה בתנאי הרישום.

צפו בהטיה מופיעה בתסריט של עשר שורות

החשבון קל יותר לראות מאשר לתאר. התסריט להלן כותב שני קבצי CSV קטנים: אחד של יקום "שורדים בלבד", והשני של אותו יקום בתוספת השמות שהפסיקו להיסחר. הוא מחשב ממוצע לכל קובץ ומדפיס את ההפרש. הוא משתמש בספרייה הסטנדרטית של Python בלבד, כך שאין צורך בהתקנות.

python3 <<'PY'
import csv, statistics

survivors = [("AAA", 0.42), ("BBB", 0.18), ("CCC", 0.63), ("DDD", 0.07)]
delisted  = [("EEE", -1.00), ("FFF", -0.55), ("GGG", 0.31)]

def write_csv(path, rows):
    with open(path, "w", newline="") as f:
        w = csv.writer(f)
        w.writerow(["ticker", "total_return"])
        w.writerows(rows)

def mean_return(path):
    with open(path, newline="") as f:
        return statistics.mean(float(r["total_return"]) for r in csv.DictReader(f))

write_csv("survivors_only.csv", survivors)
write_csv("full_universe.csv", survivors + delisted)

a = mean_return("survivors_only.csv")
b = mean_return("full_universe.csv")
print(f"survivors only: {a:+.1%} across {len(survivors)} names")
print(f"full universe: {b:+.1%} across {len(survivors) + len(delisted)} names")
print(f"survivorship bias: {a - b:+.1%}")
PY

שבעת נתוני התשואה בתסריט זה מומצאים, ואינם נתוני שוק. הם קיימים כדי להפוך את המנגנון לגלוי. ממוצע ה"שורדים בלבד" הוא 32.5%+, ממוצע היקום המלא הוא 0.9%+, וההפרש של 31.6 נקודות הוא ההטיה. שום דבר בקובץ ה"שורדים בלבד" אינו שקרי. כל אחד מארבעת השמות אכן הניב את התשואה הרשומה. הקובץ פשוט לעולם אינו מזכיר את השלושה שהיו מורידים את הממוצע.

אותה מדידה על קבוצות עוקבים (Cohorts) אמיתיות

כעת נבצע את אותו חישוב על הטייפ. הלוח להלן בונה יקום התחלתי בינואר של כל שנה מ-2016 עד 2022, תוך שמירה על סימולים שפתחו את החודש במחיר הגבוה מ-5 דולר עם נפח מסחר יומי ממוצע של מעל 250,000 מניות, ואז עוקב אחרי כל אחד מהם עד לסגירה האחרונה שלו. שמות שהפסיקו להיסחר מוחזקים במחיר ההדפסה האחרון שלהם במקום להימחק. עמודת השורדים מחשבת ממוצע רק לסימולים שעדיין מדפיסים ברים כעת. עמודת היקום המלא מחשבת ממוצע לכל מי שהיה שם בהתחלה.

שאילתהממוצע שורדים לעומת ממוצע קבוצה מלאה, לפי שנת התחלה
ה-SQL המדויק מאחורי כל מספר
WITH
    entry AS
    (
        SELECT
            ticker,
            toYear(date)                   AS cohort_start,
            argMin(toFloat64(close), date) AS entry_close
        FROM global_markets.stocks_daily_aggs
        WHERE toMonth(date) = 1
          AND date BETWEEN '2016-01-01' AND '2022-01-31'
          AND ticker NOT IN ('SPCX')
        GROUP BY ticker, cohort_start
        HAVING argMin(toFloat64(close), date) >= 5
           AND avg(volume) >= 250000
    ),
    outcome AS
    (
        SELECT
            ticker,
            argMax(toFloat64(close), date) AS final_close,
            max(date)                      AS last_bar
        FROM global_markets.stocks_daily_aggs
        WHERE date >= '2016-01-01'
        GROUP BY ticker
    )
SELECT
    toString(e.cohort_start)                                                             AS cohort_year,
    count()                                                                              AS cohort_size,
    countIf(o.last_bar < today() - 45)                                                   AS gone_count,
    round(100 * avgIf(o.final_close / e.entry_close - 1, o.last_bar >= today() - 45), 1) AS survivors_only_pct,
    round(100 * avg(o.final_close / e.entry_close - 1), 1)                               AS full_universe_pct,
    round(100 * (avgIf(o.final_close / e.entry_close - 1, o.last_bar >= today() - 45)
                 - avg(o.final_close / e.entry_close - 1)), 1)                           AS gap_pct
FROM entry AS e
INNER JOIN outcome AS o ON o.ticker = e.ticker
GROUP BY e.cohort_start
HAVING countIf(o.last_bar >= today() - 45) > 0
ORDER BY e.cohort_start
Run this yourself

עבור קבוצת ה-2016, 968 מתוך 2728 שמות עזבו את הטייפ עד לזמן כתיבת דף זה. חישוב ממוצע של השורדים בלבד נותן 212%. חישוב ממוצע של כל הקבוצה נותן 149.3%. עמודת הפער מכילה את המרחק בין שתי הקריאות הללו: 62.7 נקודות עבור אותה קבוצה, ו-9.1 נקודות עבור קבוצת ה-2022, שהיה לה הרבה פחות זמן לאבד חברים. אלו תשואות כוללות על פני טווחי זמן שונים, לכן יש לקרוא את עמודת הפער במורד ולא להשוות רמות בין שורות. שאלת היקום הזה עומדת בבסיס כיצד נמדדות תשואות חודשיות ובבסיס כל ממוצע משוקלל שווה.

לאן הגיעו השמות החסרים

מחיקה מהמסחר אינה בהכרח מחיקה של הערך. הלוח להלן לוקח יקום התחלתי אחד, ינואר 2019, וממיין אותו לפי מה שקרה לאחר מכן.

שאילתהיקום המניות מינואר 2019, לפי תוצאות
ה-SQL המדויק מאחורי כל מספר
WITH
    entry AS
    (
        SELECT
            ticker,
            argMin(toFloat64(close), date) AS entry_close
        FROM global_markets.stocks_daily_aggs
        WHERE date BETWEEN '2019-01-01' AND '2019-01-31'
          AND ticker NOT IN ('SPCX')
        GROUP BY ticker
        HAVING argMin(toFloat64(close), date) >= 5
           AND avg(volume) >= 250000
    ),
    outcome AS
    (
        SELECT
            ticker,
            argMax(toFloat64(close), date) AS final_close,
            max(date)                      AS last_bar
        FROM global_markets.stocks_daily_aggs
        WHERE date >= '2019-01-01'
        GROUP BY ticker
    )
SELECT
    if(o.last_bar >= today() - 45,
       'Still trading',
       concat('Last bar in ', toString(toYear(o.last_bar))))            AS outcome_label,
    count()                                                             AS cohort_size,
    round(100 * avg(o.final_close / e.entry_close - 1), 1)              AS avg_return_pct,
    round(100 * countIf(o.final_close > e.entry_close) / count(), 1)    AS share_above_entry_pct
FROM entry AS e
INNER JOIN outcome AS o ON o.ticker = e.ticker
GROUP BY outcome_label
ORDER BY outcome_label
Run this yourself

קבוצת ה-Still trading מכילה 1954 שמות, עם ממוצע של 139.8% ממחיר הכניסה בינואר 2019, כאשר 72.1% מהם מעל המחיר שבו התחילו. קראו את שורות ה-9 והיציאות נראות מעורבות. חברה שנרכשת בפרמיה יכולה לעזוב את הטייפ בהדפסה אחרונה חזקה, בעוד שחברה שדועכת לעבר הודעת מחיקה עוזבת בהדפסה חלשה. שניהם חסרים בקובץ "שורדים בלבד". החזירו אותם והממוצע ישתנה.

הטיית שרידות מול הטיית "מבט קדימה" (Look ahead bias)

שני אלו מסווגים יחד, והם משבשים Backtest במקומות שונים. הטיית מבט קדימה היא בעיית תזמון: הבדיקה משתמשת בעובדה ברגע שבו איש עדיין לא ידע אותה, כגון נתון רווח מתוקן או התאמה שיושמה חודשים לאחר מכן. הטיית שרידות היא בעיית חברות: הרשימה שהבדיקה סוחרת היא הרשימה שקיימת כעת, לא הרשימה שהייתה קיימת אז. Backtest יכול להיות נקי לחלוטין מבחינת תזמון ועדיין לסחור ביקום שנבנה בחוכמה שלאחר מעשה. התאמות מחיר נושאות מלכודת קשורה, שכן היסטוריית מחירים מותאמת פיצולים משכתבת את הרמה של כל בר לפני תאריך הפיצול.

כיצד לתקן הטיית שרידות ב-Backtest

  1. בנה מחדש את היקום לכל תאריך. חברות "נקודת זמן" (Point in time) משמעותה שהרשימה משוחזרת ליום שבו הבדיקה מתבצעת, מתוך רשומות שהיו קיימות באותו יום. הבדיקה מחזיקה אז שמות שהיו פעילים אז, כולל אלו שנעלמו מאוחר יותר.
  2. שמור על "מאסטר ניירות ערך" (Security master). מאסטר כזה ממפה מזהים לאורך זמן: איזה סימול הצביע על איזו חברה בין אילו תאריכים, ומה החליף אותה לאחר מיזוג או שינוי שם. קבצי מחירים מבוססים על סימולים. חברות אינן כאלו.
  3. סגור שמות שנמחקו במחיר האמיתי האחרון שלהם. כאשר שם עוזב את הטייפ, הפוזיציה נסגרת במחיר ההדפסה האחרון שלו במקום להיעלם מהממוצע. מחיקה היא מה שיוצר את הפער בלוחות לעיל. Backtest ניתן לשחזור מקבע את הכלל הזה בקוד לצד היקום.
  4. קרא את התיעוד של ספק הנתונים על סימולים מתים. ספקים שמתייחסים לכך ברצינות מתעדים זאת. Massive חושפת סימולים שנמחקו דרך ה-endpoint של הטיקרים עם דגל פעילות, ומחזיקה שינויי סימולים, מחיקות, מיזוגים ורכישות כרשומות ב-endpoint נפרד של אירועי טיקרים.
"אנחנו לא משנים את הנתונים ולא משרשרים שינויי טיקרים לסדרה מצטברת אחת."
בסיס הידע של Massive, "כיצד Massive מטפלת בשינויי טיקרים ורכישות?", נקרא באוגוסט 2026.

אם התיעוד של ספק אינו אומר דבר על סימולים שנמחקו, התייחס להורדה כאל "שורדים בלבד" עד שתבדוק זאת. הכיסוי משתנה מאוד בין מקורות: ראו את ההערות שלנו על ממשקי API חינמיים לנתוני שוק מניות.

מלכודת הטיקרים הממוחזרים

סימולים חוזרים למחזור לאחר סיום רישום. הלוח להלן סופר סימולים שהדפיסו לפחות 200 ברים יומיים, השתתקו לפחות 250 ימים קלנדריים, ואז הופיעו שוב כרישום חדש מ-2022 ואילך.

שאילתהסימולים שהונפקו מחדש תחת מנפיק חדש לאחר תקופת הפסקה
ה-SQL המדויק מאחורי כל מספר
SELECT
    toString(toYear(relisted_on)) AS year,
    count()                       AS relisting_count,
    round(avg(dark_stretch))      AS avg_dark_stretch,
    max(dark_stretch)             AS longest_dark_stretch
FROM
(
    SELECT
        ticker,
        any(relist_date)                             AS relisted_on,
        dateDiff('day', max(date), any(relist_date)) AS dark_stretch
    FROM
    (
        SELECT
            d.ticker      AS ticker,
            d.date        AS date,
            i.relist_date AS relist_date
        FROM global_markets.stocks_daily_aggs AS d
        INNER JOIN
        (
            SELECT
                ticker,
                min(toDate(listing_date)) AS relist_date
            FROM global_markets.stocks_ipos
            WHERE toDate(listing_date) BETWEEN '2022-01-01' AND today()
              AND ticker NOT IN ('SPCX')
            GROUP BY ticker
        ) AS i ON i.ticker = d.ticker
        WHERE d.date < i.relist_date
    )
    GROUP BY ticker
    HAVING dateDiff('day', max(date), any(relist_date)) >= 250
       AND count() >= 200
)
GROUP BY year
ORDER BY year
Run this yourself

ב-2022, 16 סימולים חזרו בדרך זו, לאחר שתיקה ממוצעת של 3247 ימים קלנדריים. תקופת השתיקה הארוכה ביותר באותה שורה נמשכה 5734 ימים. קובץ מחירים המבוסס על הסימול בלבד מחבר את ההיסטוריה של חברה אחת לאחרת, והחיבור נראה נקי לכל אורכו. זהו המקרה שמאסטר ניירות ערך תופס, ועמודת טיקר חשופה אינה יכולה.

הערות נתונים והגדרות

סימול נחשב כנסחר עדיין כאשר הבר היומי האחרון שלו נופל בטווח של 45 ימים קלנדריים מהיום שבו נוצר דף זה. חיץ זה מכסה את העיכוב של יום עד יומיים בתחילת הטייפ ושמות דלי סחירות שמדלגים על ימי מסחר.

התשואות הן תשואות מחיר כוללות מסגירת הכניסה בינואר ועד לסגירה האחרונה הזמינה עבור אותו סימול, ללא דיבידנדים וללא שנתיות. לקבוצות מוקדמות יותר היה זמן רב יותר לצבור תשואה, לכן הרמות אינן בנות השוואה בין שורות. הפער בין שני הממוצעים הוא כן בר השוואה.

יקומי הכניסה שומרים על סימולים במחיר של 5 דולר ומעלה עם נפח מסחר יומי ממוצע של 250,000 מניות ומעלה במהלך חודש הכניסה. הטייפ היומי נושא קרנות, יחידות וכתבי אופציה לצד חברות תפעוליות, ושום דבר כאן אינו מפריד ביניהם, לכן סגירת קרן נספרת לצד מחיקה תאגידית. סימולים עם קונפליקט זהות ידוע בהזנות של ספקים מוחרגים ב-SQL מכל לוח במקום להיות מסוננים לאחר מכן.

שאלות נפוצות

מהי הטיית שרידות ב-Backtest של מניות?

זוהי השגיאה שנותרת כאשר יקום המסחר נבנה מסימולים שקיימים עדיין היום. חברות שנמחקו מהמסחר או נרכשו לעולם אינן נכנסות לבדיקה, לכן האסטרטגיה מוצעת רק לשמות שהצליחו להגיע להווה.

איך אדע אם למאגר הנתונים שלי יש הטיית שרידות?

קח את היקום שהקובץ שלך טוען לכסות עבור תאריך לפני מספר שנים ובדוק את הבר האחרון של כל סימול. אם כל שם ברשימה מ-2016 עדיין מדפיס מחירים היום, הרשימה נבנתה מרישומים נוכחיים והשמות המתים מעולם לא היו בה.

האם הטיית שרידות תמיד גורמת ל-Backtest להיראות טוב יותר?

לא. הכיוון תלוי באופן שבו השמות עזבו את הטייפ. רכישה יכולה להיסגר בפרמיה, ומחיקת שמות אלו תציג את הקבוצה בחסר, בעוד שדעיכה ארוכה לעבר הודעת מחיקה עושה את ההפך. הלוחות לעיל מודדים את הנטו של שניהם על קבוצות אמיתיות.

מהו יקום "נקודת זמן" (Point in time)?

זוהי רשימת חברות המשוחזרת לכל תאריך בבדיקה, תוך שימוש רק ברשומות שהיו קיימות באותו תאריך. היא כוללת שמות שהיו ניתנים למסחר אז ונעלמו מאוחר יותר, ומוציאה שמות שטרם נרשמו למסחר.

האם הטיית שרידות זהה להטיית "מבט קדימה"?

לא. הטיית מבט קדימה עוסקת בתזמון: הבדיקה משתמשת במידע לפני שמישהו ידע אותו. הטיית שרידות עוסקת בחברות: הבדיקה סוחרת ברשימה שהורכבה בחוכמה שלאחר מעשה. Backtest יכול להיות נקי מאחת ומשובש מהשנייה.


כל לוח כאן נושא את ה-SQL שיצר אותו. שנו את חודש הכניסה או הרפו את מסנן הנזילות, ואז הריצו זאת בטרמינל Strasmore כדי לראות כמה מהפער שורד את כללי היקום שלכם.