SSD Nodes Learn 🎉 VPS من $4.99/شهر
الأدلة Matt Connorبقلم Matt Connor

بناء وكيل أبحاث أسهم ذاتي الاستضافة على VPS

أنشئ وكيل أبحاث أسهم على VPS باستخدام مصدر بيانات السوق وDuckDB ومؤقت systemd عند إغلاق السوق، ثم استخدم Claude API لتصفية النتائج وكتابة ملخص.

ما هو وكيل أبحاث الأسهم المستضاف ذاتياً

وكيل أبحاث الأسهم المستضاف ذاتياً هو برنامج صغير يعمل على خادم تملكه. يجلب بيانات السوق وفق جدول زمني، ويحفظها في قاعدة بيانات محلية، ويفلترها، ثم يطلب من نموذج لغوي كبير (LLM) كتابة ملخص لما تغيّر. يقرأ البيانات ويفلترها. لا ينفّذ صفقات، ولا يشكّل أي جزء من هذا الدليل نصيحة مالية.

قد يختار شخصان يبنيان هذا النظام من الصفر مكتبات مختلفة، لكنهما سينتهيان بالبنية نفسها المكوّنة من أربعة أجزاء: مصدر يوفّر الأسعار والأساسيات، ومخزن محلي يحتفظ بكل صف جرى جلبه، ومهمة تحدّث المخزن وفق مؤقت، وطبقة LLM تحوّل الصفوف المتبقية إلى جمل. يبني هذا الدليل هذه البنية باستخدام Python وDuckDB ومؤقت systemd وواجهة Claude API (واجهة برمجة التطبيقات). التنفيذ مهمة منفصلة لها حالات فشل منفصلة، ولذلك يجب وضعه على VPS مُعدّ لروبوتات التداول بدلاً من ذلك.

الأجزاء الأربعة، ووظيفة كل جزء

مكوّن جلب البيانات هو الجزء الوحيد الذي يتصل بالعالم الخارجي. يعرف كيفية طلب رمز تداول ونطاق زمني، وكيفية إعادة الصفوف. تقرأ جميع المكوّنات اللاحقة قاعدة بياناتك بدلاً من مكوّن الجلب، لذلك يؤدي تعطل مكوّن الجلب إلى فقدان يوم واحد من البيانات الجديدة، بدلاً من تعطّل الواجهة.

مخزن البيانات هو الغرض الأساسي من العملية كلها. يمكن عادةً جلب سعر الإغلاق اليومي الذي لم تسجله مرة أخرى لاحقاً. لكن لا يمكن استعادة عرض سعر لحظي، أو تقدير قبل تعديله، أو قيمة من البيانات الأساسية قبل إعادة بيانها. يتيح لك مخزن البيانات إنشاء سجل لما ذكرته البيانات فعلياً في اليوم الذي ذكرته فيه.

المجدوِل يحدد موعد تنفيذ التحديث. على الخادم، يكون ذلك مؤقت systemd، ولهذا يكون الـVPS أهم هنا من الشيفرة.

طبقة LLM تقرأ كتلة نصية قصيرة أنتجتها SQL، ثم تكتب ملخصاً لها. لا تتصل هذه الطبقة بقاعدة البيانات، ولا تنشئ الاستعلام. إذا كتب النموذج SQL، فقد يتحول رمز واحد خاطئ إلى رقم خاطئ داخل جملة سلسة، من دون شيء تقارنه به. أما إذا أنتجت SQL الأرقام، فلا يمكن للنموذج إلا أن يخطئ في الصياغة، ويمكنك مقارنة الصياغة بالصفوف التي أرسلتها.

لماذا تشغّله على VPS بدلاً من حاسوب محمول

