SSD Nodes Learn Hosting plans →
คู่มือ Matt Connorโดย Matt Connor · อัปเดตเมื่อ 2026-08-13

วิธีสร้าง Stock Research Agent บน VPS ด้วยตัวเอง

สร้างระบบวิเคราะห์หุ้นอัตโนมัติบน VPS ด้วย Python และ DuckDB พร้อมตั้งค่า systemd timer ดึงข้อมูลหลังตลาดปิดและใช้ LLM คัดกรองหุ้นตามเงื่อนไขที่คุณกำหนดเองอย่างแม่นยำ

Stock research agent แบบ self-hosted คืออะไร

Stock research agent แบบ self-hosted คือโปรแกรมขนาดเล็กที่รันบนเซิร์ฟเวอร์ของคุณเอง ทำหน้าที่ดึงข้อมูลตลาดตามกำหนดเวลา จัดเก็บไว้ในฐานข้อมูลภายใน คัดกรองข้อมูลตามเงื่อนไข และส่งข้อมูลให้ Large Language Model (LLM) สรุปการเปลี่ยนแปลงที่เกิดขึ้น โปรแกรมนี้ทำหน้าที่อ่านและคัดกรองเท่านั้น ไม่ได้มีไว้เพื่อการซื้อขาย และเนื้อหาในคู่มือนี้ไม่ใช่คำแนะนำทางการเงิน

แม้ผู้พัฒนาสองคนที่สร้างระบบนี้ขึ้นมาใหม่จะเลือกใช้ไลบรารีที่แตกต่างกัน แต่ท้ายที่สุดแล้วระบบจะมีองค์ประกอบหลัก 4 ส่วนเหมือนกัน ได้แก่ feed สำหรับดึงราคาและข้อมูลพื้นฐาน, local store สำหรับเก็บข้อมูลทุกแถวที่เคยดึงมา, job สำหรับรีเฟรชข้อมูลใน store ตามเวลาที่กำหนด และชั้น LLM สำหรับแปลงข้อมูลที่ผ่านการคัดกรองให้เป็นข้อความ คู่มือนี้จะสร้างระบบดังกล่าวโดยใช้ Python, DuckDB, systemd timer และ Claude API (application programming interface) การดำเนินการซื้อขายเป็นอีกงานหนึ่งที่มีรูปแบบความล้มเหลวเฉพาะตัว ซึ่งควรแยกไปรันบน VPS ที่ตั้งค่าไว้สำหรับ trading bot แทน

ส่วนประกอบทั้งสี่และหน้าที่ของแต่ละส่วน

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 วัน

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"
  }
]

หลักทรัพย์ 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 จะแสดงเหตุการณ์ out of memory kill ให้กำหนดค่า memory_limit และ threads อย่างชัดเจน แล้วคิวรีจะทำงานช้าลงแทนที่จะหยุดทำงานไปเลย

จำนวนโทเค็นควรวัดค่าจริง ไม่ใช่การคาดการณ์ การตอบกลับของ Messages API ทุกครั้งจะมีออบเจกต์ usage พร้อมด้วย input_tokens และ output_tokens ให้บันทึกค่าทั้งสองลงในตารางทุกครั้งที่มีการเรียกใช้งาน หลังจากผ่านไปหนึ่งสัปดาห์คุณจะทราบปริมาณการใช้งานจริง ซึ่งคุณสามารถนำไปคูณกับราคาที่โมเดลระบุไว้ในวันที่ตรวจสอบ มีสองสิ่งที่คงที่พอที่จะใช้ในการวางแผนได้ ประการแรก โทเค็นขาออก (output tokens) มีราคาสูงกว่าโทเค็นขาเข้า (input tokens) ในโมเดล Claude ทุกรุ่น ดังนั้นการจำกัดบันทึกไว้ที่ 200 คำจะช่วยลดค่าใช้จ่ายได้มากกว่าการตัดทอนข้อมูลที่คุณส่งไป ประการที่สอง prompt caching ไม่ช่วยงานที่ทำวันละครั้ง เพราะอายุของแคชมีหน่วยเป็นนาที เมื่อถึงรอบการทำงานถัดไป บล็อกที่แคชไว้จะหมดอายุและคุณต้องจ่ายค่า 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 con

