מדריך: בניית סוכן מחקר מניות עצמאי על שרת VPS
למדו כיצד להקים סוכן מחקר מניות בניהול עצמי על שרת VPS. המדריך כולל הגדרת הזנת נתוני שוק, אחסון ב-DuckDB, אוטומציה עם systemd וניתוח נתונים באמצעות מודל LLM.
מהו סוכן מחקר מניות בניהול עצמי (self-hosted)
סוכן מחקר מניות בניהול עצמי הוא תוכנה קטנה הפועלת על שרת שבבעלותך. התוכנה מושכת נתוני שוק לפי לוח זמנים, שומרת אותם במסד נתונים מקומי, מריצה עליהם סינון, ומבקשת ממודל שפה גדול (LLM) לכתוב סיכום של השינויים. הסוכן קורא ומסנן נתונים. הוא אינו מבצע מסחר, ושום דבר במדריך זה אינו מהווה ייעוץ פיננסי.
שני אנשים שיבנו מערכת כזו מאפס עשויים לבחור ספריות שונות, אך יגיעו לאותם ארבעה רכיבים: הזנה (feed) המספקת מחירים ונתונים פונדמנטליים, מאגר מקומי השומר כל שורה שאי פעם נמשכה, משימה (job) המרעננת את המאגר לפי טיימר, ושכבת LLM ההופכת את השורות ששרדו את הסינון למשפטים. מדריך זה בונה את המבנה הזה באמצעות Python, DuckDB, טיימר של systemd ו־API של Claude. ביצוע פעולות מסחר הוא משימה נפרדת עם אופני כשל נפרדים, והיא שייכת ל-שרת VPS שהוגדר עבור בוטים למסחר.
ארבעת החלקים ותפקידו של כל אחד מהם
ה־feed הוא החלק היחיד שמתקשר עם העולם החיצון. הוא יודע כיצד לבקש סימול (ticker) וטווח תאריכים, וכיצד להחזיר שורות נתונים. כל רכיב במורד הזרם קורא מהמסד הנתונים שלך ולא מה־feed, כך שתקלה ב־feed תעלה לך ביום אחד של נתונים חסרים במקום במסך שבור.
ה־store הוא התכלית של כל התהליך. מחיר סגירה יומי שלא תועד ניתן בדרך כלל לשליפה חוזרת במועד מאוחר יותר. לעומת זאת, ציטוט תוך-יומי, הערכה לפני שעודכנה, או נתון יסוד לפני שפורסם מחדש – לא ניתן לשחזר. ה־store הוא הדרך שלך לבנות תיעוד של מה שהנתונים אמרו בפועל ביום שבו פורסמו.
ה־scheduler מחליט מתי יתבצע הרענון. בשרת, מדובר ב־systemd timer, וזו הסיבה שה־VPS חשוב כאן יותר מהקוד עצמו.
שכבת ה־LLM קוראת בלוק טקסט קצר שהופק על ידי ה־SQL שלך, וכותבת לו סיכום. היא לעולם אינה מתחברת למסד הנתונים ולעולם אינה בונה את השאילתה. אם המודל כותב את ה־SQL, טוקן אחד שגוי הופך למספר שגוי בתוך משפט רהוט, ללא שום דבר שניתן להשוות אליו. אם ה־SQL מפיק את המספרים, המודל יכול לטעות רק בניסוח, ואתה יכול לבדוק את הניסוח מול השורות ששלחת.
מדוע להריץ זאת על VPS ולא על מחשב נייד
הסיבה היא מתזמן המשימות. סגירת המסחר בשוק האמריקאי בשעה 16:00 לפי שעון ניו יורק היא השעה 22:00 בברלין ו-04:00 בבוקר למחרת בג'קרטה. מחשב נייד נמצא במצב שינה בשני המקרים הללו. הרצה שהוחמצה עולה ביוקר יותר מהודעה מאוחרת: בדרך כלל ניתן לשלוף מחדש נתוני מסחר יומיים, אך כל מידע שעובר עדכון אינו ניתן לשחזור, ולכן החוסר ברישומים שלכם הוא קבוע. אותו היגיון תקף לכל סוכן שהלו"ז והמצב השמור שלו חייבים לשרוד אתחול, וזהו בדיוק הבסיס ל-הרצת KiroCrew כסוכן הפועל תמיד על ה-VPS שלכם.
הסיבה השנייה קטנה יותר אך עדיין ממשית. השרת מחזיק מפתח API אחד, בקובץ אחד, בבעלות משתמש מערכת אחד ללא shell לכניסה, המשמש למשימה אחת. קשה הרבה יותר לארגן זאת במחשב הנייד שבו אתם גם גולשים באינטרנט. שמרו את המפתח מחוץ לקוד ומחוץ לקלט של המודל, נושא שנדון ב-שמירה על מפתחות API מחוץ לסוכן AI.
הערכת משאבים: דיסק, זיכרון RAM וטוקנים
הדיסק הוא החלק הפשוט. עמודה יומית אחת היא שורה אחת לכל טיקר בכל יום מסחר, ושנת מסחר בארה"ב מונה כ-252 ימים.
The data behind this chart
[
{
"label": "20 tickers",
"rows_after_1y": "5,040",
"rows_after_10y": "50,400"
},
{
"label": "100 tickers",
"rows_after_1y": "25,200",
"rows_after_10y": "252,000"
},
{
"label": "500 tickers",
"rows_after_1y": "126,000",
"rows_after_10y": "1,260,000"
}
]עשרים טיקרים הם 5,040 שורות לאחר שנה. השורה 500 tickers מגיעה ל-1,260,000 שורות לאחר עשר שנים. כל שורה מורכבת מתאריך ומספר ערכי double, ו-DuckDB מאחסן עמודות בצורה דחוסה, כך שמדובר בעשרות מגה-בייטים ולא בגיגה-בייטים. אל תסתמכו על הערכה זו, כולל שלי. הריצו את du -h /opt/research/data/market.duckdb לאחר ה-backfill הראשון שלכם והשתמשו במספרים שלכם.
זיכרון ה-RAM הוא המקום שבו שרת VPS קטן עלול להיתקל בקשיים. כברירת מחדל, DuckDB צורך חלק גדול מהזיכרון של המכונה ואת כל הליבות שלה עבור שאילתה בודדת; זה תקין בשרת אנליטיקה, אך שגוי בשרת עם 2 GB זיכרון שמריץ גם דברים אחרים. אגרגציה אחת על כל טבלת המחירים עלולה להוביל להריגת התהליך על ידי ה-kernel, כאשר systemd מדווח על Main process exited, code=killed, status=9/KILL ו-journalctl -k מציג את ה-out of memory kill. הגדירו את memory_limit ואת threads במפורש, והשאילתה תרוץ לאט יותר במקום לקרוס.
יש למדוד טוקנים, לא להעריך אותם. כל תגובה מ-Messages API נושאת אובייקט usage עם input_tokens ו-output_tokens. כתבו את שניהם לטבלה בכל קריאה, ולאחר שבוע תדעו את הנפח האמיתי שלכם, אותו תכפילו במחיר המודל ביום הבדיקה. שני דברים יציבים מספיק כדי לתכנן לפיהם: טוקני פלט מתומחרים גבוה יותר מטוקני קלט בכל מודל Claude, לכן הגבלת הערה ל-200 מילים תשפיע על החשבון יותר מאשר צמצום הנתונים שאתם שולחים. בנוסף, prompt caching לא עוזר למשימה שמתבצעת פעם ביום, כיוון שזמן החיים של ה-cache נמדד בדקות: עד להרצה הבאה הבלוק השמור יפוג ותשלמו שוב את מחיר הקלט המלא. ה-cache משתלם כאשר הרצה אחת מבצעת קריאות רבות על אותו בלוק טקסט גדול.
התקנת הרכיבים
sudo apt update
sudo apt install -y python3-venv
sudo useradd --system --create-home --home-dir /opt/research --shell /usr/sbin/nologin research
sudo -u research python3 -m venv /opt/research/venv
sudo -u research /opt/research/venv/bin/pip install duckdb pandas yfinance anthropic
sudo install -d -o research -g research -m 750 /opt/research/dataבדקו את ההתקנה לפני כתיבת קוד כלשהו:
sudo -u research /opt/research/venv/bin/python -c 'import duckdb, yfinance, anthropic; print("ok")'פקודה זו מדפיסה ok. אם דילגתם על סביבה וירטואלית והרצתם את pip install מול ה-Python של המערכת, Ubuntu 24.04 תחסום אתכם עם error: externally-managed-environment, כיוון שההפצה היא הבעלים של /usr/lib/python3 והיא מסרבת לאפשר ל-pip לכתוב שם. ה-venv אינו עניין של נימוס. זוהי הספרייה היחידה ש-pip מורשה לגעת בה.
יש לשמור את ה-API key בקובץ שמשתמש השירות יכול לקרוא, ואף אחד אחר לא:
sudo install -d -m 755 /etc/research
sudo install -m 640 -o root -g research /dev/null /etc/research/env
sudoedit /etc/research/envהכניסו לתוכו שורה אחת, ללא מרכאות וללא export, כיוון ש-systemd מנתח את הקובץ בעצמו במקום להעביר אותו ל-shell:
ANTHROPIC_API_KEY=sk-ant-your-key-hereהמאגר: שני טבלאות
# /opt/research/store.py
import duckdb
DB = '/opt/research/data/market.duckdb'
SCHEMA = [
"""
CREATE TABLE IF NOT EXISTS prices (
ticker VARCHAR,
day DATE,
open DOUBLE,
high DOUBLE,
low DOUBLE,
close DOUBLE,
volume BIGINT,
PRIMARY KEY (ticker, day)
)
""",
"""
CREATE TABLE IF NOT EXISTS runs (
started_at TIMESTAMPTZ,
model VARCHAR,
input_tokens BIGINT,
output_tokens BIGINT,
hits BIGINT
)
""",
]
def connect(read_only=False):
con = duckdb.connect(DB, read_only=read_only)
con.execute("SET memory_limit='512MB'")
con.execute('SET threads=2')
if not read_only:
for statement in SCHEMA:
con.execute(statement)
return conהמפתח הראשי ב-(ticker, day) הוא מה שהופך את הרענון לבטוח לביצוע חוזר. INSERT OR REPLACE דורס שורה שקיימת כבר עבור אותו ticker ואותו יום, כך שהרצת ה-backfill פעמיים אינה מכפילה את הטבלה. ללא המפתח, הרצה חוזרת לאחר קריסה תשכפל בשקט כל bar, וכל ממוצע שתחשב לאחר מכן יהיה שגוי ללא הודעת שגיאה שתתריע על כך.
DuckDB מאפשר לתהליך אחד בלבד להחזיק את הקובץ פתוח לכתיבה. כותב שני נכשל מיד עם Could not set lock on file, ולאחריו ה-PID שמחזיק את הקובץ, שבפועל הוא ה-shell האינטראקטיבי duckdb שהשארת פתוח בטרמינל אחר. קוראים עוברים ב-read_only=True, וזו הסיבה ש-connect מקבל את ה-flag. אם כמה תהליכים צריכים באמת לכתוב באותו רגע, זו משימה עבור מנוע אחר: SQLite במצב WAL מאפשר לקוראים לעבוד בזמן שכותב אחד מבצע commit, ו-busy timeout גורם לכותבים אחרים להמתין במקום להיכשל. DuckDB מול SQLite עבור עומס עבודה של שרת משווה בין השניים, ו-הרצת SQLite בסביבת production על גבי VPS מכסה את ההגדרות שגורמות ל-WAL לתפקד כראוי.
משימת הרענון
# /opt/research/refresh.py
import sys
import pandas as pd
import yfinance as yf
from store import connect
TICKERS = ['AAPL', 'MSFT', 'KO', 'SAP', 'TSM']
FIRST_DAY = '2016-01-01'
COLS = ['ticker', 'day', 'open', 'high', 'low', 'close', 'volume']
def fetch(ticker, start):
df = yf.Ticker(ticker).history(start=start, auto_adjust=True)
if df.empty:
return None
df = df.reset_index()
stamps = pd.to_datetime(df['Date'])
if stamps.dt.tz is not None:
stamps = stamps.dt.tz_localize(None)
df['day'] = stamps.dt.date
df['ticker'] = ticker
df = df.rename(columns={'Open': 'open', 'High': 'high', 'Low': 'low',
'Close': 'close', 'Volume': 'volume'})
return df[COLS]
def main():
con = connect()
empty = 0
for ticker in TICKERS:
last = con.execute('SELECT max(day) FROM prices WHERE ticker = ?',
[ticker]).fetchone()[0]
rows_df = fetch(ticker, str(last) if last else FIRST_DAY)
if rows_df is None:
print(ticker + ': feed returned no rows', file=sys.stderr)
empty += 1
continue
con.register('rows_df', rows_df)
con.execute('INSERT OR REPLACE INTO prices '
'SELECT ticker, day, open, high, low, close, volume FROM rows_df')
print(ticker + ': ' + str(len(rows_df)) + ' rows')
con.close()
if empty == len(TICKERS):
print('every ticker returned nothing: the feed is broken', file=sys.stderr)
sys.exit(1)
main()הריצו אותה ידנית פעם אחת:
sudo -u research /opt/research/venv/bin/python /opt/research/refresh.pyההרצה הראשונה מבצעת מילוי לאחור של שנים ומדפיסה שורה אחת לכל ticker עם ספירה של אלפים. הריצו אותה שוב דקה לאחר מכן, וכל שורה תציג 1 או 2 שורות, כיוון שהמשימה מתחילה מהיום האחרון שכבר מאוחסן. ההרצה השנייה הזו היא המבחן האמיתי: אם הספירות עדיין באלפים, max(day) לא מחזירה דבר והפעולה insert כותבת מחדש את כל ההיסטוריה שלכם בכל לילה.
הבדיקה df.empty היא השורה החשובה ביותר בקובץ. Ticker.history() לא מעלה שגיאה עבור סמל שגוי או כזה שנמחק מהמסחר. היא מדפיסה אזהרה על כך שהסמל ייתכן שנמחק ללא נתוני מחיר שנמצאו (הניסוח המדויק משתנה בין גרסאות ספריות) ומחזירה DataFrame ריק. משימה ללא הבדיקה הזו לא כותבת דבר, מסתיימת עם קוד יציאה 0, ו-systemd מציג הרצה תקינה בירוק בזמן שהטבלה מפסיקה לגדול בשקט. אתם תגלו זאת שבועות לאחר מכן, ממסך שמחזיר את אותן השורות בכל יום.
גם לטיפול ב-timestamp יש סיבה. האינדקס שה-feed מחזיר יכול לשאת את אזור הזמן של הבורסה, והסרת ה-offset אינה זהה להמרה ל-UTC. סשן בטוקיו שמתויג בחצות לפי שעון מקומי הופך ליום הקלנדרי הקודם ב-UTC, לכן המרה ל-UTC מזיזה בשקט כל נר יפני יום אחד אחורה ושוברת את ה-primary key. tz_localize(None) שומרת על תאריך הסשן המקורי של הבורסה, שזהו המשמעות של נר יומי.
הטיימר שמופעל בסגירת המסחר
המסחר בארה"ב נסגר בשעה 16:00 לפי שעון ניו יורק, והדפסות הנתונים האחרונות דורשות מספר דקות כדי להתעדכן, לכן המשימה מוגדרת לרוץ ב-16:20. יש להגדיר זאת לפי שעון ניו יורק, ולא לפי UTC. ניו יורק נמצאת ב-UTC מינוס 5 בחורף וב-UTC מינוס 4 בקיץ, כך שטיימר שמוגדר לפי שעה קבועה ב-UTC יסטה בשעה פעמיים בשנה ויתחיל לפעול לפני סגירת המסחר. גרסה 252 של systemd ומעלה תומכת בהגדרת אזור זמן ישירות ב-OnCalendar, וגרסת Ubuntu 24.04 כוללת את גרסה 255. ודאו את הגרסה שלכם באמצעות systemctl --version.
# /etc/systemd/system/research-refresh.service
[Unit]
Description=Refresh market data and run the daily screen
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=research
Group=research
WorkingDirectory=/opt/research
EnvironmentFile=/etc/research/env
ExecStart=/opt/research/venv/bin/python /opt/research/refresh.py
ExecStart=/opt/research/venv/bin/python /opt/research/screen.py# /etc/systemd/system/research-refresh.timer
[Unit]
Description=Run the refresh after the US market close
[Timer]
OnCalendar=Mon-Fri 16:20 America/New_York
Persistent=true
RandomizedDelaySec=180
[Install]
WantedBy=timers.targetType=oneshot הוא סוג השירות היחיד שמקבל יותר מ-ExecStart אחד, והוא מריץ אותם לפי הסדר, תוך עצירה אם אחד מהם מסתיים עם קוד שגיאה שאינו אפס. זו בדיוק ההתנהגות הרצויה: רענון שנכשל לא אמור להיות מלווה בהצגת נתונים לא מעודכנים. Persistent=true חשוב בשרת VPS שמבצע אתחול עבור עדכוני ליבה. ללא הגדרה זו, אתחול ב-16:15 יגרום לאובדן הריצה; עם הגדרה זו, המשימה תרוץ מיד כשהשרת יחזור לפעילות.
sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timerlist-timers אמור להציג עמודת NEXT המכילה את מועד הריצה הבא ביום חול, מומר לשעון המקומי של השרת. רשימה ריקה מציינת שהטיימר אינו מופעל, או שחסר ביחידה סעיף [Install], ולכן ל-enable לא היה מה לקשר לתוך timers.target.
המסך: תחילה SQL, לבסוף המודל
SQL הוא מדויק ואינו כרוך בעלות לכל הרצה. המודל אינו אף אחד מאלה. לכן, השאילתה מצמצמת את מרחב הנתונים, ורק השורות ששרדו את הסינון נשלחות למודל. שמרו זאת כ-/opt/research/screen.sql.
WITH ma AS (
SELECT ticker, day, close,
avg(close) OVER w20 AS ma20,
avg(close) OVER w50 AS ma50,
row_number() OVER (PARTITION BY ticker ORDER BY day) AS n
FROM prices
WINDOW
w20 AS (PARTITION BY ticker ORDER BY day ROWS BETWEEN 19 PRECEDING AND CURRENT ROW),
w50 AS (PARTITION BY ticker ORDER BY day ROWS BETWEEN 49 PRECEDING AND CURRENT ROW)
)
SELECT ticker, day, close, round(ma20, 2) AS ma20, round(ma50, 2) AS ma50
FROM ma
WHERE n > 50 AND ma20 > ma50
ORDER BY day DESC, ticker
LIMIT 20;הפילטר n > 50 אינו קישוט. ROWS BETWEEN 49 PRECEDING AND CURRENT ROW מחשב ממוצע לכל שורה קיימת, לכן השורה השלישית של טיקר מחזירה ממוצע של שלושה ימים ועדיין מכנה זאת ma50. אם תשוו זאת ל-ma20, אתם עלולים להמציא הצלבה (crossover) בתחילת ההיסטוריה של כל טיקר, אירוע שמעולם לא התרחש. סינון לפי מספר השורה מסיר את השורות שבהן חלון הזמן לא היה מלא.
מה המודל רואה, ומה הוא לעולם לא רואה
# /opt/research/screen.py
from anthropic import Anthropic
from store import connect
SYSTEM = (
'You are a research assistant. Use only the rows in the message. '
'If a number is not in the rows, say that it is not available. '
'Do not give investment advice, price targets or buy and sell calls. '
'Write at most 200 words.'
)
con = connect()
sql = open('/opt/research/screen.sql').read()
rows = con.execute(sql).fetchall()
block = '\n'.join(' / '.join(str(v) for v in row) for row in rows)
client = Anthropic(max_retries=5) # reads ANTHROPIC_API_KEY from the environment
resp = client.messages.create(
model='claude-sonnet-5',
max_tokens=600,
system=SYSTEM,
messages=[{'role': 'user', 'content': 'Screen hits, ticker / day / close / ma20 / ma50:\n' + block}],
)
print(resp.content[0].text)
con.execute('INSERT INTO runs VALUES (now(), ?, ?, ?, ?)',
['claude-sonnet-5', resp.usage.input_tokens,
resp.usage.output_tokens, len(rows)])
con.close()מגבלת המילים ב־system prompt מגבירה את היעילות הכלכלית של השימוש במודל. ההנחיה להשתמש אך ורק בשורות שסופקו היא הנחיה שעליך לאמת במקום להסתמך עליה: מחק עמודה אחת מהבלוק, הרץ את הבדיקה שוב, וקרא את הפלט. אם נתון עבור אותה עמודה עדיין מופיע, המודל השלים את החסר בעצמו, וה־prompt שלך אינו הדוק מספיק. בדיקה זו אורכת שתי דקות והיא הדרך האמינה היחידה לוודא זאת.
המודל לעולם אינו רואה את ה־API key, לעולם אינו רואה את ה־database path, ולעולם אינו מריץ שאילתות. הוא מקבל שורות ומחזיר טקסט. הגבול הזה הוא מה שהופך את הפלט לניתן לבדיקה, כיוון שכל מספר בסיכום אמור להופיע גם בבלוק ששלחת, ואתה יכול להשוות ביניהם שורה אחר שורה. למידע נוסף על הצד של ה־prompting, המאמר שימוש ב־Claude לניתוח פיננסי מרחיב על היכולות של המודל בקריאת נתונים.
הרצה אחת, מקצה לקצה
בשעה 16:20 לפי שעון ניו יורק, הטיימר מפעיל את השירות. refresh.py פונה לערוץ הנתונים עבור כל טיקר החל מהיום האחרון שנשמר, כותב בר אחד או שניים חדשים עבור כל אחד, ומדפיס שורה לכל טיקר. screen.py פותח את אותו הקובץ, מריץ את שאילתת הממוצע הנע, ומקבל בחזרה מספר שורות. שורות אלו הופכות לבלוק טקסט של כמה מאות טוקנים. קריאת API אחת הופכת אותן להערה קצרה, ההערה עוברת ליומן, ושורה אחת נרשמת ב-runs עם ספירת הטוקנים ומספר ההתאמות.
journalctl -u research-refresh.service -n 50 --no-pagerלוג תקין מכיל שורה אחת לכל טיקר, לאחר מכן את ההערה, ולבסוף את research-refresh.service: Deactivated successfully. לאחר שבוע, קראו את העלות שלכם מתוך מסד הנתונים:
SELECT count(*) AS runs,
sum(input_tokens) AS in_tokens,
sum(output_tokens) AS out_tokens
FROM runs;הכפילו את הסכומים הללו במחיר למיליון טוקנים שמופיע במחירון המודל שלכם ביום שבו אתם מבצעים את הבדיקה. כך תקבלו את הנתון האמיתי במקום הערכות של אחרים. מחירים מפורסמים משתנים. החשבון עצמו לא.
מדוע Backtest סובל מ-overfitting וכיצד לזהות זאת
הגדירו את ה-screen כפונקציה של שני אורכי חלון, בצעו סריקה (sweep) על רשת של זוגות, ודרגו אותם לפי תשואה. הזוג הטוב ביותר ייראה מצוין. זו הבעיה, לא התוצאה. רשת של 200 זוגות היא 200 ניסויים, ואתם בחרתם את זה שהיה לו הכי הרבה מזל.
ניתן לראות זאת קורה תוך עשר דקות. חלקו את מאגר הנתונים לשניים לפי תאריך. בצעו את הסריקה על החצי הראשון בלבד ורשמו את המנצח. בצעו את אותה סריקה על החצי השני. אם שני המנצחים רחוקים זה מזה, הפרמטרים מתאימים את עצמם לרעש, וזוג שמנצח רק בחצי שעליו ביצעתם אופטימיזציה לא מעיד דבר על המחר.
Survivorship bias גרוע יותר מ-overfitting כיוון שאופטימיזציה לא יכולה לתקן אותו. רשימת ה-ticker שלכם מבוססת על חברי המדד של היום, לכן היא מכילה רק את החברות ששרדו. אם תבקשו מה-feed נתונים על ticker שנמחק מהמסחר ב-2019, תקבלו מסגרת ריקה, מה שאומר שהחברה הזו מעולם לא נכנסה למאגר שלכם ומעולם לא נכנסה לבדיקה שלכם. כל Backtest שאתם מריצים כבר הוציא מהחישוב את הכשלונות.
Restated fundamentals משבשים את ציר הזמן. נתון ההכנסות שה-API מחזיר היום עבור רבעון ב-2019 אינו תמיד הנתון שפורסם ב-2019. Screen שמשלב נתונים פונדמנטליים של היום עם מחירים מ-2019 משתמש במידע שלא היה קיים אז. מחירים הם בדרך כלל בטוחים לשימוש בהקשר זה. נתונים פונדמנטליים בדרך כלל לא.
Adjusted prices משתנים מתחת לרגליכם. עם auto_adjust=True מחירי הסגירה מותאמים לאחור עבור דיבידנדים ופיצולי מניות, לכן אותה שאילתה שתורץ בחודש הבא תחזיר היסטוריה שונה במקצת. שמירת השורות שבהן השתמשתם בפועל היא מה שהופך תוצאה לניתנת לשחזור, וזו סיבה נוספת לקיומו של המאגר המקומי.
ה-Backtest גם מתעלם מעמלות ומ-slippage, ומניח שהפקודה שלכם אינה מזיזה את המחיר. אלו שייכים לשלב הביצוע (execution), שנמצא מחוץ להיקף הנושא כאן ומכוסה ב-הרצת בוטים למסחר על גבי VPS.
מצבי כשל והודעות שתראה
error: externally-managed-environment בזמן הרצת pip. אתה נמצא מחוץ לסביבה הווירטואלית. קרא ל-/opt/research/venv/bin/pip באמצעות הנתיב המלא.
Could not set lock on file, עם PID אחריו. תהליך אחר מחזיק את קובץ ה-DuckDB פתוח לכתיבה, בדרך כלל shell אינטראקטיבי ששכחת. סגור אותו, או פתח את החיבור השני עם read_only=True.
Main process exited, code=killed, status=9/KILL בתוך systemctl status. ה-kernel הרג את המשימה בגלל חוסר בזיכרון. אשר זאת עם journalctl -k | grep -i oom, ולאחר מכן הנמך את memory_limit בתוך store.py.
הרצה ירוקה שלא כותבת דבר. systemctl status קורא את active (exited) והטבלה לא גדלה. ה-feed החזיר מסגרות ריקות. כשל זה הוא הסמוי ביותר, לכן הגדר את המשימה לצאת עם קוד שגיאה (non-zero) כאשר כל ticker חוזר ריק.
הטיימר הופעל ביום חופשה של הבורסה. systemd לא מכיר את לוח השנה של הבורסה, לכן Mon-Fri כולל ימי חופשה. ההרצה מתבצעת, ל-feed אין נתונים חדשים, והמשימה צריכה להתייחס לכך כאל מצב תקין ולא כאל שגיאה.
429 מה-API. חרגת ממכסת הקריאות (rate limit). ה-SDK של Anthropic מבצע ניסיונות חוזרים עם backoff באופן עצמאי, ו-Anthropic(max_retries=5) מעלה את מונה הניסיונות. אם השגיאה ממשיכה להופיע מדי יום, המשימה מבקשת יותר מדי נתונים בבת אחת.
מה זה לא
זהו עוזר מחקר. מודל שמסכם מסמך מפיק קריאה של אותו מסמך, והוא עלול לטעות בביטחון במספר המופיע בטקסט; לכן, כל נתון בסיכום חייב להיות ניתן לאימות מול שורה ששלחת. התייחסו לפלט כאל רשימה מצומצמת של דברים שעליכם לקרוא בעצמכם. אין כאן ייעוץ פיננסי, ודבר מזה אינו מהווה איתות מסחר.
בדיקות לאחור (backtests) הן כלי שימושי לפסילת רעיונות, אך הן חלשות באישורם. אסטרטגיה שנכשלת על הנתונים שלכם היא אכן מתה. אסטרטגיה שעוברת את הבדיקה רק שרדה את הנתונים שלכם, וזו טענה צנועה בהרבה מכפי שהיא מרגישה בשעה 1 בלילה.
ביצוע פקודות נשאר במכוון מחוץ להיקף המדריך. פקודות ופרטי גישה לברוקר נושאים פרופיל סיכון שונה מזה של סביבת מחקר לקריאה בלבד, וערבוב ביניהם מציב מפתחות מסחר על אותה מכונה שבה רץ ה-prompt של ה-LLM. אם ברצונכם לראות היכן תבנית זו משתלבת לצד דברים אחרים שכדאי להריץ על השרת שלכם, סוכני ה-AI בניהול עצמי שכדאי להריץ הוא הסקירה המקיפה יותר.
FAQ
האם אני זקוק לעדכון נתוני שוק בתשלום?
לא עבור אב-טיפוס. עדכון לא רשמי בחינם מספיק כדי ללמוד את מבנה המערכת, אך הוא צפוי להשתבש, כיוון שהוא תלוי באתר אינטרנט שאינו מחויב לך בדבר. השיבוש מתבטא בדרך כלל במסגרות נתונים ריקות ולא בחריגה (exception), לכן על המשימה שלך לבדוק את מספר השורות. עברו לעדכון בתשלום עם API מתועד וכתובת לתמיכה ברגע שהנתונים מתחילים להזין החלטות. ה-store הוא מה שהופך את ההחלפה הזו לזולה: רק פונקציית ה-fetch משתנה, בעוד ה-schedule, ה-schema וה-screen נשארים כפי שהם.
האם עליי לשמור מחירים ב-SQLite או ב-DuckDB?
DuckDB הוא מסד נתונים עמודתי (columnar) הבנוי לסריקת שורות רבות לצורך חישוב אגרגציות, וזה בדיוק מה שנדרש עבור ממוצע נע על פני עשר שנים של נתוני מסחר. SQLite מבוסס על שורות ומתאים יותר לקריאות וכתיבות קטנות רבות ממספר תהליכים במקביל. עבור משימה מתוזמנת בודדת שמוסיפה כמה מאות שורות ואז סורקת מיליונים, DuckDB הוא הבחירה המתאימה יותר. אם מספר תהליכים חייבים לכתוב בו-זמנית, SQLite במצב WAL מאפשר לקוראים לעבוד בזמן שכותב אחד מבצע commit, ושימוש ב-busy timeout גורם לכותבים אחרים להמתין במקום להיכשל מיד.
כמה עולות קריאות LLM בחודש?
תעדו את usage.input_tokens ואת usage.output_tokens מכל תגובה לתוך טבלה, ולאחר מכן הכפילו את הסיכומים השבועיים שלכם במחיר למיליון טוקנים שמופיע במחירון המודל שלכם ביום הבדיקה. זהו הנתון היחיד שיישאר רלוונטי ברבעון הבא. הרצה יומית אחת על פני screen קצר דורשת מספר קטן של קריאות, ואורך ההערה הוא החלק שאתם שולטים בו: הגבלת הסיכום ל-200 מילים חוסכת יותר מאשר שליחת פחות שורות, כיוון שטוקני פלט מתומחרים גבוה יותר מטוקני קלט בכל מודל Claude.
מדוע המשימה שלי הצליחה אך לא נכתבו שורות חדשות?
יש לכך שתי סיבות נפוצות. השוק היה סגור, כיוון ש-schedule של systemd מסוג Mon-Fri כולל חופשות של הבורסה. לחלופין, העדכון החזיר מסגרת נתונים ריקה עבור כל ticker, דבר שספריות לקוח מדווחות עליו לעיתים קרובות כאזהרה מודפסת ולא כחריגה, כך שהתהליך מסתיים בקוד 0 ו-systemd מציג הרצה תקינה. הבדילו ביניהם על ידי השוואת SELECT max(day) FROM prices מול יום המסחר האמיתי האחרון, וגרמו למשימה להסתיים בקוד שגיאה (non-zero) כאשר כל ה-tickers חוזרים ריקים.
האם הסוכן יכול להחליט מה לקנות?
לא, ובניית סוכן שמנסה לעשות זאת היא הנקודה שבה פרויקטים כאלה משתבשים. למודל אין גישה לשוק, אין תצוגה של המיקום שלכם או המצב המיסויי, ואין דרך לבדוק את המספרים של עצמו מול שום גורם. מה שהוא עושה היטב הוא קריאת כמות גדולה של טקסט והצבעה על הפריטים הבודדים שראויים לתשומת ליבכם היום. שום דבר שהוא כותב אינו מהווה ייעוץ פיננסי, וההחלטה, יחד עם האחריות עליה, נשארת שלכם.