الجدولة هي السبب الرئيسي. إغلاق السوق الأمريكية عند الساعة 16:00 بتوقيت نيويورك يوافق الساعة 22:00 في برلين والساعة 04:00 من صباح اليوم التالي في جاكرتا. يكون الحاسوب المحمول في وضع السكون في كلتا الحالتين. تفويت التشغيل يسبب خسارة أكبر من تأخر الملاحظة: يمكن عادةً جلب البيانات اليومية لاحقاً، لكن لا يمكن جلب البيانات التي خضعت للمراجعة، ولذلك تصبح الفجوة في سجلك دائمة.

السبب الثاني أقل أهمية، لكنه حقيقي أيضاً. يحتفظ الخادم بمفتاح API واحد في ملف واحد، يملكه مستخدم نظام واحد بلا shell لتسجيل الدخول، وتستخدمه مهمة واحدة. يصعب ترتيب ذلك بكثير على الحاسوب المحمول الذي تستخدمه أيضاً لتصفح الويب. أبقِ المفتاح خارج الشفرة وخارج مُدخلات النموذج، وهذا هو موضوع إبقاء مفاتيح API خارج وكيل ذكاء اصطناعي.

تحديد الحجم: القرص وذاكرة RAM والرموز

القرص هو الجزء الأسهل. يمثل كل شريط يومي صفاً واحداً لكل مؤشر ولكل يوم تداول، ويضم عام التداول في الولايات المتحدة نحو 252 يوماً.

ChartRows in the prices table by watchlist size, at 252 trading days a year
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 صفاً بعد 10 أعوام. يحتوي كل صف على تاريخ وعدد قليل من القيم العشرية، ويخزّن 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 حدوث الإنهاء بسبب نفاد الذاكرة. عيّن memory_limit وthreads صراحةً، وعندها يصبح الاستعلام أبطأ بدلاً من أن يتوقف.

يجب قياس الرموز بدلاً من تقديرها. يحتوي كل رد من Messages API على كائن usage يتضمن input_tokens وoutput_tokens. اكتب القيمتين في جدول عند كل استدعاء. بعد أسبوع ستعرف حجم الاستخدام الفعلي، ويمكنك ضربه في السعر الذي يعلنه النموذج في يوم التحقق. توجد نقطتان ثابتتان بما يكفي للتخطيط. تُسعَّر رموز الإخراج بأكثر من رموز الإدخال في كل نموذج من Claude، لذلك يؤدي تحديد الملاحظة بـ200 كلمة إلى خفض الفاتورة أكثر من تقليص البيانات التي ترسلها. كما لا يفيد prompt caching في مهمة تُنفَّذ مرة يومياً، لأن مدة التخزين المؤقت تُقاس بالدقائق؛ وبحلول التشغيل التالي تكون الكتلة المخزنة مؤقتاً قد انتهت صلاحيتها، فتدفع سعر الإدخال الكامل مرة أخرى. يفيد التخزين المؤقت عندما ينفّذ تشغيل واحد عدة استدعاءات على كتلة النص الكبيرة نفسها.

تثبيت المكوّنات

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 بالكتابة فيه. البيئة الافتراضية ليست إجراءً شكلياً. فهي الدليل الوحيد الذي يُسمح لـ pip بالكتابة فيه.

ضع مفتاح API في ملف يستطيع مستخدم الخدمة قراءته ولا يستطيع أي مستخدم آخر قراءته:

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 صفاً موجوداً مسبقاً لهذا السهم ولهذا اليوم، لذلك لا يؤدي تشغيل عملية الملء السابقة مرتين إلى مضاعفة الصفوف في الجدول. من دون هذا المفتاح، تؤدي إعادة التشغيل بعد التعطل إلى تكرار كل شمعة بصمت، وتصبح كل قيمة متوسطة تحسبها بعد ذلك خاطئة، من دون ظهور رسالة خطأ تنبّهك إلى المشكلة.