Primary key บน (ticker, day) คือสิ่งที่ทำให้การรีเฟรชข้อมูลสามารถทำซ้ำได้อย่างปลอดภัย INSERT OR REPLACE จะเขียนทับแถวที่มีอยู่แล้วสำหรับ ticker และวันที่นั้นๆ ดังนั้นการรัน backfill ซ้ำจึงไม่ทำให้ข้อมูลในตารางเพิ่มขึ้นเป็นสองเท่า หากไม่มี key นี้ การรันซ้ำหลังจากระบบขัดข้องจะทำให้ข้อมูลทุก 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() จะไม่แจ้งเตือนข้อผิดพลาดสำหรับสัญลักษณ์ที่ผิดหรือถูกถอดออกจากตลาด (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 เวอร์ชัน 252 ขึ้นไปรองรับการระบุ timezone โดยตรงใน 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 เป็น service type เพียงประเภทเดียวที่ยอมรับ ExecStart ได้มากกว่าหนึ่งรายการ โดยจะทำงานตามลำดับและหยุดทำงานหากมีรายการใดรายการหนึ่งส่งค่า exit code ที่ไม่ใช่ศูนย์ ซึ่งเป็นพฤติกรรมที่คุณต้องการพอดี นั่นคือหากการรีเฟรชข้อมูลล้มเหลว จะต้องไม่มีการแสดงผลข้อมูลที่เก่าเกินไป Persistent=true มีความสำคัญบน VPS ที่ต้องรีบูตเพื่ออัปเดต kernel หากไม่มีการตั้งค่านี้และเครื่องรีบูตตอน 16:15 น. งานนั้นจะหายไปทันที แต่หากมีการตั้งค่านี้ งานจะทำงานทันทีที่เครื่องกลับมาออนไลน์

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

list-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,
    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 ของคุณยังรัดกุมไม่เพียงพอ การทดสอบนี้ใช้เวลาเพียง 2 นาทีและเป็นวิธีเดียวที่เชื่อถือได้ในการตรวจสอบ

โมเดลไม่เคยเห็น API key ไม่เคยเห็น path ของฐานข้อมูล และไม่เคยรัน query ใดๆ โมเดลเพียงแค่รับแถวข้อมูลและส่งกลับมาเป็นข้อความ ขอบเขตนี้เองที่ทำให้ผลลัพธ์สามารถตรวจสอบได้ เพราะตัวเลขทุกตัวในบันทึกควรปรากฏอยู่ในบล็อกข้อมูลที่คุณส่งไป และคุณสามารถเปรียบเทียบข้อมูลเหล่านั้นได้ทีละบรรทัด สำหรับข้อมูลเพิ่มเติมในด้านการเขียน prompt สามารถดูได้ที่ การใช้ Claude เพื่อวิเคราะห์ข้อมูลทางการเงิน ซึ่งจะอธิบายรายละเอียดเพิ่มเติมเกี่ยวกับสิ่งที่โมเดลมีความสามารถในการอ่านได้ดี

การทำงานแบบครบวงจรในรอบเดียว

เมื่อถึงเวลา 16:20 น. ตามเวลาในนิวยอร์ก ตัวตั้งเวลาจะเริ่มการทำงานของ service โดย refresh.py จะร้องขอข้อมูลจาก feed สำหรับแต่ละ ticker โดยเริ่มจากวันที่บันทึกล่าสุด จากนั้นจะเขียนข้อมูลแท่งราคาใหม่หนึ่งหรือสองแท่งต่อ ticker และพิมพ์บรรทัดแสดงผลสำหรับแต่ละ ticker ออกมา ในขณะที่ screen.py จะเปิดไฟล์เดียวกัน ดำเนินการคิวรีค่าเฉลี่ยเคลื่อนที่ (moving average) และรับข้อมูลกลับมาจำนวนหนึ่ง ข้อมูลเหล่านั้นจะกลายเป็นบล็อกข้อความขนาดไม่กี่ร้อยโทเค็น การเรียก API หนึ่งครั้งจะเปลี่ยนข้อมูลดังกล่าวให้เป็นบันทึกสั้นๆ ซึ่งบันทึกนี้จะถูกส่งไปยัง journal และข้อมูลหนึ่งแถวจะถูกบันทึกลงใน runs พร้อมกับจำนวนโทเค็นและจำนวนครั้งที่พบข้อมูล

journalctl -u research-refresh.service -n 50 --no-pager

log ที่สมบูรณ์ควรมีหนึ่งบรรทัดต่อ ticker ตามด้วยบันทึก และปิดท้ายด้วย research-refresh.service: Deactivated successfully หลังจากผ่านไปหนึ่งสัปดาห์ ให้ตรวจสอบค่าใช้จ่ายของคุณเองจากฐานข้อมูล:

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 ที่คุณลืมปิด ให้ปิด 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) แล้วแต่ตารางไม่มีข้อมูลเพิ่มขึ้น ฟีดส่งคืนเฟรมว่างเปล่า ความล้มเหลวนี้ตรวจพบได้ยากที่สุด ดังนั้นควรตั้งค่าให้งานจบการทำงานด้วยสถานะ 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 ส่งค่าว่างกลับมา

เอเจนต์สามารถตัดสินใจว่าจะซื้ออะไรได้หรือไม่?

ไม่ได้ และการสร้างเอเจนต์เพื่อทำหน้าที่นี้คือจุดที่โปรเจกต์เหล่านี้มักจะล้มเหลว โมเดลไม่มีสิทธิ์เข้าถึงตลาด ไม่มีข้อมูลเกี่ยวกับสถานะการถือครองหรือสถานการณ์ทางภาษีของคุณ และไม่มีวิธีตรวจสอบตัวเลขของตนเองกับแหล่งข้อมูลอื่น สิ่งที่โมเดลทำได้ดีคือการอ่านข้อความจำนวนมากและบอกคุณว่ารายการใดบ้างที่ควรค่าแก่การให้ความสนใจในวันนี้ สิ่งที่โมเดลเขียนไม่ใช่คำแนะนำทางการเงิน และการตัดสินใจรวมถึงความรับผิดชอบที่ตามมายังคงเป็นของคุณเพียงผู้เดียว