ساخت عامل تحقیقاتی سهام روی VPS با Python و DuckDB
راهنمای کامل پیادهسازی یک عامل تحقیقاتی خودکار روی VPS. با استفاده از Python، پایگاه داده DuckDB و systemd timer دادههای بازار را ذخیره و با LLM تحلیل کنید.
یک عامل تحقیقاتی بازار سهام به صورت self-hosted چیست
یک عامل تحقیقاتی بازار سهام به صورت self-hosted، برنامه کوچکی روی سروری است که مالکیت آن در اختیار شماست. این برنامه دادههای بازار را طبق یک زمانبندی دریافت میکند، آنها را در یک پایگاه داده محلی ذخیره کرده، یک فیلتر روی آنها اعمال میکند و از یک مدل زبانی بزرگ (LLM) میخواهد تا تغییرات را گزارش کند. این عامل دادهها را میخواند و فیلتر میکند. این برنامه معامله انجام نمیدهد و هیچ بخشی از این راهنما توصیه مالی محسوب نمیشود.
دو نفر که این سیستم را از صفر میسازند، ممکن است کتابخانههای متفاوتی انتخاب کنند، اما در نهایت به چهار بخش یکسان میرسند: یک فید که قیمتها و دادههای بنیادی را تأمین میکند، یک فضای ذخیرهسازی محلی که تمام ردیفهای دریافتشده را نگه میدارد، یک وظیفه (job) که فضای ذخیرهسازی را طبق زمانبندی بهروزرسانی میکند، و یک لایه LLM که ردیفهای باقیمانده را به جملات تبدیل میکند. این راهنما این ساختار را با استفاده از Python، DuckDB، یک systemd timer و Claude API (رابط برنامهنویسی اپلیکیشن) پیادهسازی میکند. اجرای معاملات یک وظیفه جداگانه با حالتهای شکست متفاوت است و باید روی یک VPS تنظیمشده برای رباتهای معاملهگر انجام شود.
چهار بخش اصلی و وظیفه هر کدام
فید (The feed) تنها بخشی است که با دنیای خارج ارتباط برقرار میکند. این بخش میداند چگونه درخواست یک نماد (ticker) و بازه زمانی را ارسال کند و چگونه ردیفهای داده را تحویل دهد. تمام بخشهای پاییندستی به جای فید، دیتابیس شما را میخوانند؛ بنابراین در صورت از کار افتادن فید، شما تنها یک روز داده جدید را از دست میدهید، نه اینکه کل صفحه نمایش از کار بیفتد.
ذخیرهساز (The store) هدف اصلی کل این فرآیند است. قیمت پایانی روزانهای که ثبت نکردهاید، معمولاً بعداً قابل بازیابی است. اما قیمتهای لحظهای (intraday)، برآوردهای پیش از بازنگری، یا ارقام بنیادی پیش از اصلاح مجدد، قابل بازیابی نیستند. ذخیرهساز روشی است که با آن سوابق دقیقی از آنچه دادهها در همان روز اعلام کردهاند، ایجاد میکنید.
زمانبند (The scheduler) تعیین میکند که بهروزرسانی چه زمانی انجام شود. روی سرور، این کار توسط یک systemd timer انجام میشود؛ به همین دلیل است که در اینجا اهمیت VPS از خود کد بیشتر است.
لایه LLM یک بلوک متنی کوتاه که توسط SQL شما تولید شده را میخواند و خلاصهای از آن مینویسد. این لایه هرگز به دیتابیس متصل نمیشود و هرگز کوئری نمیسازد. اگر مدل، SQL را بنویسد، یک توکن اشتباه میتواند به یک عدد غلط در میان یک جمله روان تبدیل شود، بدون اینکه مرجعی برای مقایسه وجود داشته باشد. اگر SQL اعداد را تولید کند، مدل تنها میتواند در متن (prose) اشتباه کند و شما میتوانید متن را با ردیفهایی که ارسال کردهاید، مطابقت دهید.
چرا اجرای آن روی VPS بهتر از لپتاپ است
دلیل اصلی، زمانبندی (scheduler) است. بسته شدن بازار سهام آمریکا در ساعت 16:00 به وقت نیویورک، معادل ساعت 22:00 در برلین و 04:00 صبح روز بعد در جاکارتا است. لپتاپ در هر دو این زمانها معمولاً در حالت خواب (sleep) قرار دارد. هزینه یک اجرای از دست رفته، بیش از یک یادداشت تأخیری است: دادههای روزانه معمولاً بعداً قابل بازیابی هستند، اما هر چیزی که بازبینی شود دیگر قابل بازیابی نیست؛ بنابراین شکاف ایجاد شده در سوابق شما دائمی خواهد بود. همین استدلال برای هر عاملی (agent) که زمانبندی و وضعیت ذخیرهشدهاش باید پس از reboot حفظ شود صدق میکند، که این دقیقاً همان هدفی است که اجرای KiroCrew به عنوان یک عامل همیشه روشن روی VPS شخصی برای آن طراحی شده است.
دلیل دوم کوچکتر اما همچنان واقعی است. سرور یک کلید API را در یک فایل نگه میدارد که متعلق به یک کاربر سیستمی بدون shell ورود است و فقط توسط یک job استفاده میشود. تنظیم چنین شرایطی روی لپتاپی که با آن وبگردی میکنید بسیار دشوارتر است. کلید را خارج از کد و خارج از ورودی مدل نگه دارید، که این موضوع بحث دور نگه داشتن کلیدهای API از عاملهای هوش مصنوعی است.
تخمین منابع: دیسک، رم و توکنها
بخش دیسک ساده است. هر نوار روزانه شامل یک ردیف برای هر نماد در هر روز معاملاتی است و یک سال معاملاتی در ایالات متحده حدود 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 ستونها را بهصورت فشرده ذخیره میکند، بنابراین حجم دادهها در حد دهها مگابایت است، نه گیگابایت. به این تخمین، از جمله تخمین من، اعتماد نکنید. پس از اولین backfill، دستور du -h /opt/research/data/market.duckdb را اجرا کنید و از عدد واقعی خودتان استفاده کنید.
رم جایی است که یک VPS کوچک با مشکل مواجه میشود. DuckDB بهطور پیشفرض سهم بزرگی از حافظه دستگاه و تمام هستههای آن را برای یک کوئری واحد اشغال میکند؛ این رفتار برای یک سرور تحلیل داده مناسب است اما برای یک ماشین 2 GB که سرویسهای دیگری را نیز اجرا میکند، اشتباه است. یک تجمیع (aggregation) روی کل جدول قیمتها باعث میشود پردازش توسط کرنل متوقف (kill) شود و systemd گزارش Main process exited, code=killed, status=9/KILL را ثبت کند، در حالی که journalctl -k نشاندهنده توقف به دلیل کمبود حافظه (OOM kill) است. مقادیر 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 را چاپ میکند. اگر محیط مجازی (virtual environment) را نادیده گرفتهاید و دستور pip install را روی Python سیستمی اجرا کردهاید، اوبونتو 24.04 با خطای error: externally-managed-environment مانع شما میشود؛ زیرا توزیع مالک /usr/lib/python3 است و اجازه نمیدهد pip در آنجا بنویسد. استفاده از venv یک توصیه اخلاقی نیست، بلکه تنها دایرکتوری است که 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 ردیفی را که از قبل برای آن نماد (ticker) و آن روز وجود دارد بازنویسی میکند، بنابراین اجرای مجدد عملیات backfill باعث ایجاد دادههای تکراری در جدول نمیشود. بدون این کلید، اجرای مجدد پس از یک کرش، تمام دادهها را بیسروصدا تکثیر میکند و هر میانگینی که پس از آن محاسبه کنید اشتباه خواهد بود، بدون اینکه هیچ پیام خطایی شما را از این موضوع مطلع کند.
DuckDB اجازه میدهد دقیقاً یک پردازش فایل را برای نوشتن باز نگه دارد. نویسنده دوم بلافاصله با خطای Could not set lock on file مواجه میشود که به دنبال آن PID پردازشی که فایل را در اختیار دارد نمایش داده میشود؛ در عمل، این همان شل تعاملی duckdb است که در ترمینال دیگری باز گذاشتهاید. خوانندهها از read_only=True عبور میکنند و به همین دلیل است که connect از این فلگ استفاده میکند. اگر چندین پردازش واقعاً نیاز داشته باشند در یک لحظه بنویسند، این وظیفهٔ موتور دیگری است: SQLite در حالت WAL به خوانندهها اجازه میدهد همزمان با commit کردن یک نویسنده کار کنند و یک busy timeout باعث میشود سایر نویسندهها بهجای شکست خوردن، منتظر بمانند. مقایسه 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اجرای اول، دادههای سالهای گذشته را بازیابی (backfill) میکند و برای هر نماد (ticker) یک خط با شمارشی در حد هزاران ردیف چاپ میکند. یک دقیقه بعد دوباره آن را اجرا کنید؛ هر خط باید 1 یا 2 ردیف را نشان دهد، زیرا این وظیفه از آخرین روزی که قبلاً ذخیره شده است شروع میشود. آن اجرای دوم، آزمون اصلی است: اگر شمارشها همچنان در حد هزاران ردیف باشند، max(day) چیزی برنمیگرداند و عملیات درج (insert)، هر شب کل تاریخچه شما را بازنویسی میکند.
بررسی df.empty مهمترین خط در فایل است. Ticker.history() برای یک نماد اشتباه یا حذفشده از لیست (delisted)، خطا (raise) نمیدهد. این تابع یک هشدار درباره احتمال حذف نماد از لیست یا پیدا نشدن دادههای قیمت چاپ میکند (متن دقیق آن در نسخههای مختلف کتابخانه تغییر میکند) و یک DataFrame خالی برمیگرداند. وظیفهای که فاقد این بررسی باشد، چیزی نمینویسد، با کد 0 خارج میشود و systemd یک اجرای موفق و سبز را نشان میدهد، در حالی که جدول بیسروصدا از رشد باز میماند. شما هفتهها بعد متوجه این موضوع میشوید، آن هم از صفحهای که هر روز ردیفهای تکراری نشان میدهد.
مدیریت timestamp نیز دلیل خاص خود را دارد. ایندکسی که فید برمیگرداند ممکن است حاوی منطقه زمانی بورس باشد و حذف offset با تبدیل به UTC یکی نیست. یک نشست معاملاتی توکیو که در نیمهشب به وقت محلی ثبت شده، در UTC به روز تقویمی قبل تبدیل میشود؛ بنابراین تبدیل به UTC بهطور نامحسوس هر کندل ژاپنی را یک روز به عقب میبرد و کلید اصلی (primary key) را خراب میکند. tz_localize(None) تاریخ نشست خودِ بورس را حفظ میکند، که همان معنای کندل روزانه است.
تایمری که در زمان بسته شدن بازار اجرا میشود
بازار ایالات متحده در ساعت 16:00 به وقت نیویورک بسته میشود و ثبت نهایی تراکنشها چند دقیقه زمان میبرد، بنابراین این job در ساعت 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.targetType=oneshot تنها نوع سرویسی است که بیش از یک ExecStart را میپذیرد و آنها را به ترتیب اجرا میکند؛ اگر یکی از آنها با کد خروجی غیر صفر متوقف شود، بقیه اجرا نمیشوند. این دقیقاً همان رفتاری است که شما میخواهید: یک بهروزرسانی ناموفق نباید با نمایش دادههای قدیمی دنبال شود. Persistent=true در یک VPS که برای بهروزرسانیهای kernel ریبوت میشود، اهمیت دارد. بدون آن، اگر ریبوت در ساعت 16:15 رخ دهد، اجرا از دست میرود؛ اما با آن، job به محض بالا آمدن مجدد دستگاه اجرا خواهد شد.
sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timerlist-timers باید ستونی به نام NEXT را نشان دهد که زمان اجرای روز کاری بعدی را به وقت محلی سرور نمایش میدهد. لیست خالی به این معنی است که تایمر فعال نیست یا unit فاقد بخش [Install] است، بنابراین enable چیزی برای لینک کردن به timers.target نداشته است.
صفحه نمایش: ابتدا SQL، در نهایت مدل
SQL دقیق است و اجرای آن هزینهای ندارد. مدل هیچکدام از این دو ویژگی را ندارد. بنابراین پرسوجو (query) دامنه دادهها را محدود میکند و تنها موارد باقیمانده به مدل ارسال میشوند. این را با نام /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) ابداع میکنید که هرگز رخ نداده است. فیلتر کردن بر اساس شماره ردیف، ردیفهایی را که پنجره (window) در آنها کامل نبوده است، حذف میکند.
آنچه مدل میبیند و آنچه هرگز نمیبیند
# /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 را نمیبیند، مسیر دیتابیس را مشاهده نمیکند و هیچ query را اجرا نمیکند. مدل فقط ردیفها را دریافت کرده و متن بازمیگرداند. این مرز همان چیزی است که خروجی را قابل بررسی میکند، زیرا هر عددی در یادداشت باید در بلوکی که ارسال کردهاید نیز وجود داشته باشد و میتوانید آنها را خط به خط مقایسه کنید. برای اطلاعات بیشتر در مورد جنبههای prompting این موضوع، استفاده از Claude برای تحلیل مالی به جزئیات بیشتری درباره تواناییهای مدل در خواندن دادهها میپردازد.
یک اجرای کامل، از ابتدا تا انتها
در ساعت 16:20 به وقت نیویورک، تایمر سرویس را آغاز میکند. refresh.py برای هر نماد (ticker)، دادهها را از آخرین روز ذخیرهشده از فید درخواست میکند، یک یا دو کندل جدید برای هر کدام مینویسد و برای هر نماد یک خط چاپ میکند. 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;این مجموعها را در قیمتهای «به ازای هر میلیون توکن» که مدل شما در روز خواندن قیمتها ارائه میدهد، ضرب کنید. این کار عدد واقعی را به شما میدهد، نه برآوردهای دیگران را. قیمتهای منتشرشده تغییر میکنند، اما محاسبات ثابت میمانند.
چرا بکتستها دچار بیشبرازش (Overfit) میشوند و چگونه این پدیده را رصد کنیم
خروجی اسکرین (Screen) را به عنوان تابعی از طول دو پنجره در نظر بگیرید، یک شبکه (Grid) از جفتها را بررسی کنید و آنها را بر اساس بازدهی رتبهبندی کنید. بهترین جفت، عالی به نظر خواهد رسید. این خودِ مشکل است، نه نتیجه. یک شبکه شامل 200 جفت، یعنی 200 آزمایش، و شما خوششانسترین آنها را انتخاب کردهاید.
شما میتوانید این اتفاق را در ده دقیقه مشاهده کنید. دادههای ذخیرهشده را بر اساس تاریخ به دو نیم تقسیم کنید. شبکه را فقط روی نیمه اول اجرا کنید و برنده را یادداشت کنید. همان شبکه را روی نیمه دوم اجرا کنید. اگر دو برنده با هم فاصله زیادی داشته باشند، پارامترها در حال برازش نویز (Noise) هستند و جفتی که فقط در نیمهای که آن را تنظیم کردهاید برنده میشود، هیچ حرفی برای فردا ندارد.
سوگیری بقا (Survivorship bias) از بیشبرازش بدتر است، زیرا با تنظیم (Tuning) قابل اصلاح نیست. لیست نمادهای شما شامل اعضای فعلی شاخص است، بنابراین فقط شرکتهایی را در بر میگیرد که باقی ماندهاند. اگر از فید دادهها نمادی را بخواهید که در سال 2019 از لیست خارج شده است، یک فریم خالی به شما میدهد؛ این یعنی آن شرکت هرگز وارد دیتابیس شما نمیشود و هرگز در تست شما شرکت نمیکند. هر بکتستی که اجرا میکنید، از پیش شکستخوردهها را حذف کرده است.
اصول بنیادی بازنویسیشده (Restated fundamentals) خط زمانی را میشکنند. رقم درآمدی که API امروز برای یک فصل از سال 2019 برمیگرداند، همیشه همان رقمی نیست که در سال 2019 منتشر شده بود. اسکرینی که اصول بنیادی امروز را با قیمتهای سال 2019 ترکیب میکند، از اطلاعاتی استفاده میکند که در آن زمان وجود نداشته است. قیمتها معمولاً در اینجا ایمن هستند، اما اصول بنیادی معمولاً اینطور نیستند.
قیمتهای تعدیلشده (Adjusted prices) زیر پای شما حرکت میکنند. با auto_adjust=True قیمتهای بسته شدن به عقب برای سود سهام و تقسیم سهام تعدیل میشوند، بنابراین همان کوئری که ماه آینده اجرا شود، تاریخچه کمی متفاوتی را برمیگرداند. ذخیره کردن ردیفهایی که واقعاً استفاده کردهاید، همان چیزی است که یک نتیجه را قابل بازتولید میکند و دلیل دیگری برای وجود دیتابیس محلی است.
بکتست همچنین کارمزدها و لغزش قیمت (Slippage) را نادیده میگیرد و فرض میکند که سفارش شما قیمت را تغییر نمیدهد. این موارد به بخش اجرا (Execution) مربوط میشوند که خارج از محدوده این بحث است و در اجرای رباتهای معاملاتی روی VPS پوشش داده شده است.
حالتهای شکست و پیامهایی که مشاهده خواهید کرد
error: externally-managed-environment هنگام اجرای pip. شما خارج از محیط مجازی (virtual environment) هستید. /opt/research/venv/bin/pip را با مسیر کامل فراخوانی کنید.
Could not set lock on file، به همراه یک PID در ادامه آن. پردازش دیگری فایل DuckDB را برای نوشتن باز نگه داشته است؛ معمولاً یک شل تعاملی است که فراموش کردهاید. آن را ببندید یا اتصال دوم را با 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) فریمهای خالی بازگردانده است. این نوع شکست طولانیترین زمان را برای شناسایی میگیرد، بنابراین کاری کنید که اگر هر تیکر (ticker) خالی بازگشت، وضعیت خروج (exit code) غیر صفر باشد.
تایمر در تعطیلات بازار فعال شده است. systemd تقویم بورس را نمیشناسد، بنابراین Mon-Fri شامل تعطیلات نیز میشود. اجرا انجام میشود، فید داده جدیدی ندارد و کار باید آن را به عنوان یک وضعیت عادی در نظر بگیرد، نه یک خطا.
429 از سمت API. شما از محدودیت نرخ (rate limit) فراتر رفتهاید. SDK شرکت Anthropic بهطور خودکار با استفاده از backoff تلاش مجدد میکند و Anthropic(max_retries=5) تعداد تلاشها را افزایش میدهد. اگر این خطا همچنان هر روز رخ میدهد، یعنی درخواستهای ارسالی در یک بازه زمانی بیش از حد مجاز است.
این ابزار چه کاربردی ندارد
این یک دستیار پژوهشی است. مدلی که یک پرونده را خلاصه میکند، برداشتی از آن ارائه میدهد و ممکن است در مورد عددی که در متن چاپ شده، با اطمینان کامل دچار اشتباه شود؛ به همین دلیل، هر عدد موجود در این یادداشت باید به ردیفی که ارسال کردهاید قابل ردیابی باشد. خروجی را به عنوان فهرستی کوتاه از مواردی در نظر بگیرید که باید شخصاً مطالعه کنید. هیچکدام از مطالب اینجا توصیه مالی نیست و هیچکدام سیگنال معاملاتی محسوب نمیشوند.
بکتستها برای رد کردن ایدهها مفیدند، اما برای تأیید آنها ضعیف عمل میکنند. استراتژیای که روی دادههای شما شکست میخورد، واقعاً از بین رفته است. استراتژیای که موفق میشود، تنها از آزمون دادههای شما جان سالم به در برده است؛ این ادعای بسیار کوچکتری نسبت به آن چیزی است که در ساعت 1 بامداد به نظر میرسد.
اجرای معاملات عمداً خارج از محدوده این ابزار باقی میماند. سفارشها و اعتبارنامههای کارگزاری، پروفایل ریسک متفاوتی نسبت به یک محیط پژوهشی فقطخواندنی (read-only) دارند و ترکیب آنها باعث میشود کلیدهای معاملاتی روی همان ماشینی قرار بگیرند که پرامپتهای LLM در آن اجرا میشوند. اگر میخواهید بدانید این الگو در کنار سایر مواردی که ارزش اجرا روی سرور شخصی شما را دارند در چه جایگاهی قرار میگیرد، عاملهای هوش مصنوعی خودمیزبانی که ارزش اجرا دارند تور جامعتری در این زمینه است.
FAQ
آیا به یک منبع دادههای بازار پولی نیاز دارم؟
برای نمونه اولیه، خیر. یک منبع داده غیررسمی و رایگان برای یادگیری ساختار سیستم کافی است، اما این منبع قطعاً از کار خواهد افتاد، زیرا به وبسایتی وابسته است که هیچ تعهدی به شما ندارد. این منابع معمولاً بهجای ایجاد خطا، فریمهای خالی برمیگردانند؛ بنابراین وظیفه شما این است که تعداد ردیفها را بررسی کنید. زمانی که دادهها شروع به تأثیرگذاری بر تصمیمات شما کردند، به یک منبع پولی با API مستند و آدرس پشتیبانی مهاجرت کنید. فروشگاه داده (store) همان چیزی است که این جابهجایی را ارزان میکند: فقط تابع fetch تغییر میکند و زمانبندی، طرحواره (schema) و صفحه نمایش (screen) همانطور که هستند باقی میمانند.
آیا قیمتها را در SQLite نگه دارم یا DuckDB؟
DuckDB ستونمحور است و برای اسکن ردیفهای زیاد جهت محاسبه یک مقدار تجمعی ساخته شده است، که دقیقاً همان کاری است که میانگین متحرک روی 10 سال داده انجام میدهد. SQLite ردیفمحور است و برای خواندن و نوشتنهای کوچک و متعدد از چندین پردازش بهطور همزمان مناسبتر است. برای یک job زمانبندیشده که چند صد ردیف را اضافه میکند و سپس میلیونها ردیف را اسکن میکند، DuckDB گزینه بهتری است. اگر چندین پردازش باید همزمان بنویسند، SQLite در حالت WAL به خوانندهها اجازه میدهد در حالی که یک نویسنده در حال commit است به کار خود ادامه دهند، و یک busy timeout باعث میشود سایر نویسندهها بهجای شکست خوردن فوری، منتظر بمانند.
هزینههای تماس با LLM در ماه چقدر است؟
مقادیر usage.input_tokens و usage.output_tokens را از هر پاسخ در یک جدول ثبت کنید، سپس مجموع هفتگی خود را در قیمت هر میلیون توکن که مدل شما در روز بررسی اعلام کرده است، ضرب کنید. این تنها عددی است که در فصل آینده نیز معتبر باقی میماند. یک اجرای روزانه روی یک screen کوتاه، تعداد کمی تماس ایجاد میکند و طول یادداشت بخشی است که شما کنترل میکنید: محدود کردن خلاصه به 200 کلمه، بیشتر از ارسال ردیفهای کمتر صرفهجویی میکند، زیرا قیمت توکنهای خروجی در تمام مدلهای Claude بالاتر از توکنهای ورودی است.
چرا job من با موفقیت اجرا شد اما ردیف جدیدی ننوشت؟
دو دلیل معمول وجود دارد. بازار بسته بوده است، زیرا زمانبندی Mon-Fri در systemd شامل تعطیلات بورس نمیشود. یا اینکه منبع داده برای هر نماد (ticker) یک فریم خالی برگردانده است، که کتابخانههای کلاینت اغلب آن را بهصورت یک هشدار چاپی گزارش میکنند نه یک استثنا، بنابراین پردازش همچنان با کد 0 خارج میشود و systemd وضعیت سبز را نشان میدهد. با مقایسه SELECT max(day) FROM prices با آخرین روز معاملاتی واقعی، این دو حالت را از هم تشخیص دهید و کاری کنید که اگر تمام نمادها خالی برگشتند، job با کدی غیر از صفر خارج شود.
آیا عامل (agent) میتواند تصمیم بگیرد چه چیزی بخرد؟
خیر، و تلاش برای ساخت چنین سیستمی همان جایی است که این پروژهها به بیراهه میروند. مدل هیچ دسترسی به بازار، هیچ دیدی نسبت به موقعیت مالی یا وضعیت مالیاتی شما و هیچ راهی برای بررسی اعداد خود در برابر واقعیت ندارد. کاری که مدل در آن مهارت دارد، خواندن حجم زیادی از متن و معرفی چند موردی است که امروز شایسته توجه شما هستند. هیچکدام از نوشتههای آن توصیه مالی نیست و تصمیمگیری، همراه با مسئولیت آن، بر عهده شما باقی میماند.