يسمح DuckDB لعملية واحدة فقط بفتح الملف للكتابة. تفشل أي عملية كتابة ثانية فوراً مع Could not set lock on file، ويتبعها PID الذي يحتفظ بالملف مفتوحاً. ويكون هذا عملياً عادةً shell التفاعلي duckdb الذي تركته مفتوحاً في طرفية أخرى. يمكن لعمليات القراءة المتزامنة أن تستمر عبر read_only=True، ولذلك تستخدم connect هذا الخيار. إذا احتاجت عدة عمليات فعلاً إلى الكتابة في الوقت نفسه، فهذه مهمة لمحرك مختلف: يتيح SQLite في وضع WAL للقرّاء العمل أثناء تنفيذ عملية كتابة واحدة، كما يجعل مهلة الانتظار لكون قاعدة البيانات مشغولة عمليات الكتابة الأخرى تنتظر بدلاً من فشلها. تقارن DuckDB في مقابل SQLite لأحمال الخادم بين المحركين، وتشرح تشغيل SQLite في بيئة الإنتاج على 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

يعيد التشغيل الأول ملء بيانات سنوات، ويطبع سطراً واحداً لكل رمز مع عدد بالآلاف. شغّلها مرة أخرى بعد دقيقة، وستعرض الأسطر صفاً أو صفين، لأن المهمة تبدأ من آخر يوم مخزّن مسبقاً. هذا التشغيل الثاني هو الاختبار الفعلي: إذا ظلت الأعداد بالآلاف، فإن max(day) لا يعيد أي بيانات، وتعيد عملية الإدراج كتابة كامل السجل التاريخي كل ليلة.

يُعد فحص df.empty أهم سطر في الملف. لا يرفع Ticker.history() استثناءً عند استخدام رمز خاطئ أو رمز أُزيل من التداول. بل يطبع تحذيراً يفيد باحتمال إزالة الرمز من التداول مع عدم العثور على بيانات أسعار، وقد تختلف الصياغة الدقيقة بين إصدارات المكتبة، ثم يعيد DataFrame فارغاً. تكتب المهمة، من دون هذا الفحص، أي شيء، وتنتهي برمز خروج 0، ويعرض systemd تشغيلًا ناجحاً باللون الأخضر، بينما يتوقف الجدول بهدوء عن النمو. وستكتشف ذلك بعد أسابيع، عندما تعرض الشاشة الصفوف نفسها كل يوم.

ولمعالجة الطابع الزمني سبب مهم أيضاً. قد يحمل الفهرس الذي يعيده مصدر البيانات المنطقة الزمنية للبورصة، وإزالة الإزاحة ليست مماثلة للتحويل إلى UTC. تتحول جلسة طوكيو المسجّلة عند منتصف الليل بالتوقيت المحلي إلى اليوم التقويمي السابق في UTC، لذلك يؤدي التحويل إلى UTC بصمت إلى إرجاع كل شمعة يابانية يومًا واحدًا، ويؤدي إلى كسر المفتاح الأساسي. يحافظ tz_localize(None) على تاريخ جلسة البورصة نفسه، وهو ما تمثله الشمعة اليومية.

المؤقت الذي يعمل عند إغلاق السوق

يغلق السوق الأمريكي عند الساعة 16:00 بتوقيت نيويورك، وتحتاج آخر الأسعار إلى بضع دقائق حتى تستقر، لذلك تعمل المهمة عند الساعة 16:20. اكتب الوقت بتوقيت نيويورك، وليس بتوقيت UTC. تكون نيويورك على UTC ناقص 5 في الشتاء، وعلى UTC ناقص 4 في الصيف. لذلك ينحرف المؤقت المكتوب على أنه ساعة UTC ثابتة بمقدار ساعة مرتين سنوياً، ويبدأ بالعمل قبل الإغلاق. يقبل systemd 252 والإصدارات الأحدث منطقة زمنية مباشرة في 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.target

Type=oneshot هو نوع الخدمة الوحيد الذي يقبل أكثر من ExecStart، ويشغّلها بالترتيب، ويتوقف إذا خرج أحدها بقيمة غير صفرية. هذا هو السلوك المطلوب تماماً: يجب ألا يؤدي فشل التحديث إلى عرض بيانات قديمة على الشاشة. يهم Persistent=true على VPS يُعاد تشغيله لتثبيت تحديثات النواة. إذا أعدت التشغيل عند الساعة 16:15 من دونه، فستُفقد المهمة ببساطة؛ أما معه، فتعمل المهمة فور عودة الجهاز.

sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timer

يجب أن يعرض list-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، فإنك تنشئ تقاطعاً عند بداية تاريخ كل مؤشر، رغم أنه لم يحدث فعلياً. يؤدي التصفية وفق رقم الصف إلى إسقاط الصفوف التي لم تكتمل فيها النافذة.

ما يراه النموذج، وما لا يراه مطلقاً

# /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()

يحدّ عدد الكلمات في مطالبة النظام الجزء الأعلى تكلفة من الفاتورة. أما التعليمات التي تفرض استخدام الصفوف المعطاة فقط، فعليك التحقق منها بدلاً من الوثوق بها: احذف عموداً واحداً من الكتلة، وشغّلها مرة أخرى، ثم اقرأ الناتج. إذا ظهر رقم خاص بذلك العمود رغم حذفه، فهذا يعني أن النموذج ملأ الفجوة، وأن مطالبتك ليست محكمة بما يكفي. يستغرق هذا الاختبار دقيقتين، وهو الطريقة الوحيدة الصادقة لمعرفة ذلك.

لا يرى النموذج مفتاح API، ولا يرى مسار قاعدة البيانات، ولا ينفّذ أي استعلام. يتلقى صفوفاً ويُرجع نصاً. هذا الحد الفاصل يجعل الناتج قابلاً للتحقق، لأن كل رقم في الملاحظة يجب أن يظهر أيضاً في الكتلة التي أرسلتها، ويمكنك مقارنتهما سطراً بسطر. لمزيد من المعلومات عن جانب صياغة المطالبات، يتناول استخدام 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;

اضرب هذه الإجماليات في أسعار كل مليون رمز التي يعلنها نموذجك في اليوم الذي تقرأها فيه. ستحصل بذلك على الرقم الفعلي بدلاً من تقدير شخص آخر. تتغير الأسعار المنشورة. أما الحساب فلا يتغير.

لماذا تُفرِط الاختبارات الخلفية في الملاءمة، وكيف تراقب حدوث ذلك

أعد كتابة الشاشة على شكل دالة في طولي النافذتين، ثم افحص شبكة من الأزواج ورتّبها حسب العائد. سيبدو الزوج الأفضل ممتازاً. هذه هي المشكلة، وليست النتيجة. شبكة تضم 200 زوج تعني إجراء 200 تجربة، وقد احتفظت بالزوج الذي حالفه أكبر قدر من الحظ.

يمكنك مراقبة ذلك خلال عشر دقائق. اقسم البيانات إلى نصفين حسب التاريخ. افحص الشبكة على النصف الأول فقط، وسجّل الفائز. ثم افحص الشبكة نفسها على النصف الثاني. إذا كان الفائزان مختلفين كثيراً، فهذا يعني أن المعلمات تلائم الضوضاء، كما أن الزوج الذي يفوز فقط على النصف الذي ضبطته عليه لا يخبرك بشيء عن الغد.

انحياز البقاء أسوأ من فرط الملاءمة، لأن الضبط لا يستطيع إصلاحه. فقائمة مؤشرات الأسهم لديك تتكون من أعضاء المؤشر الحاليين، ولذلك لا تحتوي إلا على الشركات التي بقيت. إذا طلبت من مصدر البيانات مؤشراً أُزيل من التداول في 2019، فسيعيد إطاراً فارغاً، ما يعني أن تلك الشركة لا تدخل مخزنك ولا اختبارك. كل اختبار خلفي تجريه استبعد حالات الفشل مسبقاً.

