วิธีสร้าง Stock Research Agent บน VPS ด้วยตัวเอง
สร้างระบบวิจัยหุ้นอัตโนมัติบน VPS ด้วย Python และ DuckDB ตั้งค่าระบบดึงข้อมูลตลาดและสรุปผลผ่าน LLM โดยใช้ systemd timer เพื่อรันงานหลังปิดตลาดอย่างแม่นยำและปลอดภัย
ตัวแทนวิจัยหุ้นแบบ self-hosted คืออะไร
ตัวแทนวิจัยหุ้นแบบ self-hosted คือโปรแกรมขนาดเล็กที่รันบนเซิร์ฟเวอร์ของคุณเอง ทำหน้าที่ดึงข้อมูลตลาดตามกำหนดเวลา จัดเก็บไว้ในฐานข้อมูลภายใน คัดกรองข้อมูลเหล่านั้น และส่งคำสั่งให้โมเดลภาษาขนาดใหญ่ (LLM) เขียนสรุปการเปลี่ยนแปลงที่เกิดขึ้น โปรแกรมนี้ทำหน้าที่อ่านและคัดกรองข้อมูลเท่านั้น ไม่ได้ทำหน้าที่ซื้อขายหลักทรัพย์ และเนื้อหาในคู่มือนี้ไม่ใช่คำแนะนำทางการเงิน
หากคนสองคนสร้างระบบนี้ขึ้นมาใหม่โดยเริ่มจากศูนย์ แม้จะเลือกใช้ไลบรารีที่แตกต่างกัน แต่ท้ายที่สุดแล้วระบบจะมีองค์ประกอบหลัก 4 ส่วนเหมือนกัน ได้แก่ ฟีดข้อมูลราคาและปัจจัยพื้นฐาน, ระบบจัดเก็บข้อมูลภายในที่บันทึกทุกแถวข้อมูลที่คุณเคยดึงมา, งานที่ทำหน้าที่อัปเดตข้อมูลในระบบจัดเก็บตามเวลาที่กำหนด และเลเยอร์ LLM ที่แปลงข้อมูลที่ผ่านการคัดกรองให้เป็นข้อความ คู่มือนี้สร้างระบบดังกล่าวโดยใช้ Python, DuckDB, systemd timer และ Claude API (application programming interface) นี่เป็นตัวแทนที่มีขอบเขตงานจำกัดและมีลูปการทำงานที่แน่นอน ดังนั้นหากคุณต้องการทำความรู้จักกับเวอร์ชันทั่วไปก่อน เส้นทางการเรียนรู้ตัวแทน AI แบบเป็นลำดับขั้น จะช่วยอธิบายลูปการทำงาน เครื่องมือ และหน่วยความจำตามลำดับก่อนที่จะนำไปประยุกต์ใช้กับปัญหาเฉพาะทาง ส่วนการดำเนินการซื้อขายเป็นงานแยกต่างหากที่มีรูปแบบความล้มเหลวเฉพาะตัว ซึ่งควรดำเนินการบน VPS ที่ตั้งค่าสำหรับบอทเทรด แทน
ส่วนประกอบทั้งสี่และหน้าที่ของแต่ละส่วน
The feed เป็นส่วนเดียวที่ติดต่อกับโลกภายนอก มันรู้วิธีการร้องขอ ticker และช่วงวันที่ รวมถึงรู้วิธีการส่งข้อมูลกลับมาเป็นแถว ทุกส่วนที่อยู่ถัดไปจะอ่านข้อมูลจากฐานข้อมูลของคุณแทนที่จะอ่านจาก feed โดยตรง ดังนั้นหาก feed เกิดขัดข้อง คุณจะสูญเสียข้อมูลใหม่เพียงหนึ่งวัน แทนที่จะทำให้หน้าจอแสดงผลพังทั้งหมด
The store คือจุดประสงค์หลักของระบบทั้งหมด ราคาปิดรายวันที่คุณไม่ได้บันทึกไว้มักจะสามารถดึงข้อมูลย้อนหลังได้ แต่ราคาซื้อขายระหว่างวัน, ข้อมูลประมาณการก่อนการปรับปรุง หรือตัวเลขพื้นฐานก่อนการแก้ไขใหม่นั้นไม่สามารถทำได้ The store คือวิธีการสร้างบันทึกว่าข้อมูลจริง ๆ ระบุไว้อย่างไรในวันที่ข้อมูลนั้นถูกเผยแพร่
The scheduler เป็นตัวตัดสินใจว่าการรีเฟรชจะเกิดขึ้นเมื่อใด บนเซิร์ฟเวอร์ส่วนนี้คือ systemd timer ซึ่งเป็นเหตุผลว่าทำไม VPS ถึงมีความสำคัญมากกว่าตัวโค้ดในบริบทนี้
The LLM layer ทำหน้าที่อ่านข้อความสั้น ๆ ที่ SQL ของคุณสร้างขึ้น แล้วเขียนสรุปผลจากข้อความนั้น มันไม่เคยเชื่อมต่อกับฐานข้อมูลและไม่เคยสร้างคำสั่ง query เอง หากโมเดลเป็นผู้เขียน SQL เพียงแค่ token เดียวที่ผิดพลาดอาจกลายเป็นตัวเลขที่ผิดในประโยคที่ดูสละสลวย โดยไม่มีสิ่งใดให้ตรวจสอบความถูกต้อง แต่ถ้า SQL เป็นตัวสร้างตัวเลข โมเดลจะทำได้เพียงแค่เขียนคำบรรยายผิดพลาดเท่านั้น ซึ่งคุณสามารถตรวจสอบคำบรรยายนั้นเทียบกับแถวข้อมูลที่คุณส่งไปได้
เหตุใดจึงควรใช้งานบน VPS แทนแล็ปท็อป
เหตุผลคือเรื่องของตัวจัดตารางเวลา (scheduler) ตลาดหุ้นสหรัฐฯ ปิดทำการเวลา 16:00 น. ตามเวลาในนิวยอร์ก ซึ่งตรงกับเวลา 22:00 น. ในเบอร์ลิน และ 04:00 น. ของวันถัดไปในจาการ์ตา แล็ปท็อปมักจะอยู่ในโหมดพักเครื่องในช่วงเวลาดังกล่าว การพลาดการทำงานมีต้นทุนสูงกว่าการแจ้งเตือนล่าช้า โดยปกติแล้วข้อมูลราคาแบบรายวันอาจดึงย้อนหลังได้ แต่ข้อมูลใดก็ตามที่มีการแก้ไขภายหลังจะไม่สามารถกู้คืนได้ ทำให้เกิดช่องว่างในบันทึกของคุณอย่างถาวร เหตุผลเดียวกันนี้ยังใช้กับเอเจนต์ใดก็ตามที่ตารางเวลาและสถานะที่จัดเก็บไว้ต้องคงอยู่แม้ผ่านการรีบูต ซึ่งเป็นแนวคิดหลักของ การรัน KiroCrew ในฐานะเอเจนต์ที่ทำงานตลอดเวลาบน VPS ของคุณเอง
เหตุผลประการที่สองมีความสำคัญน้อยกว่าแต่ยังคงเป็นจริง เซิร์ฟเวอร์จะเก็บ API key ไว้เพียงชุดเดียว ในไฟล์เดียว ซึ่งเป็นกรรมสิทธิ์ของผู้ใช้ระบบ (system user) ที่ไม่มี login shell และถูกใช้งานโดยงานเดียวเท่านั้น การจัดการในลักษณะนี้ทำได้ยากกว่ามากบนแล็ปท็อปที่คุณใช้ท่องเว็บทั่วไป ควรเก็บ key ไว้แยกจากโค้ดและไม่ให้ป้อนเข้าสู่ input ของโมเดล ซึ่งเป็นหัวข้อของ การเก็บรักษา API keys ให้ห่างจาก AI agent
การประเมินขนาด: ดิสก์, RAM และโทเค็น
ดิสก์เป็นส่วนที่จัดการได้ง่าย ข้อมูลรายวันหนึ่งรายการคือหนึ่งแถวต่อหนึ่งหลักทรัพย์ต่อหนึ่งวันทำการ โดยปีการซื้อขายของสหรัฐฯ มีประมาณ 252 วัน
ข้อมูลเบื้องหลังแผนภูมินี้
[
{
"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"
}
]หลักทรัพย์ 20 รายการจะมีจำนวน 5,040 แถวหลังจากผ่านไปหนึ่งปี และบรรทัด 500 tickers จะมีจำนวนถึง 1,260,000 แถวหลังจากผ่านไปสิบปี แต่ละแถวประกอบด้วยวันที่และค่าตัวเลขทศนิยมจำนวนหนึ่ง ซึ่ง DuckDB จะจัดเก็บข้อมูลแบบคอลัมน์ที่ผ่านการบีบอัดแล้ว ดังนั้นขนาดข้อมูลจึงอยู่ในระดับหลักสิบเมกะไบต์ไม่ใช่กิกะไบต์ อย่าเพิ่งเชื่อการคาดการณ์นี้รวมถึงของผมด้วย ให้รันคำสั่ง du -h /opt/research/data/market.duckdb หลังจากทำ backfill ครั้งแรกเสร็จสิ้นแล้วใช้ตัวเลขจริงของคุณเอง
RAM เป็นจุดที่ VPS ขนาดเล็กมักประสบปัญหา โดยปกติ DuckDB จะดึงหน่วยความจำส่วนใหญ่ของเครื่องและใช้คอร์ทั้งหมดสำหรับการประมวลผลหนึ่งคิวรี ซึ่งเหมาะสมสำหรับเซิร์ฟเวอร์วิเคราะห์ข้อมูล แต่ไม่เหมาะกับเครื่องขนาด 2 GB ที่ต้องรันงานอื่นด้วย การรวมข้อมูล (aggregation) ทั้งหมดในตารางราคาอาจทำให้กระบวนการถูก kernel สั่งยุติการทำงาน และ systemd จะรายงาน Main process exited, code=killed, status=9/KILL ในขณะที่ journalctl -k จะแสดงสถานะการสั่ง kill เนื่องจากหน่วยความจำไม่เพียงพอ ให้กำหนดค่า memory_limit และ threads อย่างชัดเจน เพื่อให้คิวรีทำงานช้าลงแทนที่จะล้มเหลว
โทเค็นควรใช้วิธีวัดค่าจริงแทนการคาดการณ์ ทุกการตอบกลับจาก Messages API จะมีออบเจกต์ usage ซึ่งระบุค่า input_tokens และ output_tokens ให้บันทึกค่าทั้งสองลงในตารางทุกครั้งที่มีการเรียกใช้งาน หลังจากผ่านไปหนึ่งสัปดาห์คุณจะทราบปริมาณการใช้งานจริง ซึ่งคุณสามารถนำไปคูณกับราคาที่โมเดลระบุไว้ในวันที่ตรวจสอบได้ มีสองสิ่งที่ค่อนข้างคงที่สำหรับการวางแผน: โทเค็นขาออก (output tokens) มีราคาสูงกว่าโทเค็นขาเข้า (input tokens) ในทุกโมเดลของ Claude ดังนั้นการจำกัดความยาวของโน้ตไว้ที่ 200 คำจะช่วยลดค่าใช้จ่ายได้มากกว่าการตัดทอนข้อมูลที่คุณส่งไป และ prompt caching ไม่ช่วยงานที่ทำเพียงวันละครั้ง เนื่องจาก cache มีอายุการใช้งานเริ่มต้นเพียง 5 นาที และอายุการใช้งานที่ยาวที่สุดที่ Anthropic เสนอคือ 1 ชั่วโมง ซึ่งมีค่าใช้จ่ายในการเขียนสูงถึง 2 เท่าของราคา input พื้นฐาน เทียบกับ 1.25 เท่าสำหรับ cache 5 นาที ไม่ว่าจะกรณีใด เมื่อถึงการรันงานในวันถัดไป บล็อกที่แคชไว้จะหมดอายุและคุณต้องจ่ายค่า input เต็มจำนวนอีกครั้ง การแคชจะคุ้มค่าก็ต่อเมื่อการรันงานหนึ่งครั้งมีการเรียกใช้งานหลายครั้งโดยใช้บล็อกข้อความขนาดใหญ่ชุดเดิม
ติดตั้งส่วนประกอบต่างๆ
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 ของระบบ Ubuntu 24.04 จะหยุดการทำงานของคุณด้วยข้อความ error: externally-managed-environment เนื่องจากระบบจัดการแพ็กเกจของดิสทริบิวชันเป็นเจ้าของ /usr/lib/python3 และไม่อนุญาตให้ pip เขียนข้อมูลลงในนั้น การใช้ venv ไม่ใช่เรื่องของมารยาท แต่เป็นไดเรกทอรีเดียวที่ pip ได้รับอนุญาตให้เข้าถึง
เก็บ API key ไว้ในไฟล์ที่ผู้ใช้บริการ (service user) สามารถอ่านได้เพียงผู้เดียวและไม่มีใครอื่นเข้าถึงได้:
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 จะแยกวิเคราะห์ไฟล์นี้ด้วยตัวเองแทนการส่งต่อไปยังเชลล์:
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 conPrimary key บน (ticker, day) คือสิ่งที่ทำให้การรีเฟรชข้อมูลสามารถทำซ้ำได้อย่างปลอดภัย INSERT OR REPLACE จะเขียนทับแถวที่มีอยู่เดิมสำหรับ ticker และวันที่นั้นๆ ดังนั้นการรัน backfill ซ้ำสองครั้งจึงไม่ทำให้ข้อมูลในตารางเพิ่มขึ้นเป็นสองเท่า หากไม่มี key นี้ การรันซ้ำหลังจากระบบขัดข้องจะทำให้เกิดข้อมูลซ้ำซ้อนในทุกแท่งราคาโดยไม่มีการแจ้งเตือน และค่าเฉลี่ยทุกค่าที่คุณคำนวณหลังจากนั้นจะผิดพลาดโดยไม่มีข้อความแสดงข้อผิดพลาดใดๆ แจ้งให้คุณทราบ
DuckDB อนุญาตให้มีเพียงหนึ่งกระบวนการ (process) เท่านั้นที่เปิดไฟล์เพื่อเขียนข้อมูลได้ หากมีกระบวนการที่สองพยายามเขียนจะล้มเหลวทันทีด้วย Could not set lock on file ตามด้วย PID ของกระบวนการที่ถือสิทธิ์การเขียนอยู่ ซึ่งในทางปฏิบัติมักจะเป็น shell duckdb แบบโต้ตอบที่คุณเปิดทิ้งไว้ใน terminal อื่น การเชื่อมต่อแบบอ่านอย่างเดียว (read only) ไม่สามารถหลีกเลี่ยงข้อจำกัดนี้ได้ เอกสารเรื่อง concurrency ของ DuckDB อธิบายไว้สองโหมด คือหนึ่งกระบวนการที่อ่านและเขียนได้ หรือหลายกระบวนการที่อ่านได้อย่างเดียว ไม่สามารถผสมทั้งสองอย่างได้ connect รองรับ flag read_only เพื่อให้ผู้อ่านหลายรายแชร์ไฟล์ร่วมกันได้ ซึ่งจะใช้งานได้ก็ต่อเมื่อไม่มีผู้เขียนเปิดไฟล์นั้นอยู่ หากมีหลายกระบวนการที่จำเป็นต้องเขียนข้อมูลพร้อมกันจริงๆ นั่นเป็นหน้าที่ของเอนจินอื่น: 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การรันครั้งแรกจะเป็นการดึงข้อมูลย้อนหลังหลายปีและแสดงผลทีละบรรทัดต่อหนึ่ง ticker โดยมีจำนวนแถวหลักพัน หากรันซ้ำอีกครั้งในอีกหนึ่งนาทีถัดมา แต่ละบรรทัดควรแสดงเพียง 1 หรือ 2 แถว เนื่องจากงานจะเริ่มดึงข้อมูลต่อจากวันล่าสุดที่จัดเก็บไว้แล้ว การรันครั้งที่สองนี้คือการทดสอบที่แท้จริง หากจำนวนแถวยังคงเป็นหลักพัน แสดงว่า max(day) ไม่ส่งค่าใดๆ กลับมา และการ insert กำลังเขียนทับประวัติข้อมูลทั้งหมดของคุณใหม่ทุกคืน
การตรวจสอบด้วย df.empty เป็นบรรทัดที่สำคัญที่สุดในไฟล์ Ticker.history() จะไม่แจ้งเตือนข้อผิดพลาดสำหรับสัญลักษณ์ที่ผิดหรือถูกถอดออกจากตลาด (delisted) มันจะเพียงแค่พิมพ์คำเตือนว่าสัญลักษณ์นั้นอาจถูกถอดออกจากตลาดและไม่พบข้อมูลราคา (ข้อความที่แน่นอนอาจแตกต่างกันไปตามเวอร์ชันของไลบรารี) จากนั้นจะส่งคืน DataFrame ว่างเปล่า งานที่ไม่มีการตรวจสอบนี้จะไม่เขียนข้อมูลใดๆ ลงไป แต่จะจบการทำงานด้วยสถานะ 0 ทำให้ systemd แสดงสถานะการทำงานปกติเป็นสีเขียว ในขณะที่ตารางข้อมูลหยุดอัปเดตไปอย่างเงียบๆ คุณจะพบปัญหานี้ในอีกหลายสัปดาห์ต่อมา จากหน้าจอที่แสดงข้อมูลเดิมซ้ำๆ ทุกวัน
การจัดการ timestamp มีเหตุผลรองรับเช่นกัน ดัชนีที่ feed ส่งกลับมาอาจมีเขตเวลาของตลาดหลักทรัพย์ติดมาด้วย และการตัด offset ออกไม่เหมือนกับการแปลงเป็น UTC เซสชันของตลาดโตเกียวที่ประทับเวลาไว้ตอนเที่ยงคืนตามเวลาท้องถิ่นจะถูกแปลงเป็นวันก่อนหน้าใน UTC ดังนั้นการแปลงเป็น UTC จะเลื่อนแท่งข้อมูลของญี่ปุ่นย้อนหลังไปหนึ่งวันโดยไม่แจ้งเตือน และจะทำให้ primary key เสียหาย tz_localize(None) จะคงวันที่ของเซสชันตามตลาดหลักทรัพย์ไว้ ซึ่งเป็นความหมายที่แท้จริงของแท่งข้อมูลรายวัน
ตัวตั้งเวลาที่ทำงานเมื่อตลาดปิด
ตลาดหุ้นสหรัฐฯ ปิดทำการเวลา 16:00 น. ตามเวลาในนิวยอร์ก และข้อมูลการซื้อขายล่าสุดต้องใช้เวลาสองสามนาทีในการประมวลผลให้เสร็จสิ้น งานนี้จึงถูกตั้งให้ทำงานเวลา 16:20 น. ให้ระบุเวลาตามเขตเวลานิวยอร์ก ไม่ใช่ UTC นิวยอร์กมีเวลาเป็น UTC ลบ 5 ในช่วงฤดูหนาว และ UTC ลบ 4 ในช่วงฤดูร้อน ดังนั้นหากเขียนตัวตั้งเวลาเป็นชั่วโมง UTC แบบคงที่ เวลาจะคลาดเคลื่อนไปหนึ่งชั่วโมงปีละสองครั้งและเริ่มทำงานก่อนตลาดปิด systemd เวอร์ชัน 235 ขึ้นไปรองรับการระบุเขตเวลาโดยตรงใน 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 เป็น service type ประเภทเดียวที่ยอมรับ ExecStart ได้มากกว่าหนึ่งรายการ โดยจะรันตามลำดับและหยุดทำงานหากมีรายการใดรายการหนึ่งส่งค่า exit code ที่ไม่ใช่ศูนย์ ซึ่งเป็นพฤติกรรมที่ต้องการพอดี เพราะการรีเฟรชที่ล้มเหลวไม่ควรตามด้วยการแสดงผลข้อมูลที่เก่าแล้ว Persistent=true มีความสำคัญบน VPS ที่ต้องรีบูตเพื่ออัปเดต kernel หากรีบูตตอน 16:15 น. โดยไม่มีตัวเลือกนี้ งานที่ต้องรันจะหายไปทันที แต่หากมีตัวเลือกนี้ งานจะรันทันทีที่เครื่องกลับมาออนไลน์ สำหรับเอเจนต์ที่ต้องทำงานตลอดเวลาแทนที่จะรันแล้วจบไป จะต้องใช้โครงสร้าง unit แบบอื่น คือเป็นกระบวนการที่ทำงานยาวนานโดยใช้กฎ Restart= และไม่มีตัวตั้งเวลา ซึ่งอธิบายไว้ใน การรัน dsh แบบ headless บน VPS ด้วย systemd
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 จะคำนวณค่าเฉลี่ยของแถวข้อมูลที่มีอยู่ทั้งหมด ดังนั้นแถวที่สามของ ticker จะส่งคืนค่าเฉลี่ยของสามวันและยังคงเรียกมันว่า ma50 หากเปรียบเทียบค่านี้กับ ma20 คุณจะสร้างจุดตัด (crossover) ปลอมขึ้นมาในช่วงเริ่มต้นของประวัติ ticker ทุกตัวซึ่งไม่เคยเกิดขึ้นจริง การกรองด้วยหมายเลขแถวจะช่วยตัดแถวที่หน้าต่างข้อมูล (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,
thinking={'type': 'disabled'},
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()บรรทัด thinking มีความสำคัญต่อโมเดลนี้ โดยปกติแล้ว Claude Sonnet 5 จะใช้กระบวนการคิด (thinking) ซึ่งโทเค็นที่ใช้ในการคิดจะถูกคิดค่าใช้จ่ายในฐานะโทเค็นขาออกและนับรวมใน max_tokens โดยโทเค็นเหล่านี้จะแสดงผลเป็นบล็อกก่อนเนื้อหาหลัก หากไม่มีบรรทัดดังกล่าว resp.content[0] อาจกลายเป็นบล็อกการคิด และการค้นหา .text จะล้มเหลว ปัจจุบัน Claude Sonnet 5 ถือเป็นโมเดลรุ่นเก่า ส่วนรุ่นถัดมาคือ claude-sonnet-5-5 จะปฏิเสธ disabled โดยการตั้งค่าที่ต้องใช้คือ thinking={'type': 'between_tools'} ซึ่งเป็นการปิดการทำงานของกระบวนการคิดก่อนที่จะแสดงคำตอบ
การจำกัดจำนวนคำใน system prompt ช่วยควบคุมค่าใช้จ่ายในส่วนที่สูงกว่าได้ คำสั่งให้ใช้เฉพาะแถวข้อมูลที่กำหนดให้เป็นสิ่งที่คุณต้องตรวจสอบด้วยตนเองแทนการเชื่อถือเพียงอย่างเดียว ให้ลองลบคอลัมน์หนึ่งออกจากบล็อกข้อมูลแล้วรันคำสั่งใหม่อีกครั้งเพื่ออ่านผลลัพธ์ หากตัวเลขของคอลัมน์นั้นยังคงปรากฏอยู่ แสดงว่าโมเดลได้เติมข้อมูลส่วนที่ขาดหายไปเอง ซึ่งหมายความว่า prompt ของคุณยังไม่รัดกุมพอ การทดสอบนี้ใช้เวลาเพียงสองนาทีและเป็นวิธีเดียวที่เชื่อถือได้ในการตรวจสอบ
โมเดลไม่เคยเห็น API key ไม่เคยเห็น path ของฐานข้อมูล และไม่เคยรัน query ใดๆ โมเดลเพียงแค่รับแถวข้อมูลและส่งคืนเป็นข้อความ ขอบเขตนี้เองที่ทำให้ผลลัพธ์สามารถตรวจสอบได้ เนื่องจากตัวเลขทุกตัวในบันทึกควรปรากฏอยู่ในบล็อกข้อมูลที่คุณส่งไป และคุณสามารถเปรียบเทียบข้อมูลแบบบรรทัดต่อบรรทัดได้ ทันทีที่คุณขยายขอบเขตข้อมูลขาเข้าให้รวมถึงหน้าเว็บที่ดึงมาจากอินเทอร์เน็ต ขอบเขตดังกล่าวจะเปลี่ยนรูปแบบไป เนื่องจากพาดหัวข่าวหรือโพสต์ในฟอรัมอาจมีคำสั่งที่มุ่งเป้าไปที่โมเดลแทนที่จะเป็นข้อเท็จจริงสำหรับคุณ ซึ่งเป็นความเสี่ยงที่ การให้ SearXNG search backend แก่เอเจนต์ เปิดช่องไว้ สำหรับข้อมูลเพิ่มเติมในด้านการเขียน prompt การใช้ Claude สำหรับการวิเคราะห์ทางการเงิน ได้อธิบายรายละเอียดเพิ่มเติมเกี่ยวกับสิ่งที่โมเดลมีความสามารถในการอ่านได้ดี
การทำงานแบบครบวงจรในรอบเดียว
เมื่อถึงเวลา 16:20 น. ตามเวลาในนิวยอร์ก ตัวตั้งเวลาจะเริ่มการทำงานของบริการ refresh.py จะร้องขอข้อมูลจากฟีดสำหรับแต่ละ ticker โดยเริ่มจากวันที่จัดเก็บล่าสุด จากนั้นจะเขียนข้อมูลแท่งใหม่ 1 หรือ 2 แท่งต่อ ticker และพิมพ์บรรทัดแสดงผลสำหรับแต่ละ ticker ออกมา screen.py จะเปิดไฟล์เดียวกัน ดำเนินการคิวรีค่าเฉลี่ยเคลื่อนที่ (moving average) และรับผลลัพธ์กลับมาจำนวนหนึ่ง แถวข้อมูลเหล่านั้นจะกลายเป็นบล็อกข้อความขนาดไม่กี่ร้อยโทเค็น การเรียกใช้ API หนึ่งครั้งจะเปลี่ยนข้อมูลเหล่านั้นให้เป็นบันทึกย่อสั้นๆ บันทึกดังกล่าวจะถูกส่งไปยังวารสาร และข้อมูลหนึ่งแถวจะถูกบันทึกลงใน runs พร้อมด้วยจำนวนโทเค็นและจำนวนครั้งที่พบข้อมูล
journalctl -u research-refresh.service -n 50 --no-pagerlog ที่สมบูรณ์ควรมีหนึ่งบรรทัดต่อ ticker ตามด้วยบันทึกย่อ และปิดท้ายด้วย research-refresh.service: Deactivated successfully หากคุณกำลังอ่าน log นั้นจากเซสชัน Claude Code บนเซิร์ฟเวอร์ ในขณะที่เซสชันที่สองกำลังทำงานกับการคิวรีบนหน้าจอ ทั้งสองเซสชันสามารถส่งข้อความถึงกันได้ แทนที่คุณจะต้องคัดลอกบรรทัดระหว่างเทอร์มินัล หลังจากผ่านไปหนึ่งสัปดาห์ ให้ตรวจสอบค่าใช้จ่ายของคุณจากฐานข้อมูล:
SELECT count(*) AS runs,
sum(input_tokens) AS in_tokens,
sum(output_tokens) AS out_tokens
FROM runs;ให้นำยอดรวมเหล่านั้นคูณกับราคาต่อล้านโทเค็นที่โมเดลของคุณระบุไว้ในวันที่คุณตรวจสอบ วิธีนี้จะทำให้คุณได้ตัวเลขจริงแทนที่จะเป็นเพียงการประมาณการของผู้อื่น ราคาที่ประกาศไว้อาจมีการเปลี่ยนแปลง แต่หลักการคำนวณยังคงเดิม
เหตุใดการทำ backtest จึงเกิดการ overfit และวิธีสังเกตการณ์
ให้เขียนฟังก์ชัน screen โดยใช้ความยาวของ window สองค่า จากนั้นทำการ sweep ค่าผ่านตารางคู่ตัวเลข แล้วจัดอันดับตามผลตอบแทน คู่ที่ให้ผลลัพธ์ดีที่สุดจะดูยอดเยี่ยมมาก แต่นั่นคือปัญหา ไม่ใช่ผลลัพธ์ที่แท้จริง การทำ grid search จำนวน 200 คู่ คือการทดลอง 200 ครั้ง และคุณเพียงแค่เลือกผลลัพธ์ที่โชคดีที่สุดออกมาเท่านั้น
คุณสามารถสังเกตกระบวนการนี้ได้ภายในสิบนาที ให้แบ่งข้อมูลใน store ออกเป็นสองส่วนตามวันที่ ทำการ sweep ตารางบนข้อมูลครึ่งแรกเท่านั้นแล้วจดบันทึกผู้ชนะไว้ จากนั้นทำซ้ำบนข้อมูลครึ่งหลัง หากผู้ชนะของทั้งสองช่วงมีความแตกต่างกันมาก แสดงว่าพารามิเตอร์เหล่านั้นกำลังปรับเข้าหา noise และคู่พารามิเตอร์ที่ชนะเฉพาะในช่วงที่คุณปรับแต่งนั้น ไม่สามารถบ่งบอกถึงอนาคตได้เลย
Survivorship bias นั้นเลวร้ายยิ่งกว่าการ overfit เพราะการปรับแต่งพารามิเตอร์ไม่สามารถแก้ไขปัญหานี้ได้ รายชื่อ ticker ของคุณคือสมาชิกดัชนีในปัจจุบัน ดังนั้นมันจึงประกอบด้วยบริษัทที่รอดชีวิตมาได้เท่านั้น หากคุณเรียกข้อมูล ticker ที่ถูกเพิกถอนออกจากตลาดในปี 2019 ระบบจะส่งกลับมาเป็นกรอบข้อมูลว่างเปล่า ซึ่งหมายความว่าบริษัทนั้นไม่เคยเข้าสู่ store ของคุณและไม่เคยเข้าสู่การทดสอบของคุณ ทุกการทำ backtest ที่คุณรันจึงได้คัดออกเฉพาะบริษัทที่ล้มเหลวไปเรียบร้อยแล้ว
การปรับปรุงข้อมูลพื้นฐาน (Restated fundamentals) ทำให้เส้นเวลาบิดเบือน ตัวเลขรายได้ที่ API ส่งกลับมาในวันนี้สำหรับไตรมาสปี 2019 ไม่ใช่ตัวเลขเดียวกับที่ประกาศในปี 2019 เสมอไป การทำ screen ที่ผสมผสานข้อมูลพื้นฐานปัจจุบันเข้ากับราคาในปี 2019 คือการใช้ข้อมูลที่ยังไม่มีอยู่จริงในขณะนั้น โดยปกติแล้วราคาจะมีความปลอดภัยในประเด็นนี้ แต่ข้อมูลพื้นฐานมักจะไม่เป็นเช่นนั้น
ราคาที่ปรับปรุงแล้ว (Adjusted prices) เปลี่ยนแปลงอยู่ตลอด ด้วย auto_adjust=True ราคาปิดจะถูกปรับย้อนหลังสำหรับเงินปันผลและการแตกหุ้น ดังนั้นการรัน query เดิมในเดือนหน้าจะให้ประวัติข้อมูลที่แตกต่างออกไปเล็กน้อย การจัดเก็บแถวข้อมูลที่คุณใช้จริงคือสิ่งที่ทำให้ผลลัพธ์สามารถทำซ้ำได้ และเป็นอีกเหตุผลหนึ่งว่าทำไมจึงต้องมี local store
นอกจากนี้ การทำ backtest ยังละเลยค่าคอมมิชชันและ slippage รวมถึงสมมติว่าคำสั่งซื้อของคุณไม่ส่งผลกระทบต่อราคา สิ่งเหล่านี้เป็นเรื่องของการ execution ซึ่งอยู่นอกเหนือขอบเขตของเนื้อหานี้ และมีการครอบคลุมไว้ใน การรันบอทเทรดบน VPS
รูปแบบความล้มเหลวและข้อความที่คุณจะพบ
error: externally-managed-environment เมื่อรัน pip คุณอยู่นอก virtual environment ให้เรียกใช้ /opt/research/venv/bin/pip ด้วยพาธเต็ม
Could not set lock on file ตามด้วย PID มีโพรเซสอื่นเปิดไฟล์ DuckDB ค้างไว้ในโหมดเขียน ซึ่งมักจะเป็น interactive shell ที่คุณลืมปิด ให้ปิดโพรเซสนั้น การเปิดการเชื่อมต่อที่สองด้วย read_only=True ไม่ช่วยแก้ปัญหาในขณะที่ตัวเขียนยังทำงานอยู่ เพราะ DuckDB อนุญาตให้หลายโพรเซสอ่านพร้อมกันได้ หรือให้โพรเซสเดียวเขียนเท่านั้น ไม่สามารถทำทั้งสองอย่างพร้อมกันได้
Main process exited, code=killed, status=9/KILL ใน systemctl status เคอร์เนลสั่งยุติงานเนื่องจากหน่วยความจำไม่เพียงพอ ให้ยืนยันด้วย journalctl -k | grep -i oom จากนั้นลดค่า memory_limit ใน store.py
การรันที่สำเร็จแต่ไม่เขียนข้อมูลใดๆ systemctl status อ่าน active (exited) และตารางไม่มีข้อมูลเพิ่มขึ้น ฟีดส่งคืนเฟรมที่ว่างเปล่า ความล้มเหลวนี้ตรวจพบได้ยากที่สุด ดังนั้นควรตั้งค่าให้งานจบการทำงานด้วยสถานะ non-zero เมื่อ ticker ทุกตัวส่งค่าว่างกลับมา
ตัวจับเวลาทำงานในวันหยุดตลาด systemd ไม่ทราบปฏิทินของตลาดหลักทรัพย์ ดังนั้น Mon-Fri จึงรวมวันหยุดด้วย การรันจะเกิดขึ้น ฟีดไม่มีข้อมูลใหม่ และงานควรจัดการกรณีนี้ว่าเป็นเรื่องปกติแทนที่จะเป็นข้อผิดพลาด
429 จาก API คุณใช้งานเกินขีดจำกัดอัตรา (rate limit) แล้ว Anthropic SDK จะทำการลองใหม่โดยใช้ backoff โดยอัตโนมัติ และ Anthropic(max_retries=5) จะเพิ่มจำนวนครั้งที่พยายาม หากยังคงล้มเหลวทุกวัน แสดงว่างานของคุณมีการร้องขอข้อมูลมากเกินไปในการส่งครั้งเดียว
สิ่งที่นี่ไม่ใช่
นี่คือผู้ช่วยวิจัย โมเดลที่สรุปเอกสารจะสร้างการอ่านเอกสารนั้นขึ้นมา และอาจให้ข้อมูลตัวเลขที่พิมพ์อยู่ในข้อความผิดพลาดได้อย่างมั่นใจ ดังนั้นตัวเลขทุกตัวในบันทึกนี้จะต้องสามารถตรวจสอบย้อนกลับไปยังแถวข้อมูลที่คุณส่งมาได้ ให้ถือว่าผลลัพธ์นี้เป็นเพียงรายการย่อสำหรับสิ่งที่คุณควรกลับไปอ่านด้วยตนเอง เนื้อหาที่นี่ไม่ใช่คำแนะนำทางการเงินและไม่ใช่สัญญาณการซื้อขายแต่อย่างใด
การทดสอบย้อนหลัง (backtest) มีประโยชน์สำหรับการคัดค้านแนวคิดที่ไม่ดี แต่มีประสิทธิภาพต่ำในการยืนยันแนวคิด กลยุทธ์ที่ล้มเหลวบนข้อมูลของคุณถือว่าใช้ไม่ได้จริง ส่วนกลยุทธ์ที่ผ่านการทดสอบนั้นเป็นเพียงการรอดพ้นจากข้อมูลของคุณเท่านั้น ซึ่งเป็นข้อสรุปที่จำกัดกว่าความรู้สึกที่คุณอาจมีในช่วงเวลาตี 1
การดำเนินการซื้อขายอยู่นอกเหนือขอบเขตของงานนี้โดยเจตนา คำสั่งซื้อขายและข้อมูลรับรองของโบรกเกอร์มีความเสี่ยงที่แตกต่างจากการใช้งานเพื่อการวิจัยแบบอ่านอย่างเดียว การนำมารวมกันจะทำให้คีย์สำหรับการซื้อขายไปอยู่บนเครื่องเดียวกับที่ใช้ประมวลผล LLM หากคุณต้องการทราบว่ารูปแบบนี้มีความเกี่ยวข้องอย่างไรกับสิ่งอื่นๆ ที่ควรติดตั้งบนเซิร์ฟเวอร์ของคุณ เอเจนต์ AI แบบ self-hosted ที่น่าใช้งาน คือภาพรวมที่กว้างขึ้นของเรื่องนี้
FAQ
ฉันจำเป็นต้องใช้ข้อมูลตลาดแบบเสียค่าบริการหรือไม่?
ไม่จำเป็นสำหรับช่วงต้นแบบ ข้อมูลฟรีอย่างไม่เป็นทางการเพียงพอสำหรับการเรียนรู้โครงสร้างระบบ แต่ข้อมูลเหล่านี้อาจใช้งานไม่ได้เนื่องจากขึ้นอยู่กับเว็บไซต์ที่ไม่มีพันธะผูกพันกับคุณ โดยปกติแล้วข้อมูลมักจะขาดหายไปในรูปแบบเฟรมว่างแทนที่จะเกิดข้อยกเว้น ดังนั้นงานของคุณต้องตรวจสอบจำนวนแถวข้อมูลเสมอ ให้เปลี่ยนไปใช้ข้อมูลแบบเสียค่าบริการที่มี API ระบุไว้อย่างชัดเจนและมีช่องทางติดต่อสนับสนุนเมื่อข้อมูลเริ่มถูกนำไปใช้ประกอบการตัดสินใจ การใช้ store จะช่วยให้การเปลี่ยนผ่านนี้ทำได้ง่ายขึ้น เพราะคุณเพียงแค่เปลี่ยนฟังก์ชัน fetch ส่วนตารางเวลา (schedule), โครงสร้างข้อมูล (schema) และหน้าจอ (screen) จะยังคงเหมือนเดิม
ฉันควรเก็บราคาไว้ใน SQLite หรือ DuckDB?
DuckDB เป็นฐานข้อมูลแบบ columnar ซึ่งออกแบบมาเพื่อการสแกนข้อมูลจำนวนมากเพื่อคำนวณค่ารวม ซึ่งเหมาะอย่างยิ่งกับการคำนวณค่าเฉลี่ยเคลื่อนที่จากข้อมูลย้อนหลัง 10 ปี ส่วน SQLite เป็นแบบ row-oriented ซึ่งจัดการการอ่านและเขียนข้อมูลขนาดเล็กจากหลายกระบวนการพร้อมกันได้ดีกว่า สำหรับงานที่ตั้งเวลาไว้ซึ่งมีการเพิ่มข้อมูลเพียงไม่กี่ร้อยแถวแล้วสแกนข้อมูลนับล้าน DuckDB จะเป็นตัวเลือกที่เหมาะสมกว่า หากจำเป็นต้องมีหลายกระบวนการเขียนข้อมูลพร้อมกัน SQLite ในโหมด WAL จะช่วยให้ผู้อ่านทำงานได้ในขณะที่ผู้เขียนกำลัง commit และการตั้งค่า busy timeout จะช่วยให้ผู้เขียนรายอื่นรอแทนที่จะล้มเหลวทันที
การเรียกใช้งาน LLM มีค่าใช้จ่ายต่อเดือนเท่าไร?
ให้บันทึก usage.input_tokens และ usage.output_tokens จากทุกการตอบกลับลงในตาราง จากนั้นนำผลรวมรายสัปดาห์ไปคูณกับราคาต่อล้านโทเค็นที่โมเดลของคุณระบุไว้ในวันที่ตรวจสอบ นั่นเป็นตัวเลขเดียวที่จะยังคงถูกต้องในไตรมาสถัดไป การรันงานรายวันเพื่อคัดกรองข้อมูลเพียงเล็กน้อยจะใช้จำนวนการเรียกใช้งานไม่มาก และความยาวของบันทึกคือส่วนที่คุณควบคุมได้ การจำกัดสรุปผลไว้ที่ 200 คำจะช่วยประหยัดค่าใช้จ่ายได้มากกว่าการลดจำนวนแถวข้อมูล เนื่องจากโทเค็นขาออก (output tokens) มีราคาสูงกว่าโทเค็นขาเข้า (input tokens) ในโมเดล Claude ทุกรุ่น
ทำไมงานของฉันถึงสำเร็จแต่ไม่มีการเขียนแถวข้อมูลใหม่?
มีสาเหตุทั่วไปสองประการ ประการแรกคือตลาดปิดทำการ เนื่องจากตารางเวลาของ systemd Mon-Fri รวมวันหยุดของตลาดหลักทรัพย์ไว้ด้วย หรือประการที่สองคือ feed ส่งคืนเฟรมว่างสำหรับทุก ticker ซึ่งไลบรารีฝั่งไคลเอนต์มักจะรายงานเป็นเพียงคำเตือนที่พิมพ์ออกมาแทนที่จะเป็นข้อยกเว้น ทำให้กระบวนการยังคง exit 0 และ systemd ยังคงแสดงสถานะการทำงานเป็นสีเขียว คุณสามารถแยกแยะสาเหตุได้โดยการเปรียบเทียบ SELECT max(day) FROM prices กับวันซื้อขายจริงล่าสุด และกำหนดให้งาน exit ด้วยค่าที่ไม่ใช่ศูนย์หากทุก ticker ส่งค่าว่างกลับมา
เอเจนต์สามารถตัดสินใจว่าจะซื้ออะไรได้หรือไม่?
ไม่ได้ และการสร้างเอเจนต์เพื่อทำหน้าที่นี้คือจุดที่โปรเจกต์เหล่านี้มักจะล้มเหลว โมเดลไม่มีสิทธิ์เข้าถึงตลาด ไม่มีข้อมูลเกี่ยวกับสถานะการถือครองหรือสถานการณ์ทางภาษีของคุณ และไม่มีวิธีตรวจสอบตัวเลขของตนเองกับแหล่งข้อมูลอื่น สิ่งที่โมเดลทำได้ดีคือการอ่านข้อความจำนวนมากและบอกคุณว่ารายการใดบ้างที่ควรค่าแก่การให้ความสนใจในวันนี้ สิ่งที่โมเดลเขียนไม่ใช่คำแนะนำทางการเงิน และการตัดสินใจรวมถึงความรับผิดชอบที่ตามมายังคงเป็นของคุณเพียงผู้เดียว