Build a BTC-USD Paper Bot in Python
Build a Python bot that replays BTC-USD data and simulates crossover trades.
Introduction
30 Second Summary
A trading idea can look convincing on a chart while hiding flawed signals. Testing it with real money makes every bug expensive.
In this project, you will build a terminal-based BTC-USD paper-trading bot in Python. The bot replays public market data from the Coinbase Advanced Trade API through a moving-average strategy.
What You'll Build
You will run bot.py to watch real BTC-USD candles become a simulated trade log that ends with your virtual wallet's equity.
By the end of this project, you'll have:
- A public market data feed that fetches recent BTC-USD candles without an exchange account or API key.
- A moving-average replay that exposes repeated trend labels before producing clean crossover events.
- A paper wallet that simulates one open position against a virtual $10,000 balance. Its summary shows ending equity plus profit or loss after simulated fees.
- Secret Mission: Add a 3% stop-loss exit that records why each position closed.
Do I need any experience before starting?
This beginner project is designed for Windows, with trading knowledge as an optional head start. Setup is included, so no Python experience, Coinbase account, or API key is required.
Before We Start
Before any setup, lock in the goal for this project. This keeps your first move toward live execution focused on testing strategy logic without risking real money.
Set Up Python on Windows
Your paper-trading strategy needs a safe place to run before it can use market data. Right now, Windows has no runtime or code editor for the bot.
Python runs the bot. Visual Studio Code works with the Microsoft Python extension to run your files from the editor.
In this step, get ready to:
- Install Python 3.14.7 on Windows.
- Install Visual Studio Code with the Microsoft Python extension.
- Run bot.py in the integrated terminal.
Install and verify Python
The Python Install Manager installs Python runtimes on Windows. You will use it to install the current Python 3.14 runtime.
- Open the official Python release page for version 3.14.7.
- Download the Python Install Manager for Windows.
- Open the downloaded installer.
- Approve the Windows permission prompt if it appears.
- Complete the installation using the default selections.
Why use the Python Install Manager?
The manager installs the runtime that your bot needs. It also makes the python command available on supported Windows systems.
- Press the Windows key to open Windows search.
- Type PowerShell into the search field.
- Press Enter to open PowerShell.
- Install the Python 3.14 runtime by running this command:
py install 3.14
What does this command do?
The py command asks the Python Install Manager to install the latest runtime in the 3.14 release line. For this project, that runtime is Python 3.14.7.
Before you check, which Python version do you expect Windows to report?
- Check the active Python version by running this command:
python --version
What should I see?
PowerShell should print Python 3.14.7. This confirms that Windows can launch the required runtime.
✔️ I see Python 3.14.7
Your Python runtime is ready. The bot now has the exact version it needs.
ⓧ I see an older version
An older result means Windows is still launching another installed runtime. Reinstalling the current release gives the manager another chance to update the active version.
- Install Python 3.14 again by running this command:
py install 3.14
What does this command do?
The command installs the latest available Python 3.14 runtime through the Python Install Manager.
- Close PowerShell.
- Press the Windows key to reopen Windows search.
- Type PowerShell into the search field.
- Press Enter to open a fresh PowerShell window.
- Check the active version again by running this command:
python --version
What should I see?
The fresh PowerShell window should print Python 3.14.7.
Still seeing an older version?
Follow the official Python instructions for Windows to check how Windows selects the default runtime.
Help me fix the Python version selected by Windows.
ⓧ Command not found
Windows cannot find the Python launcher yet. Installing the Python Install Manager adds the required commands.
- Open the official Python release page for version 3.14.7.
- Download the Python Install Manager for Windows.
- Open the downloaded installer.
- Complete the installation using the default selections.
- Close PowerShell.
- Open a fresh PowerShell window through Windows search.
- Install Python 3.14 by running this command:
py install 3.14
What does this command do?
The Python Install Manager downloads the latest runtime in the 3.14 release line.
- Confirm the installed version by running this command:
python --version
What should I see?
PowerShell should now print Python 3.14.7.
Still missing the command?
Restart Windows after installing the Python Install Manager. This lets Windows finish registering the new commands.
Help me troubleshoot a missing Python command on Windows.
Install Visual Studio Code and its Python extension
Visual Studio Code gives you one place to edit your bot. The Python extension connects the editor to the runtime you just verified.
Windows may ask for permission while the installer runs. That prompt is a normal part of installing the editor.
- Open the Visual Studio Code setup page in your browser.
- Download the User setup installer for Windows.
- Open the downloaded installer.
- Approve the Windows permission prompt if it appears.
- Complete the installer using the default selections.
Why choose User setup?
Visual Studio Code recommends User setup for most Windows users. It installs the editor for your Windows account.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search field.
- Press Enter to open Visual Studio Code.
Your code editor is now running. The final setup piece adds support for Python files.
- Press Ctrl+Shift+X to open Extensions.
- Type Python into the Extensions search field.
- Select the extension named Python that is published by Microsoft.
- Click Install on the extension details page.
You will see the Python extension listed as installed. Visual Studio Code can now run Python files through your local runtime.
Extension not installing?
- Confirm that the extension name is Python.
- Confirm that the listed publisher is Microsoft.
- Restart Visual Studio Code if the installation status stops changing.
Help me install the Microsoft Python extension in Visual Studio Code.
Create and run the smoke test
A smoke test is a small program that proves your setup can run code. Your first test prints one line in the integrated terminal.
- Click File in the Visual Studio Code menu bar.
- Select Open Folder.
- Choose your Desktop in the folder picker.
- Use the folder picker's new-folder control to create crypto-paper-bot.
- Select the crypto-paper-bot folder.
- Confirm the folder selection to open it in Visual Studio Code.
The Explorer sidebar now shows crypto-paper-bot. This folder keeps every file for your paper-trading bot together.
- Use the new-file control in the Explorer sidebar to create bot.py inside crypto-paper-bot.
- Add the smoke-test message to bot.py by pasting this code:
print("Crypto paper bot ready")
What does this code do?
The print() function writes text to the terminal. This message gives you a visible result as soon as the file runs.
- Save bot.py by pressing Ctrl+S.
Before you run it, what exact line do you expect the integrated terminal to print?
- Click Run Python File in Terminal in the top-right editor toolbar.
- Select Python 3.14.7 if Visual Studio Code asks you to choose an interpreter.
You will see Crypto paper bot ready in the integrated terminal. That first terminal line proves your Windows toolchain works from editor to interpreter.
Message missing from the terminal?
- Confirm that bot.py is the active editor tab.
- Confirm that the file contains the same print() line shown above.
- Choose the Python 3.14.7 interpreter if a different runtime is selected.
Help me run bot.py from Visual Studio Code.
✔️ Awesome, I've got everything!
Your bot.py file is saved. The integrated terminal prints the ready message.
ⓧ I'd like to double check the full code
print("Crypto paper bot ready")
Your Windows development environment is ready for the bot. Next, you will replace the smoke test with real BTC-USD candle data.
Fetch BTC-USD Candles
Your Windows Python setup now runs bot.py inside Visual Studio Code. A trading rule needs ordered market observations before it can make decisions.
This step connects your bot to the public Coinbase Advanced Trade API through an HTTP request. The bot turns the JSON response into ordered BTC-USD closing prices.
In this step, get ready to:
- Configure the public candle request.
- Retrieve ordered numeric candle data from Coinbase.
- Display the candle count plus the oldest and newest closing prices.
Configure the candle request
Your bot needs a stable request configuration before it can retrieve market data. These constants keep the market pair plus candle settings in one place.
- In bot.py place your cursor above print("Crypto paper bot ready").
- Paste the request configuration at your cursor using this code:
import json
import time
from urllib.parse import urlencode
from urllib.request import urlopen
PRODUCT_ID = "BTC-USD"
GRANULARITY = "ONE_HOUR"
CANDLE_SECONDS = 3600
CANDLE_LIMIT = 350
BASE_URL = (
"https://api.coinbase.com/api/v3/brokerage/market/products/"
f"{PRODUCT_ID}/candles"
)
What does this configuration do?
- The json module parses the API response.
- The time module supplies the current time.
- The urlencode function formats the query parameters.
- The urlopen function sends the public request.
- PRODUCT_ID selects BTC-USD.
- GRANULARITY requests one-hour candles.
- CANDLE_SECONDS works with CANDLE_LIMIT to define the requested history span.
- BASE_URL identifies the public candle endpoint.
- Save bot.py.
- Select Run Python File in Terminal from the editor toolbar.
Good start. The terminal prints Crypto paper bot ready because Python loaded the imports plus request constants successfully.
Startup message missing?
- Keep print("Crypto paper bot ready") below the BASE_URL block.
- Compare every quote in BASE_URL with the code above.
- Compare each parenthesis in BASE_URL with the code above.
Help me find why the updated file no longer prints its startup message.
Fetch and normalize the candles
The API returns candle fields as strings. Your function sorts the records before converting each timestamp plus closing price into numbers.
- Place your cursor below the closing parenthesis of BASE_URL.
- Add fetch_candles() at your cursor using this code:
def fetch_candles():
end = int(time.time())
start = end - (CANDLE_SECONDS * CANDLE_LIMIT)
query = urlencode(
{
"start": str(start),
"end": str(end),
"granularity": GRANULARITY,
"limit": str(CANDLE_LIMIT),
}
)
with urlopen(f"{BASE_URL}?{query}", timeout=10) as response:
payload = json.loads(response.read().decode("utf-8"))
candles = sorted(payload["candles"], key=lambda candle: int(candle["start"]))
return [
{
"start": int(candle["start"]),
"close": float(candle["close"]),
}
for candle in candles
]
What does this function do?
- end captures the current time.
- start calculates the beginning of the requested period.
- query contains the required request parameters.
- urlopen() retrieves the public response.
- json.loads() converts the response body into Python data.
- sorted() places the candles in chronological order.
- The returned list converts each start value into an integer.
- The returned list converts each close value into a floating-point number.
The public endpoint accepts this request without a Coinbase account. It does not require an API key.
- Save bot.py.
- Select Run Python File in Terminal from the editor toolbar.
The terminal prints Crypto paper bot ready. This confirms Python parsed the complete fetch_candles() definition.
Startup output disappeared?
- Place fetch_candles() below the closing parenthesis of BASE_URL.
- Match the indentation inside query with the code above.
- Match the indentation inside the returned list with the code above.
Help me find a syntax or indentation problem in my candle function.
Display the fetched prices
The retrieval function needs an entry point that calls it. The entry point collects the closing prices for a readable terminal summary.
- Find print("Crypto paper bot ready") below fetch_candles().
- Delete the existing print line.
- Paste this entry point in its place:
def main():
candles = fetch_candles()
closes = [candle["close"] for candle in candles]
print(f"Fetched {len(candles)} candles for {PRODUCT_ID}.")
print(f"Oldest close: ${closes[0]:,.2f}")
print(f"Newest close: ${closes[-1]:,.2f}")
if __name__ == "__main__":
main()
How does the terminal summary work?
- main() calls fetch_candles() when you run the file.
- closes collects the normalized closing prices.
- len(candles) reports the number of records returned.
- closes[0] selects the oldest close after sorting.
- closes[-1] selects the newest close after sorting.
- Save bot.py.
Before you run the bot, which closing price do you expect to appear first after the candles are sorted?
- Select Run Python File in Terminal from the editor toolbar.
You'll see a fetched candle count. The next two lines show Oldest close plus Newest close values.
The exact count plus prices depend on the available market data. That's your first live market-data result from the bot.
Candle request not completing?
- Confirm that your computer has an active internet connection.
- Retry Run Python File in Terminal after restoring the connection.
- Compare BASE_URL with the configuration code above.
- Check that main() appears below fetch_candles().
Help me troubleshoot why the public candle request is not producing its terminal summary.
✔️ Awesome, I've got everything!
Your saved bot.py now fetches public candle data. It displays the oldest plus newest closing prices.
ⓧ I'd like to double check the full code
import json
import time
from urllib.parse import urlencode
from urllib.request import urlopen
PRODUCT_ID = "BTC-USD"
GRANULARITY = "ONE_HOUR"
CANDLE_SECONDS = 3600
CANDLE_LIMIT = 350
BASE_URL = (
"https://api.coinbase.com/api/v3/brokerage/market/products/"
f"{PRODUCT_ID}/candles"
)
def fetch_candles():
end = int(time.time())
start = end - (CANDLE_SECONDS * CANDLE_LIMIT)
query = urlencode(
{
"start": str(start),
"end": str(end),
"granularity": GRANULARITY,
"limit": str(CANDLE_LIMIT),
}
)
with urlopen(f"{BASE_URL}?{query}", timeout=10) as response:
payload = json.loads(response.read().decode("utf-8"))
candles = sorted(payload["candles"], key=lambda candle: int(candle["start"]))
return [
{
"start": int(candle["start"]),
"close": float(candle["close"]),
}
for candle in candles
]
def main():
candles = fetch_candles()
closes = [candle["close"] for candle in candles]
print(f"Fetched {len(candles)} candles for {PRODUCT_ID}.")
print(f"Oldest close: ${closes[0]:,.2f}")
print(f"Newest close: ${closes[-1]:,.2f}")
if __name__ == "__main__":
main()
Your bot now has ordered BTC-USD prices. Next, you'll turn those closes into fast plus slow moving-average signals.
Build Moving-Average Signals
Your bot now retrieves ordered BTC-USD closing prices from the public Coinbase Advanced Trade API. Those prices are observations without a rule for comparing short-term movement with longer-term movement.
A simple moving average smooths prices across a fixed window. Comparing a fast average with a slow average reveals the current market condition.
A trading rule must distinguish a market condition from a new decision. This step tests what happens when that distinction is missing.
In this step, get ready to:
- Define fast and slow moving-average windows.
- Calculate a simple moving average for each price window.
- Count naive trend labels across the fetched candles.
Calculate the moving averages
The fast window responds to recent prices sooner. The slow window provides a steadier comparison across more candles.
- In bot.py, find this candle-limit line:
CANDLE_LIMIT = 350
Where the Windows Belong
The candle limit controls how much market data the request retrieves. The moving-average windows belong directly below it because they configure how the fetched prices are analyzed.
- Replace that line with the three configuration lines below:
CANDLE_LIMIT = 350
FAST_WINDOW = 5
SLOW_WINDOW = 20
What Do These Settings Control?
- The FAST_WINDOW setting uses five closes for the short-term average.
- The SLOW_WINDOW setting uses 20 closes for the longer-term average.
- Keeping both values near the other constants makes the strategy parameters easy to find.
- In bot.py, locate the blank line immediately before def main():.
- Add the moving-average helper at that location by copying the code below:
def build_sma(values, window):
averages = [None] * len(values)
for index in range(window - 1, len(values)):
window_values = values[index - window + 1 : index + 1]
averages[index] = sum(window_values) / window
return averages
What Does This Function Do?
- The averages list reserves one result for every closing price. Early positions remain None until the window has enough prices.
- The loop starts at the first index that can hold a complete window.
- The window_values slice holds the closes used for the current calculation.
- Dividing the window sum by window produces the average for that candle.
- Save bot.py.
- Select Run Python File in Terminal in the upper-right corner of Visual Studio Code.
Good progress. You will still see the fetched candle count plus the oldest and newest closes, which confirms the new helper has valid Python syntax.
Does the Script Stop Before Printing Prices?
Check that build_sma() sits above main(). Make sure every line inside the function uses the same four-space indentation.
Ask for help with the function structure if the terminal points to this new section: Help me check the indentation and syntax of my build_sma function in bot.py.
Expose the repeated labels
A market condition can remain true across many candles. A crossover event occurs only when the relationship between the two averages changes.
- In bot.py, select the existing main() function from def main(): through the newest-close print statement.
- Replace the selected function by copying the version below:
def main():
candles = fetch_candles()
if len(candles) <= SLOW_WINDOW:
raise RuntimeError(f"Need at least {SLOW_WINDOW + 1} candles.")
closes = [candle["close"] for candle in candles]
fast_sma = build_sma(closes, FAST_WINDOW)
slow_sma = build_sma(closes, SLOW_WINDOW)
naive_label_count = len(candles) - SLOW_WINDOW + 1
print(f"Fetched {len(candles)} candles for {PRODUCT_ID}.")
print(f"Oldest close: ${closes[0]:,.2f}")
print(f"Newest close: ${closes[-1]:,.2f}")
print(f"Naive trend labels generated: {naive_label_count}")
What Does This Update Do?
- The candle-count check stops the calculation when there are too few prices for the slow window.
- The fast_sma list stores averages from five-candle windows.
- The slow_sma list stores averages from 20-candle windows.
- The naive_label_count value counts every candle from the first available slow average onward.
- Save bot.py.
Before you run the bot, do you expect the naive label count to be a small handful or close to the fetched candle count?
- Select Run Python File in Terminal in the upper-right corner of Visual Studio Code.
You will see Naive trend labels generated with a count close to the fetched candle count. The exact values depend on the latest market data.
Why Is the Count So High?
That near-one-label-per-candle result is intentional. The naive rule treats every available market condition as another instruction.
A fast average can remain above the slow average for many consecutive candles. Acting on every one of those candles would produce repeated orders from one sustained trend.
Missing the Naive Label Count?
Confirm that the updated main() calls build_sma() twice. Check that the final print statement remains inside the function.
If Python reports that a window name is missing, compare the spelling of FAST_WINDOW with SLOW_WINDOW near the top of the file.
Get targeted help with the missing output: Help me find why my naive trend label count is not printing.
✔️ Awesome, I've got everything!
Your moving averages now expose how often a persistent condition can be mistaken for a new trade instruction.
ⓧ I'd like to double check the full code
import json
import time
from urllib.parse import urlencode
from urllib.request import urlopen
PRODUCT_ID = "BTC-USD"
GRANULARITY = "ONE_HOUR"
CANDLE_SECONDS = 3600
CANDLE_LIMIT = 350
FAST_WINDOW = 5
SLOW_WINDOW = 20
BASE_URL = (
"https://api.coinbase.com/api/v3/brokerage/market/products/"
f"{PRODUCT_ID}/candles"
)
def fetch_candles():
end = int(time.time())
start = end - (CANDLE_SECONDS * CANDLE_LIMIT)
query = urlencode(
{
"start": str(start),
"end": str(end),
"granularity": GRANULARITY,
"limit": str(CANDLE_LIMIT),
}
)
with urlopen(f"{BASE_URL}?{query}", timeout=10) as response:
payload = json.loads(response.read().decode("utf-8"))
candles = sorted(payload["candles"], key=lambda candle: int(candle["start"]))
return [
{
"start": int(candle["start"]),
"close": float(candle["close"]),
}
for candle in candles
]
def build_sma(values, window):
averages = [None] * len(values)
for index in range(window - 1, len(values)):
window_values = values[index - window + 1 : index + 1]
averages[index] = sum(window_values) / window
return averages
def main():
candles = fetch_candles()
if len(candles) <= SLOW_WINDOW:
raise RuntimeError(f"Need at least {SLOW_WINDOW + 1} candles.")
closes = [candle["close"] for candle in candles]
fast_sma = build_sma(closes, FAST_WINDOW)
slow_sma = build_sma(closes, SLOW_WINDOW)
naive_label_count = len(candles) - SLOW_WINDOW + 1
print(f"Fetched {len(candles)} candles for {PRODUCT_ID}.")
print(f"Oldest close: ${closes[0]:,.2f}")
print(f"Newest close: ${closes[-1]:,.2f}")
print(f"Naive trend labels generated: {naive_label_count}")
if __name__ == "__main__":
main()
You have made the repeated-signal problem visible with real BTC-USD data. Next, you will detect actual crossings and turn them into stateful paper trades.
Execute Paper-Trading Crossovers
Your moving averages now expose the repeated-label problem. Those labels describe a market condition. A trade needs a single event.
This step turns crossings into simulated BUY or SELL events. A paper wallet uses position state to keep one BTC position open at a time.
The final report calculates mark-to-market equity after simulated fees. Coinbase receives no order request.
In this step, get ready to:
- Define the paper-wallet rules.
- Detect both crossover directions.
- Produce the paper-wallet trade report.
Define the paper wallet rules
The wallet starts with virtual cash. Its position fraction controls each allocation. Its fee rate reduces simulated trade value.
- In bot.py, find SLOW_WINDOW = 20.
- Paste the wallet settings directly below that line:
STARTING_CASH = 10_000.0
POSITION_FRACTION = 0.20
SIMULATED_FEE_RATE = 0.001
What do these settings control?
- STARTING_CASH gives the paper wallet a virtual $10,000 balance.
- POSITION_FRACTION allocates 20% of available cash to each entry.
- SIMULATED_FEE_RATE deducts 0.1% from each simulated transaction.
- Save bot.py.
- Select Run Python File in Terminal in the editor toolbar.
You'll see the candle report finish with Naive trend labels generated. This confirms that the new wallet settings contain valid Python.
Seeing a syntax error near the settings?
Check that all three settings sit below SLOW_WINDOW = 20. Each setting needs its own line.
Ask for help checking the wallet settings in bot.py.
Detect actual crossover events
A crossover compares consecutive candles. A cross-up occurs when the fast average moves above the slow average. A cross-down occurs when it moves below.
- In bot.py, find the blank line above def main():.
- Start run_backtest() in that space by pasting this code:
def run_backtest(candles, fast_sma, slow_sma):
cash = STARTING_CASH
btc_quantity = 0.0
trades = []
for index in range(SLOW_WINDOW, len(candles)):
previous_fast = fast_sma[index - 1]
previous_slow = slow_sma[index - 1]
current_fast = fast_sma[index]
current_slow = slow_sma[index]
price = candles[index]["close"]
crossed_up = previous_fast <= previous_slow and current_fast > current_slow
crossed_down = previous_fast >= previous_slow and current_fast < current_slow
How does crossover detection work?
- cash holds the paper wallet's available dollars.
- btc_quantity records the open BTC position.
- trades collects every simulated transaction.
- crossed_up becomes true only when the fast average crosses above the slow average between two candles.
- crossed_down captures the opposite crossing.
- Save bot.py.
- Select Run Python File in Terminal in the editor toolbar.
The script completes with its fetched count plus its naive label count. This confirms that the crossover comparisons contain valid Python.
Seeing an indentation error?
Keep the variables beneath the for line indented by eight spaces. Keep the two crossover lines at that same level.
Ask for help correcting the run_backtest() indentation.
The crossover booleans identify event candles. The next block turns those events into stateful paper trades.
- Find the crossed_down assignment inside run_backtest().
- Paste the trade execution logic directly below it:
if crossed_up and btc_quantity == 0.0:
allocation = cash * POSITION_FRACTION
fee = allocation * SIMULATED_FEE_RATE
btc_quantity = (allocation - fee) / price
cash -= allocation
trades.append(
{
"side": "BUY",
"price": price,
"quantity": btc_quantity,
}
)
elif crossed_down and btc_quantity > 0.0:
quantity_sold = btc_quantity
proceeds = quantity_sold * price
fee = proceeds * SIMULATED_FEE_RATE
cash += proceeds - fee
btc_quantity = 0.0
trades.append(
{
"side": "SELL",
"price": price,
"quantity": quantity_sold,
}
)
How does the paper wallet trade?
- A cross-up can create a BUY only when btc_quantity is zero.
- allocation reserves 20% of the available cash.
- fee reduces the BTC bought or the cash received.
- A cross-down can create a SELL only when BTC is already held.
- trades stores each side plus its price plus its quantity.
- Save bot.py.
VS Code should show no red underline beneath the BUY or SELL blocks. The matching indentation keeps both branches inside the candle loop.
Is the SELL block misaligned?
Align elif with if. Keep both branches inside the for loop.
Ask for help aligning the BUY and SELL branches.
Once the replay ends, the wallet values the remaining BTC at the newest close. That produces the final equity figure.
- Find the closing parenthesis of the SELL trade record.
- Paste the account summary below the candle loop:
last_price = candles[-1]["close"]
ending_equity = cash + (btc_quantity * last_price)
return {
"trades": trades,
"cash": cash,
"btc_quantity": btc_quantity,
"last_price": last_price,
"ending_equity": ending_equity,
"profit_loss": ending_equity - STARTING_CASH,
}
How is the final account value calculated?
- last_price reads the newest candle close.
- ending_equity combines remaining cash with the current value of held BTC.
- profit_loss measures the difference from the virtual starting balance.
- Save bot.py.
VS Code should show no red underline beneath the returned dictionary. The completed function now returns every value needed by the report.
Is the summary inside the candle loop?
The last_price line needs four leading spaces. The trade branches above it need eight leading spaces.
Ask for help checking the summary indentation.
Report trades and account equity
The backtest now returns a complete paper-wallet result. The final addition calls it from main() before formatting the trade log.
- In main(), find the line that prints Naive trend labels generated.
- Paste the paper-trading report directly below that line:
result = run_backtest(candles, fast_sma, slow_sma)
print(f"Executed trades: {len(result['trades'])}")
for number, trade in enumerate(result["trades"], start=1):
print(
f"{number}. {trade['side']} "
f"{trade['quantity']:.8f} BTC at ${trade['price']:,.2f}"
)
print(f"Ending cash: ${result['cash']:,.2f}")
print(f"BTC held: {result['btc_quantity']:.8f}")
print(f"Last price: ${result['last_price']:,.2f}")
print(f"Ending equity: ${result['ending_equity']:,.2f}")
print(f"Profit/loss: ${result['profit_loss']:,.2f}")
What does the report show?
- Executed trades reports the number of crossover orders created by the replay.
- Each numbered record shows the trade side plus its BTC quantity plus its execution price.
- Ending cash reports uninvested virtual dollars.
- BTC held reports the remaining open position.
- Ending equity plus Profit/loss reveal the final paper-wallet result.
✔️ Awesome, I've got everything!
Your crossover engine plus paper-wallet report are in place.
ⓧ I'd like to double check the full code
import json
import time
from urllib.parse import urlencode
from urllib.request import urlopen
PRODUCT_ID = "BTC-USD"
GRANULARITY = "ONE_HOUR"
CANDLE_SECONDS = 3600
CANDLE_LIMIT = 350
FAST_WINDOW = 5
SLOW_WINDOW = 20
STARTING_CASH = 10_000.0
POSITION_FRACTION = 0.20
SIMULATED_FEE_RATE = 0.001
BASE_URL = (
"https://api.coinbase.com/api/v3/brokerage/market/products/"
f"{PRODUCT_ID}/candles"
)
def fetch_candles():
end = int(time.time())
start = end - (CANDLE_SECONDS * CANDLE_LIMIT)
query = urlencode(
{
"start": str(start),
"end": str(end),
"granularity": GRANULARITY,
"limit": str(CANDLE_LIMIT),
}
)
with urlopen(f"{BASE_URL}?{query}", timeout=10) as response:
payload = json.loads(response.read().decode("utf-8"))
candles = sorted(payload["candles"], key=lambda candle: int(candle["start"]))
return [
{
"start": int(candle["start"]),
"close": float(candle["close"]),
}
for candle in candles
]
def build_sma(values, window):
averages = [None] * len(values)
for index in range(window - 1, len(values)):
window_values = values[index - window + 1 : index + 1]
averages[index] = sum(window_values) / window
return averages
def run_backtest(candles, fast_sma, slow_sma):
cash = STARTING_CASH
btc_quantity = 0.0
trades = []
for index in range(SLOW_WINDOW, len(candles)):
previous_fast = fast_sma[index - 1]
previous_slow = slow_sma[index - 1]
current_fast = fast_sma[index]
current_slow = slow_sma[index]
price = candles[index]["close"]
crossed_up = previous_fast <= previous_slow and current_fast > current_slow
crossed_down = previous_fast >= previous_slow and current_fast < current_slow
if crossed_up and btc_quantity == 0.0:
allocation = cash * POSITION_FRACTION
fee = allocation * SIMULATED_FEE_RATE
btc_quantity = (allocation - fee) / price
cash -= allocation
trades.append(
{
"side": "BUY",
"price": price,
"quantity": btc_quantity,
}
)
elif crossed_down and btc_quantity > 0.0:
quantity_sold = btc_quantity
proceeds = quantity_sold * price
fee = proceeds * SIMULATED_FEE_RATE
cash += proceeds - fee
btc_quantity = 0.0
trades.append(
{
"side": "SELL",
"price": price,
"quantity": quantity_sold,
}
)
last_price = candles[-1]["close"]
ending_equity = cash + (btc_quantity * last_price)
return {
"trades": trades,
"cash": cash,
"btc_quantity": btc_quantity,
"last_price": last_price,
"ending_equity": ending_equity,
"profit_loss": ending_equity - STARTING_CASH,
}
def main():
candles = fetch_candles()
if len(candles) <= SLOW_WINDOW:
raise RuntimeError(f"Need at least {SLOW_WINDOW + 1} candles.")
closes = [candle["close"] for candle in candles]
fast_sma = build_sma(closes, FAST_WINDOW)
slow_sma = build_sma(closes, SLOW_WINDOW)
naive_label_count = len(candles) - SLOW_WINDOW + 1
print(f"Fetched {len(candles)} candles for {PRODUCT_ID}.")
print(f"Oldest close: ${closes[0]:,.2f}")
print(f"Newest close: ${closes[-1]:,.2f}")
print(f"Naive trend labels generated: {naive_label_count}")
result = run_backtest(candles, fast_sma, slow_sma)
print(f"Executed trades: {len(result['trades'])}")
for number, trade in enumerate(result["trades"], start=1):
print(
f"{number}. {trade['side']} "
f"{trade['quantity']:.8f} BTC at ${trade['price']:,.2f}"
)
print(f"Ending cash: ${result['cash']:,.2f}")
print(f"BTC held: {result['btc_quantity']:.8f}")
print(f"Last price: ${result['last_price']:,.2f}")
print(f"Ending equity: ${result['ending_equity']:,.2f}")
print(f"Profit/loss: ${result['profit_loss']:,.2f}")
if __name__ == "__main__":
main()
- Save bot.py.
Before you run the completed bot, do you expect the current candle window to contain zero trades or several crossover trades?
- Select Run Python File in Terminal in the editor toolbar.
You'll see Executed trades followed by a market-dependent count. Any simulated orders appear as numbered BUY or SELL records.
Below the trade log, you'll see Ending cash, BTC held, plus Last price. The summary ends with Ending equity plus Profit/loss.
Does the paper-wallet report fail to print?
Check that the report sits inside main() with four leading spaces. Confirm that run_backtest() appears above main().
If the candle request fails, run the file again after confirming your internet connection.
Ask for help debugging the paper-wallet report.
You did it. Your bot now converts indicator crossings into stateful paper trades without risking money.
Secret mission
Add a Stop-Loss Exit
Give your paper wallet an independent risk exit. Track each entry price, close positions that fall 3% below that price, and label every trade with its reason.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep your local files, pause your work, or delete the project entirely. Keeping this local project has no ongoing cost because the bot creates no cloud resources.
Resources you used:
- The local crypto-paper-bot folder containing bot.py.
Why is the list so short?
Your bot downloads public market data each time it runs. Those requests leave no remote project resource to delete.
The simulated trades exist only in memory during each run. They disappear when bot.py finishes.
Python and Visual Studio Code remain installed for future projects.
Keep everything running
No action needed. Choose this if you want to keep testing the strategy with newer market data.
- Leave the crypto-paper-bot folder on your computer.
- Keep bot.py available so you can replay the latest candles later.
Pause - I'll come back to this later
Pausing preserves your code without leaving a server or trading process running. The script already stops after each replay.
- Switch back to the Visual Studio Code window from earlier.
- Close Visual Studio Code.
- Leave the crypto-paper-bot folder on your computer.
Delete - I don't want to use this again
Deleting the folder permanently removes your bot code. Choose Pause if you may want to reuse the strategy later.
- Switch back to the Visual Studio Code window from earlier.
- Close Visual Studio Code.
- Press the Windows key to open Windows search.
- Type File Explorer into Windows search.
- Press Enter to open File Explorer.
The full project sits inside one folder, so removing that folder clears every local artifact from this project.
- Return to the location where you created the crypto-paper-bot folder.
- Select the crypto-paper-bot folder.
- Press Shift+Delete to delete the folder permanently.
- Confirm the deletion when Windows asks.
- Confirm that crypto-paper-bot no longer appears in File Explorer.
Nice Work!
Nice Work!
You made it! Your terminal-based BTC-USD paper-trading bot now turns public candle data into simulated trades without risking money.
You've learned how to:
- Use Python to fetch public candle data from the Coinbase Advanced Trade API. Parse each JSON response into ordered prices for market replay.
- Calculate simple moving averages across 5-candle and 20-candle windows. Compare consecutive averages to detect crossover events instead of repeated trend labels.
- Run a position-limited paper wallet with 20% position sizing. Apply a 0.1% simulated fee to each trade. Calculate ending equity and profit or loss from cash plus BTC holdings.
- Complete the optional Secret Mission by adding an entry-aware 3% stop-loss exit. Label each simulated trade with the reason it executed.
Ready to quiz yourself?