البيانات الأساسية المعاد بيانها تخرق التسلسل الزمني. فقيمة الإيرادات التي تعيدها API اليوم لربع سنة من 2019 ليست دائماً القيمة التي نُشرت في 2019. تستخدم الشاشة التي تخلط البيانات الأساسية الحالية مع أسعار 2019 معلومات لم تكن موجودة آنذاك. تكون الأسعار آمنة عادةً في هذه الحالة، أما البيانات الأساسية فعادةً لا تكون كذلك.

تتغير الأسعار المعدلة من دون أن تلاحظ. مع auto_adjust=True تُعدَّل أسعار الإغلاق بأثر رجعي بسبب توزيعات الأرباح والتجزئات، ولذلك يعيد الاستعلام نفسه عند تشغيله الشهر المقبل سجلاً تاريخياً مختلفاً قليلاً. تخزين الصفوف التي استخدمتها فعلياً هو ما يجعل النتيجة قابلة لإعادة الإنتاج، وهذا سبب آخر لوجود المخزن المحلي أصلاً.

يتجاهل الاختبار الخلفي أيضاً العمولات وانزلاق الأسعار، ويفترض أن أمرك لا يحرّك السعر. تندرج هذه الأمور ضمن التنفيذ، وهو خارج نطاق هذا الدليل ومشروح في تشغيل روبوتات التداول على 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. أوقفت النواة المهمة بسبب استهلاك الذاكرة. أكّد ذلك باستخدام journalctl -k | grep -i oom، ثم خفّض memory_limit في store.py.

تشغيل ناجح لا يكتب شيئاً. يقرأ systemctl status من active (exited)، ولم يزد حجم الجدول. أعاد مصدر البيانات إطارات فارغة. يصعب اكتشاف هذا الفشل، لذلك اجعل المهمة تنتهي بحالة غير صفرية عندما تعود جميع مؤشرات التداول فارغة.

عمل المؤقت في عطلة للسوق. لا يعرف systemd تقويم البورصة، لذلك يتضمن Mon-Fri العطلات. تُنفَّذ المهمة، لكن مصدر البيانات لا يعيد بيانات جديدة. يجب أن تتعامل المهمة مع ذلك بوصفه سلوكاً طبيعياً لا خطأً.

429 من API. لقد تجاوزت حد معدل الطلبات. يعيد Anthropic SDK المحاولة تلقائياً مع التراجع التدريجي، ويزيد Anthropic(max_retries=5) عدد المحاولات. إذا استمر الفشل يومياً، فهذا يعني أن المهمة تطلب كمية كبيرة في دفعة واحدة.

ما ليس عليه هذا

هذا مساعد للبحث. ينتج النموذج ملخصاً للإيداع، وقد يخطئ بثقة في قراءة رقم مطبوع في النص. لذلك يجب أن يكون مصدر كل رقم في المذكرة صفاً أرسلته أنت. تعامل مع الناتج على أنه قائمة مختصرة بأمور تقرؤها بنفسك. لا يتضمن هذا أي نصيحة مالية، وليس أياً منه إشارة تداول.

تُفيد الاختبارات الخلفية في رفض الأفكار، لكنها ضعيفة في تأكيدها. إذا فشلت استراتيجية على بياناتك أنت، فهي فاشلة فعلاً. وإذا نجحت، فهذا يعني أنها صمدت أمام بياناتك فقط. هذا ادعاء أضيق بكثير مما يبدو عليه عند الساعة 1am.

يبقى التنفيذ خارج النطاق عمداً. تحمل الأوامر وبيانات اعتماد الوسيط مستوى مختلفاً من المخاطر مقارنة بصندوق بحث للقراءة فقط. كما أن الجمع بينهما يضع مفاتيح التداول على الجهاز نفسه الذي يستقبل مطالبة LLM. إذا أردت معرفة موضع هذا النمط إلى جانب الأشياء الأخرى التي تستحق التشغيل على خادمك، فاطّلع على وكلاء الذكاء الاصطناعي المستضافين ذاتياً الذين يستحقون التشغيل في الجولة الأوسع.

FAQ

