Tự host stock research agent trên VPS với DuckDB
Hướng dẫn dựng stock research agent tự host trên VPS bằng Python, DuckDB, market data feed, systemd timer chạy lúc đóng cửa và Claude API để lọc dữ liệu.
Stock research agent tự host là gì
Stock research agent tự host là một chương trình nhỏ chạy trên server do bạn sở hữu. Chương trình này định kỳ lấy dữ liệu thị trường, lưu dữ liệu vào database cục bộ, lọc dữ liệu, rồi yêu cầu một large language model (LLM) viết tóm tắt những thay đổi. Nó chỉ đọc và lọc dữ liệu. Nó không thực hiện giao dịch, và nội dung trong hướng dẫn này không phải là lời khuyên tài chính.
Hai người xây dựng chương trình này từ đầu có thể chọn các library khác nhau nhưng vẫn tạo ra cùng 4 phần: một feed cung cấp giá và dữ liệu fundamentals, một local store lưu mọi row đã từng được lấy, một job cập nhật store theo timer, và một lớp LLM chuyển các row còn lại thành câu. Hướng dẫn này xây dựng mô hình đó bằng Python, DuckDB, systemd timer và Claude API (application programming interface). Việc thực hiện giao dịch là một job riêng với các kiểu lỗi riêng, và nên chạy trên một VPS được thiết lập cho trading bot thay vào đó.
Bốn phần và chức năng của từng phần
Feed là phần duy nhất giao tiếp với thế giới bên ngoài. Nó biết cách yêu cầu dữ liệu theo mã ticker và khoảng ngày, cũng như trả về các dòng dữ liệu. Toàn bộ phần xử lý phía sau đọc database thay vì đọc feed. Vì vậy, khi feed bị gián đoạn, bạn chỉ mất dữ liệu mới của một ngày thay vì làm hỏng toàn bộ giao diện.
Store là mục tiêu chính của toàn bộ quy trình. Giá đóng cửa theo ngày mà bạn chưa ghi lại thường có thể lấy lại sau. Nhưng báo giá intraday, một ước tính trước khi được điều chỉnh, hoặc một chỉ số fundamentals trước khi được restate thì không thể. Store giúp bạn xây dựng bản ghi về dữ liệu thực tế đã được công bố vào đúng ngày đó.
Scheduler quyết định thời điểm refresh. Trên server, đó là systemd timer. Đây là lý do VPS quan trọng hơn code trong trường hợp này.
LLM layer đọc một đoạn văn bản ngắn do SQL tạo ra rồi viết bản tóm tắt từ đó. Nó không bao giờ kết nối trực tiếp đến database và cũng không tự tạo query. Nếu model viết SQL, chỉ một token sai cũng có thể biến thành một con số sai trong một câu văn trôi chảy, mà không có dữ liệu đối chiếu. Nếu SQL tạo ra các con số, model chỉ có thể viết sai phần diễn giải. Bạn có thể kiểm tra phần diễn giải bằng cách đối chiếu với các dòng dữ liệu đã gửi cho model.
Vì sao nên chạy trên VPS thay vì laptop
Lý do chính là scheduler. Thời điểm thị trường Mỹ đóng cửa lúc 16:00 theo giờ New York là 22:00 ở Berlin và 04:00 sáng hôm sau ở Jakarta. Laptop đang ở trạng thái sleep vào cả hai thời điểm đó. Một lần chạy bị bỏ lỡ gây thiệt hại nhiều hơn một ghi chú đến muộn: daily bar thường có thể fetch lại sau, nhưng dữ liệu đã được điều chỉnh thì không, nên khoảng trống trong record của bạn là vĩnh viễn.
Lý do thứ hai nhỏ hơn nhưng vẫn đáng kể. Server lưu một API key duy nhất trong một file, thuộc sở hữu của một system user duy nhất không có login shell và chỉ được một job sử dụng. Cách này khó thực hiện hơn nhiều trên laptop mà bạn cũng dùng để duyệt web. Để key ngoài code và ngoài input của model. Nội dung này được trình bày trong cách giữ API key ngoài AI agent.
Quy mô: disk, RAM và token
Disk là phần dễ tính. Mỗi bar hằng ngày là một row cho mỗi ticker trong mỗi ngày giao dịch, và một năm giao dịch ở Mỹ có khoảng 252 ngày.
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"
}
]Hai mươi ticker tạo ra 5,040 row sau một năm. Dòng 500 tickers đạt 1,260,000 row sau 10 năm. Mỗi row gồm một ngày và một số double, còn DuckDB lưu các column dưới dạng nén, nên dữ liệu chỉ chiếm vài chục megabyte thay vì gigabyte. Không nên tin tuyệt đối vào ước tính này, kể cả con số của tôi. Hãy chạy du -h /opt/research/data/market.duckdb sau lần backfill đầu tiên và dùng số liệu của chính bạn.
RAM mới là phần gây vấn đề trên một VPS nhỏ. Theo mặc định, DuckDB dùng phần lớn memory của máy và toàn bộ core cho một query. Cách này phù hợp với analytics server nhưng không phù hợp với máy 2 GB còn chạy các dịch vụ khác. Khi đó, một phép aggregation trên toàn bộ bảng prices có thể khiến kernel kill process, còn systemd báo Main process exited, code=killed, status=9/KILL trong khi journalctl -k cho thấy process đã bị kill do hết memory. Hãy đặt rõ memory_limit và threads để query chạy chậm hơn thay vì bị kill.
Token cần được đo, không nên ước tính. Mỗi response từ Messages API đều có object usage với input_tokens và output_tokens. Hãy ghi cả hai giá trị vào một table sau mỗi lần gọi. Sau một tuần, bạn sẽ biết volume thực tế và có thể nhân với mức giá model công bố tại thời điểm kiểm tra. Có 2 điểm đủ ổn định để lập kế hoạch. Output token luôn có giá cao hơn input token trên mọi Claude model, nên giới hạn note ở 200 từ giúp giảm chi phí nhiều hơn việc cắt bớt dữ liệu gửi đi. Prompt caching không giúp ích cho job chạy mỗi ngày một lần, vì cache chỉ tồn tại trong vài phút. Đến lần chạy tiếp theo, block đã cache sẽ hết hạn và bạn lại phải trả toàn bộ giá input. Caching có lợi khi một lần chạy thực hiện nhiều call trên cùng một block văn bản lớn.
Cài đặt các thành phần
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/dataKiểm tra việc cài đặt trước khi viết code:
sudo -u research /opt/research/venv/bin/python -c 'import duckdb, yfinance, anthropic; print("ok")'Lệnh này in ra ok. Nếu bỏ qua virtual environment và chạy pip install với Python hệ thống, Ubuntu 24.04 sẽ dừng với lỗi error: externally-managed-environment, vì bản phân phối quản lý /usr/lib/python3 và không cho pip ghi vào đó. venv không phải là tùy chọn để làm cho đúng quy trình. Đây là thư mục duy nhất mà pip được phép ghi.
Đặt API key trong một file mà user chạy service có thể đọc, còn các user khác thì không:
sudo install -d -m 755 /etc/research
sudo install -m 640 -o root -g research /dev/null /etc/research/env
sudoedit /etc/research/envĐặt một dòng vào file đó, không có dấu ngoặc kép và không có export, vì systemd tự phân tích file này thay vì truyền file cho shell:
ANTHROPIC_API_KEY=sk-ant-your-key-hereKho lưu trữ: hai bảng
# /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 conKhóa chính trên (ticker, day) giúp thao tác refresh có thể chạy lặp lại an toàn. INSERT OR REPLACE ghi đè hàng đã tồn tại cho ticker và ngày đó, nên chạy backfill hai lần không làm nhân đôi dữ liệu trong bảng. Nếu không có khóa này, một lần chạy lại sau khi bị crash sẽ âm thầm tạo bản sao cho mọi bar. Mọi giá trị trung bình tính sau đó đều sai, nhưng không có thông báo lỗi nào cho bạn biết.
DuckDB chỉ cho phép một process mở file để ghi tại một thời điểm. Writer thứ hai sẽ thất bại ngay với Could not set lock on file, kèm theo PID của process đang giữ file. Trong thực tế, process đó thường là shell duckdb tương tác mà bạn để mở trong một terminal khác. Reader vẫn hoạt động qua read_only=True, vì vậy connect nhận flag này. Nếu nhiều process thực sự cần ghi cùng lúc, bạn nên dùng engine khác: SQLite ở chế độ WAL cho phép reader hoạt động trong khi một writer commit, còn busy timeout khiến các writer khác chờ thay vì thất bại. DuckDB và SQLite trong workload server so sánh hai engine này, còn chạy SQLite trong môi trường production trên VPS trình bày các setting giúp WAL hoạt động đúng.
Job refresh
# /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()Chạy thủ công một lần:
sudo -u research /opt/research/venv/bin/python /opt/research/refresh.pyLần chạy đầu tiên sẽ bổ sung dữ liệu cho nhiều năm và in một dòng cho mỗi mã, với số lượng lên đến hàng nghìn. Chạy lại sau 1 phút, mỗi dòng chỉ còn 1 hoặc 2 bản ghi, vì job bắt đầu từ ngày cuối cùng đã được lưu. Lần chạy thứ hai mới là bài kiểm tra thực sự: nếu số lượng vẫn lên đến hàng nghìn, max(day) không trả về dữ liệu nào và thao tác insert đang ghi lại toàn bộ lịch sử của bạn mỗi đêm.
Kiểm tra df.empty là dòng quan trọng nhất trong file. Ticker.history() không báo lỗi khi mã không hợp lệ hoặc đã bị hủy niêm yết. Nó in cảnh báo rằng mã có thể đã bị hủy niêm yết và không tìm thấy dữ liệu giá (câu chữ chính xác thay đổi giữa các phiên bản library), rồi trả về một DataFrame rỗng. Job không có bước kiểm tra này sẽ không ghi dữ liệu, thoát với mã 0, và systemd vẫn hiển thị lần chạy thành công trong khi bảng âm thầm không tăng thêm. Bạn chỉ phát hiện ra sau nhiều tuần, khi một màn hình liên tục trả về cùng một số dòng mỗi ngày.
Việc xử lý timestamp cũng có lý do. Index do feed trả về có thể chứa timezone của sàn, và xóa offset không tương đương với chuyển đổi sang UTC. Một phiên giao dịch tại Tokyo có timestamp là nửa đêm theo giờ địa phương sẽ chuyển thành ngày lịch trước đó trong UTC. Vì vậy, chuyển đổi sang UTC một cách không chủ ý sẽ làm mọi bar của Nhật lùi lại 1 ngày và làm hỏng primary key. tz_localize(None) giữ nguyên ngày giao dịch của sàn, đúng với ý nghĩa của một daily bar.
Timer chạy lúc thị trường đóng cửa
Thị trường Mỹ đóng cửa lúc 16:00 theo giờ New York. Các giao dịch cuối cùng cần thêm vài phút để ổn định, nên job chạy lúc 16:20. Hãy ghi thời gian này theo giờ New York, không ghi theo UTC. New York là UTC trừ 5 vào mùa đông và UTC trừ 4 vào mùa hè. Vì vậy, timer dùng một giờ UTC cố định sẽ lệch 1 giờ hai lần mỗi năm và bắt đầu chạy trước khi thị trường đóng cửa. systemd 252 trở lên cho phép chỉ định timezone trực tiếp trong OnCalendar. Ubuntu 24.04 đi kèm systemd 255. Xác nhận phiên bản đang dùng bằng 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 là loại service duy nhất chấp nhận nhiều hơn một ExecStart và chạy chúng theo thứ tự, dừng lại nếu một lệnh thoát với mã khác 0. Đây chính xác là hành vi cần có: một lần refresh thất bại không được theo sau bởi việc chụp màn hình dữ liệu đã cũ. Persistent=true đặc biệt quan trọng trên VPS khởi động lại để cập nhật kernel. Nếu reboot lúc 16:15 mà không có tùy chọn này, lần chạy sẽ bị bỏ qua hoàn toàn. Khi có tùy chọn này, job sẽ chạy ngay sau khi máy hoạt động trở lại.
sudo systemctl daemon-reload
sudo systemctl enable --now research-refresh.timer
systemctl list-timers research-refresh.timerlist-timers phải hiển thị cột NEXT chứa lần chạy tiếp theo trong tuần, đã chuyển sang giờ local của chính server. Danh sách trống có nghĩa là timer chưa được enable hoặc unit thiếu section [Install], nên enable không có gì để liên kết vào timers.target.
Màn hình: SQL trước, model sau cùng
SQL cho kết quả chính xác và không phát sinh chi phí mỗi lần chạy. Model thì không như vậy. Vì thế, query thu hẹp phạm vi dữ liệu trước, và chỉ những dòng còn lại mới được gửi đến model. Lưu nội dung này thành /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;Bộ lọc n > 50 không phải để trang trí. ROWS BETWEEN 49 PRECEDING AND CURRENT ROW tính trung bình trên bất kỳ số dòng nào đang có, nên dòng thứ ba của một ticker sẽ trả về trung bình của 3 ngày nhưng vẫn gắn nhãn đó là ma50. Nếu so sánh giá trị này với ma20, bạn sẽ tạo ra một crossover ở đầu lịch sử của mọi ticker, dù crossover đó chưa từng xảy ra. Lọc theo số thứ tự dòng sẽ loại bỏ những dòng mà cửa sổ chưa đủ dữ liệu.
Những gì model nhìn thấy và những gì model không bao giờ nhìn thấy
# /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()Giới hạn số từ trong system prompt khống chế phần chi phí lớn hơn của hóa đơn. Chỉ dẫn yêu cầu dùng duy nhất các dòng đã cung cấp là phần bạn phải kiểm tra, không nên chỉ tin vào đó: xóa một cột khỏi block, chạy lại rồi đọc output. Nếu số liệu của cột đó vẫn xuất hiện, model đã tự điền phần còn thiếu và prompt của bạn chưa đủ chặt. Bài kiểm tra này chỉ mất 2 phút và là cách duy nhất để xác minh một cách trung thực.
Model không bao giờ thấy API key, không thấy đường dẫn database và không chạy query. Model nhận các dòng dữ liệu rồi trả về văn bản. Ranh giới này giúp kiểm tra output, vì mọi con số trong ghi chú cũng phải xuất hiện trong block bạn đã gửi, và bạn có thể đối chiếu từng dòng. Để tìm hiểu thêm về cách viết prompt cho việc này, dùng Claude để phân tích tài chính trình bày sâu hơn về những gì model đọc tốt.
Một lần chạy, từ đầu đến cuối
Vào 16:20 theo giờ New York, timer khởi động service. refresh.py truy vấn feed cho từng ticker, bắt đầu từ ngày được lưu gần nhất, ghi thêm một hoặc hai bar cho mỗi ticker, rồi in một dòng cho từng ticker. screen.py mở cùng file đó, chạy truy vấn moving average và nhận về một số dòng dữ liệu. Các dòng này được ghép thành một block văn bản dài vài trăm token. Một API call chuyển block đó thành một ghi chú ngắn. Ghi chú được ghi vào journal, đồng thời một row được thêm vào runs cùng số lượng token và số lần truy vấn.
journalctl -u research-refresh.service -n 50 --no-pagerMột log hoạt động bình thường sẽ có một dòng cho mỗi ticker, sau đó là ghi chú và research-refresh.service: Deactivated successfully. Sau một tuần, hãy đọc chi phí thực tế từ database:
SELECT count(*) AS runs,
sum(input_tokens) AS in_tokens,
sum(output_tokens) AS out_tokens
FROM runs;Nhân các tổng số này với đơn giá mỗi một triệu token mà model của bạn công bố tại thời điểm bạn kiểm tra. Kết quả là con số thực tế, không phải ước tính của người khác. Giá công bố có thể thay đổi. Phép tính thì không.
Vì sao backtest bị overfit và cách quan sát quá trình này
Viết lại screen thành một hàm của 2 độ dài cửa sổ, quét một grid gồm các cặp tham số rồi xếp hạng theo lợi nhuận. Cặp tốt nhất sẽ trông rất ấn tượng. Đó mới là vấn đề, không phải kết quả. Một grid gồm 200 cặp tương đương 200 thử nghiệm, và bạn đã giữ lại cặp gặp may nhất.
Bạn có thể quan sát quá trình này trong 10 phút. Chia store thành 2 nửa theo ngày. Chỉ quét grid trên nửa đầu và ghi lại cặp thắng. Quét cùng grid trên nửa sau. Nếu 2 cặp thắng khác nhau nhiều, các tham số đang khớp với nhiễu. Một cặp chỉ thắng trên nửa dữ liệu dùng để tinh chỉnh nó không cho biết gì về ngày mai.
Survivorship còn tệ hơn overfitting vì tuning không thể sửa được vấn đề này. Danh sách ticker của bạn là các mã đang thuộc index hôm nay, nên chỉ chứa những công ty còn tồn tại. Khi yêu cầu feed trả về ticker đã bị delist vào năm 2019, feed trả về một frame rỗng. Điều đó có nghĩa là công ty này chưa bao giờ được đưa vào store và cũng chưa bao giờ được đưa vào test. Mọi backtest bạn chạy đều đã loại bỏ các trường hợp thất bại từ trước.
Fundamentals được restate làm hỏng timeline. Con số doanh thu mà API trả về hôm nay cho một quý của năm 2019 không phải lúc nào cũng là con số đã được công bố trong năm 2019. Một screen kết hợp fundamentals của hôm nay với giá của năm 2019 đang sử dụng thông tin chưa tồn tại tại thời điểm đó. Giá thường an toàn trong trường hợp này. Fundamentals thường không.
Giá đã adjusted có thể thay đổi dưới dữ liệu của bạn. Với auto_adjust=True, giá đóng cửa được điều chỉnh ngược về quá khứ để phản ánh dividend và split, nên cùng một query chạy vào tháng sau có thể trả về một lịch sử hơi khác. Lưu các row bạn thực sự đã dùng là cách giúp kết quả có thể tái lập. Đây cũng là một lý do khác để local store tồn tại.
Backtest cũng bỏ qua commission và slippage, đồng thời giả định order của bạn không làm giá thay đổi. Những yếu tố đó thuộc execution, nằm ngoài phạm vi của phần này và được đề cập trong chạy trading bot trên VPS.
Các dạng lỗi và chuỗi bạn sẽ thấy
error: externally-managed-environment khi pip chạy. Bạn đang ở ngoài virtual environment. Gọi /opt/research/venv/bin/pip bằng full path.
Could not set lock on file, kèm theo một PID. Một process khác đang mở file DuckDB để ghi, thường là một shell tương tác mà bạn quên đóng. Hãy đóng process đó hoặc mở connection thứ hai bằng read_only=True.
Main process exited, code=killed, status=9/KILL trong systemctl status. Kernel đã kill job vì thiếu bộ nhớ. Xác nhận bằng journalctl -k | grep -i oom, sau đó giảm memory_limit trong store.py.
Job chạy thành công nhưng không ghi gì. systemctl status đọc active (exited) nhưng table không tăng số dòng. Feed trả về các frame rỗng. Dạng lỗi này thường khó phát hiện nhất, vì vậy hãy để job thoát với mã khác 0 khi mọi ticker đều trả về rỗng.
Timer chạy vào ngày thị trường nghỉ. systemd không biết lịch của exchange, nên Mon-Fri bao gồm cả ngày nghỉ. Job vẫn chạy, feed không có dữ liệu mới, và job nên coi đây là trạng thái bình thường thay vì lỗi.
429 từ API. Bạn đã vượt rate limit. Anthropic SDK tự retry với backoff, còn Anthropic(max_retries=5) làm tăng số lần thử. Nếu tình trạng này vẫn xảy ra mỗi ngày, job đang gửi quá nhiều yêu cầu trong một burst.
Những gì công cụ này không làm
Đây là một trợ lý nghiên cứu. Khi model tóm tắt một hồ sơ, kết quả chỉ là cách diễn giải hồ sơ đó. Model có thể khẳng định sai về một con số được in trong văn bản. Vì vậy, mọi số liệu trong ghi chú phải truy ngược được về một dòng dữ liệu mà bạn đã gửi. Hãy xem kết quả như danh sách rút gọn những nội dung bạn cần tự đọc. Nội dung này không phải tư vấn tài chính và không có phần nào là tín hiệu giao dịch.
Backtest hữu ích để loại bỏ các ý tưởng và không đáng tin khi dùng để xác nhận chúng. Một strategy thất bại trên dữ liệu của chính bạn thì thực sự không còn giá trị. Một strategy vượt qua backtest chỉ mới tồn tại được trên dữ liệu của bạn. Đây là một kết luận hạn chế hơn nhiều so với cảm giác lúc 1am.
Phần execution được cố ý để ngoài phạm vi. Order và thông tin xác thực của broker có mức rủi ro khác với một research box chỉ đọc. Việc kết hợp chúng còn khiến trading key nằm trên cùng một máy với LLM prompt. Nếu muốn xem mô hình này nằm ở đâu bên cạnh những thứ đáng chạy trên server riêng, các AI agent tự host đáng chạy là phần tổng quan rộng hơn.
FAQ
Tôi có cần feed dữ liệu thị trường trả phí không?
Không cần cho prototype. Một feed miễn phí không chính thức phù hợp để tìm hiểu cấu trúc của hệ thống, và nó sẽ hỏng vì phụ thuộc vào một website không có nghĩa vụ cung cấp dữ liệu cho bạn. Feed thường trả về các frame rỗng thay vì phát sinh exception, nên job của bạn phải kiểm tra số dòng. Khi dữ liệu bắt đầu được dùng để đưa ra quyết định, hãy chuyển sang feed trả phí có API được tài liệu hóa và địa chỉ hỗ trợ. Store giúp việc thay đổi này ít tốn công: chỉ cần thay đổi hàm fetch, còn schedule, schema và màn hình vẫn giữ nguyên.
Tôi nên lưu giá trong SQLite hay DuckDB?
DuckDB dùng dạng cột và được thiết kế để quét nhiều dòng nhằm tính aggregate. Đây chính xác là trường hợp tính moving average trên dữ liệu bar của 10 năm. SQLite dùng dạng dòng và phù hợp hơn với nhiều lần đọc, ghi nhỏ từ nhiều process cùng lúc. Với một scheduled job duy nhất, append vài trăm dòng rồi quét hàng triệu dòng, DuckDB phù hợp hơn. Nếu nhiều process phải ghi đồng thời, SQLite ở chế độ WAL cho phép reader tiếp tục làm việc trong khi một writer commit. busy timeout khiến các writer khác chờ thay vì fail ngay.
Chi phí gọi LLM mỗi tháng là bao nhiêu?
Ghi usage.input_tokens và usage.output_tokens của mọi response vào một table, sau đó nhân tổng theo tuần với giá trên mỗi triệu token mà model công bố tại thời điểm bạn kiểm tra. Đây là con số duy nhất còn chính xác trong quý tiếp theo. Một lần chạy mỗi ngày trên một screen ngắn chỉ tạo ra ít call. Độ dài của note là phần bạn kiểm soát được: giới hạn summary ở 200 từ tiết kiệm nhiều hơn việc gửi ít dòng hơn, vì token output có giá cao hơn token input trên mọi model Claude.
Tại sao job của tôi chạy thành công nhưng không ghi thêm dòng mới?
Có 2 nguyên nhân thường gặp. Thị trường đóng cửa vì schedule systemd Mon-Fri có tính cả ngày nghỉ của sàn. Hoặc feed trả về frame rỗng cho mọi ticker. Các client library thường báo trường hợp này bằng warning được in ra thay vì exception, nên process vẫn thoát với mã 0 và systemd vẫn hiển thị lần chạy thành công. Phân biệt 2 trường hợp bằng cách so sánh SELECT max(day) FROM prices với ngày giao dịch thực tế gần nhất. Đồng thời, hãy để job thoát với mã khác 0 khi mọi ticker đều trả về rỗng.
Agent có thể quyết định nên mua gì không?
Không. Việc xây dựng agent để cố làm điều đó là nguyên nhân khiến các dự án này đi sai hướng. Model không có quyền truy cập thị trường, không biết vị thế hoặc tình hình thuế của bạn, và không thể đối chiếu các con số của chính nó với dữ liệu nào. Điểm mạnh của nó là đọc một lượng lớn văn bản và cho bạn biết vài mục nào đáng để bạn chú ý hôm nay. Không nội dung nào nó viết ra là tư vấn tài chính. Quyết định và trách nhiệm đi kèm vẫn thuộc về bạn.