هل أحتاج إلى موجز مدفوع لبيانات السوق؟

ليس في مرحلة النموذج الأولي. يكفي موجز مجاني غير رسمي لتعلّم بنية النظام، لكنه سيتعطل لأنه يعتمد على موقع لا يلتزم بتوفير الخدمة لك. غالباً ما يتعطل على شكل إطارات فارغة بدلاً من ظهور استثناء، لذلك يجب أن تتحقق مهمتك من عدد الصفوف. انتقل إلى موجز مدفوع ذي API موثّق وعنوان دعم عندما تبدأ البيانات في التأثير في قرار. ما يجعل هذا التبديل رخيصاً هو طبقة التخزين: لا تتغير إلا دالة الجلب، بينما يبقى الجدول الزمني والمخطط والشاشة كما هي.

هل أحتفظ بالأسعار في SQLite أم DuckDB؟

DuckDB قاعدة بيانات عمودية ومصممة لفحص عدد كبير من الصفوف لحساب قيمة تجميعية، وهذا يطابق تماماً حساب متوسط متحرك على أشرطة تمتد لعشر سنوات. أما SQLite فهي موجهة إلى الصفوف وتناسب عمليات القراءة والكتابة الصغيرة المتعددة من عدة عمليات في الوقت نفسه. بالنسبة إلى مهمة مجدولة واحدة تضيف بضع مئات من الصفوف ثم تفحص ملايين الصفوف، فإن DuckDB أنسب. إذا كان يجب على عدة عمليات الكتابة في الوقت نفسه، فإن SQLite في وضع WAL تتيح للقرّاء العمل أثناء تنفيذ كاتب واحد لعملية commit، كما يجعل مهلة الانشغال الكتّاب الآخرين ينتظرون بدلاً من فشلهم مباشرة.

ما تكلفة استدعاءات LLM شهرياً؟

سجّل usage.input_tokens وusage.output_tokens من كل استجابة في جدول، ثم اضرب إجمالياتك الأسبوعية في سعر كل مليون token الذي يورده نموذجك في يوم التحقق. هذا هو الرقم الوحيد الذي سيظل صحيحاً في الربع القادم. يشمل التشغيل اليومي على شاشة قصيرة عدداً قليلاً من الاستدعاءات، أما طول الملاحظة فهو الجزء الذي يمكنك التحكم فيه: يؤدي تحديد الملخص بـ200 كلمة إلى توفير أكبر من إرسال عدد أقل من الصفوف، لأن tokens المخرجات أعلى سعراً من tokens المدخلات في كل نماذج Claude.

لماذا نجحت مهمتي لكنها لم تكتب أي صفوف جديدة؟

هناك سببان شائعان. كان السوق مغلقاً، لأن جدول Mon-Fri في systemd يتضمن عطلات البورصة. أو أعاد الموجز إطاراً فارغاً لكل رمز، وهو ما تبلّغه مكتبات العملاء غالباً في صورة تحذير مطبوع بدلاً من استثناء، لذلك تنتهي العملية بالرمز 0 وتعرض systemd التشغيل بحالة نجاح. ميّز بين السببين بمقارنة SELECT max(day) FROM prices بآخر يوم تداول فعلي، واجعل المهمة تنتهي برمز غير صفري عندما تكون نتيجة كل الرموز فارغة.

هل يستطيع الوكيل أن يقرر ما الذي أشتريه؟

لا. ويحدث الخطأ في هذه المشاريع عندما تُبنى لمحاولة ذلك. لا يملك النموذج وصولاً إلى السوق، ولا يعرف مراكزك أو وضعك الضريبي، ولا يستطيع التحقق من أرقامه بنفسه بمقارنتها بأي مصدر. ما يجيده هو قراءة كمية كبيرة من النصوص وإخبارك بالعناصر القليلة التي تستحق انتباهك اليوم. لا تُعدّ أي مادة يكتبها نصيحة مالية، ويبقى القرار ومسؤوليته معك.