Use when writing, validating, or troubleshooting a recurring scheduled buy strategy (DCA, dollar-cost averaging, weekly buys, daily buys, monthly accumulatio...
---
name: dca-weekly
description: Use when writing, validating, or troubleshooting a recurring scheduled buy strategy (DCA, dollar-cost averaging, weekly buys, daily buys, monthly accumulation, accumulator) on Superior Trade — especially anything that should "buy more of the same pair" on a calendar trigger rather than a price trigger.
version: 0.1.0
updated: 2026-05-07
---
# Strategy: DCA · Scheduled Buys
## When to use
A user asks to "buy X every week", "DCA into BTC", "scheduled buy", "accumulator", "monthly buy", or any variation that means *open a position once, then keep adding to it on a calendar cadence*. **Not** for "buy when price drops" — that's grid trading (see `grid-trading`).
## What it does
- Holds **one open trade per pair** (Freqtrade's hard rule).
- The first calendar trigger opens the trade with a **small** initial stake.
- Every subsequent trigger calls `adjust_trade_position` and adds the **same notional** to the open trade.
- The position grows in fills inside a single Trade row. PnL is reported per-trade.
- Exits only when the user sets one (typically never, for true DCA — `stoploss = -0.99` and no `populate_exit_trend`).
## Backtest reference
| Window | `BTC/USDC` 1d, 2025-11-15 → 2026-05-01 (auto-narrowed to data availability, ~10 weeks) |
|---|---|
| Trades | 1 (still open at end, force-closed) |
| Entry orders inside the trade | **10** (1 initial + 9 weekly DCA, tagged `weekly_dca`) |
| Stake per buy | ~$36.87 |
| Total invested | ~$365 of $10,000 wallet |
| Per-trade PnL | +10.0% |
| Wallet PnL | +0.37% / +$36.61 |
| Holding | 66 days |
| Backtest ID | `01kqyz1ysdy9dyw7tbdrhz5gek` |
The `(rejected_signals: 9)` warning in logs is normal: `populate_entry_trend` keeps emitting Monday flags even while a trade is open, but `adjust_trade_position` does the actual buys.
## The Freqtrade primitives that make this work
These four flags are the difference between v1 (1 trade ever, the rest rejected) and v2 (a real ladder of fills). All four are required:
```python
position_adjustment_enable = True
max_entry_position_adjustment = 26 # cap on number of weekly adds
max_dca_multiplier = 27.0 # 1 initial + 26 adds
```
Plus two callbacks:
- `custom_stake_amount` — divides the user-configured stake by `max_dca_multiplier` so the **initial** entry leaves room for the future weekly adds.
- `adjust_trade_position` — the calendar trigger. Returns `(stake, tag)` to add, `None` to do nothing.
## Reference implementation
```python
from freqtrade.strategy import IStrategy
from freqtrade.persistence import Trade
from datetime import datetime
import pandas as pd
class WeeklyDcaBtcStrategy(IStrategy):
minimal_roi = {"0": 100.0} # never exit on profit target
stoploss = -0.99 # never exit on stop
trailing_stop = False
timeframe = "1d"
process_only_new_candles = True
startup_candle_count = 5
can_short = False
# The piece naive translations miss.
position_adjustment_enable = True
max_entry_position_adjustment = 26 # ~6 months of weekly buys
max_dca_multiplier = 27.0 # 1 initial + 26 weekly adds
def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
dataframe["dow"] = pd.to_datetime(dataframe["date"]).dt.dayofweek
return dataframe
def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
# Initial entry on the first Monday encountered.
dataframe.loc[(dataframe["dow"] == 0) & (dataframe["volume"] > 0), "enter_long"] = 1
return dataframe
def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
return dataframe
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
proposed_stake: float, min_stake, max_stake: float,
leverage: float, entry_tag, side: str, **kwargs) -> float:
# Reserve room for the future weekly adds.
return proposed_stake / self.max_dca_multiplier
def adjust_trade_position(self, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float,
min_stake, max_stake: float,
current_entry_rate: float, current_exit_rate: float,
current_entry_profit: float, current_exit_profit: float,
**kwargs):
if trade.has_open_orders:
return None
if current_time.weekday() != 0: # Monday only
return None
# Skip the Monday on which the initial entry was placed (Freqtrade
# calls adjust_trade_position on the same candle as the initial
# entry; without this guard you double-buy on week 1).
filled = trade.select_filled_orders(trade.entry_side)
if filled:
last_dt = filled[-1].order_filled_utc
if last_dt and last_dt.date() == current_time.date():
return None
# Buy the same notional as the initial entry every Monday.
first_stake = filled[0].stake_amount_filled if filled else (min_stake or 10)
return (first_stake, "weekly_dca")
```
## Config requirements
```json
{
"exchange": { "name": "hyperliquid", "pair_whitelist": ["BTC/USDC"] },
"stake_currency": "USDC",
"stake_amount": 1000,
"dry_run_wallet": 10000,
"timeframe": "1d",
"max_open_trades": 1,
"stoploss": -0.99,
"minimal_roi": { "0": 100.0 },
"entry_pricing": { "price_side": "same" },
"exit_pricing": { "price_side": "same" },
"pairlists": [{ "method": "StaticPairList" }]
}
```
`stake_amount` is the **post-division** budget the user wants per buy times `max_dca_multiplier`. With `stake_amount: 1000` and `max_dca_multiplier: 27`, each Monday buy is ~$37; total budget is ~$1000.
`dry_run_wallet` must be ≥ `stake_amount` (Freqtrade keeps a 1% reserve, so the strict gate is `stake_amount ≤ dry_run_wallet × 0.99`). Default `dry_run_wallet` is 1000; bump it up if you raise stake.
## Common pitfalls
1. **No `position_adjustment_enable`.** Without it, repeat Monday flags are silently rejected and you get one trade ever. The classic v1 mistake.
2. **No same-day guard in `adjust_trade_position`.** Without the `filled[-1].order_filled_utc.date() == current_time.date()` check, the strategy double-buys on the Monday the initial entry was placed.
3. **Forgetting to scale `stake_amount`.** Without `custom_stake_amount` returning `proposed_stake / max_dca_multiplier`, the first buy uses the full configured stake and the wallet runs out before week 5.
4. **Using `populate_exit_trend` to "exit half".** Doesn't work — Freqtrade only knows full exits via `populate_exit_trend`. Partial exits go through `adjust_trade_position` returning a *negative* stake.
5. **Setting `stoploss` ≥ -0.5.** A real DCA isn't supposed to stop out on a 50% drawdown. Use `-0.99` so the stop never triggers, then exit manually if needed.
## Variants
- **Daily / monthly cadence**: change `current_time.weekday() != 0` to `current_time.day != 1` (1st of month) or remove the guard entirely (every candle close).
- **Drawdown-aware DCA**: add a check on `current_profit < -0.10` to add EXTRA on top of the calendar — buy more when down 10%. Combine the calendar check with `current_profit < threshold`.
- **Spot vs futures**: works on both. Use `BTC/USDC` for spot (`trading_mode: "spot"` or omit) or `BTC/USDC:USDC` for perp (`trading_mode: "futures"`, `margin_mode: "cross"`). DCA is most idiomatic on spot.
## Sources
- Freqtrade `adjust_trade_position` — https://www.freqtrade.io/en/stable/strategy-callbacks/#adjust-trade-position
- DigDeeperStrategy reference — https://github.com/freqtrade/freqtrade/issues/7052
- Internal audit — `docs/standard-strategies-audit.md`, backtest `01kqyz1ysdy9dyw7tbdrhz5gek`
don't have the plugin yet? install it then click "run inline in claude" again.
build a recurring buy ladder on a calendar cadence (weekly, daily, monthly) inside a single freqtrade trade. the strategy opens a position with a small initial stake, then adds the same notional amount on every trigger date. use this when a user says "buy X every week", "DCA into BTC", "scheduled buy", or "accumulator". do not use for price-triggered grid buys (see grid-trading instead). the position grows as multiple fills stacked inside one Trade row, with PnL reported per-trade. true DCA runs indefinitely without exits, so stoploss and exit signals remain dormant.
freqtrade config:
stake_amount: post-division budget per buy times max_dca_multiplier. e.g. if you want ~$37/week for 27 weeks, set stake_amount: 1000 (1000 / 27 ≈ 37). required.dry_run_wallet: must be ≥ stake_amount (strict check: stake_amount ≤ dry_run_wallet × 0.99). default is 1000; bump it if you raise stake. required.timeframe: "1d" for weekly buys, "1h" for hourly, etc. required.max_open_trades: must be ≥ 1 (typically set to 1 for single-pair DCA). required.stoploss: use -0.99 to prevent accidental stop-outs on drawdowns. required.minimal_roi: use {"0": 100.0} to never exit on profit targets. required.exchange.name: e.g. "hyperliquid", "binance", "kraken". required.exchange.pair_whitelist: e.g. ["BTC/USDC"]. required.stake_currency: e.g. "USDC", "BUSD". required.strategy code constants:
position_adjustment_enable: must be True. without it, repeat entry signals are silently rejected. required.max_entry_position_adjustment: integer cap on number of weekly adds. e.g. 26 for ~6 months of weekly buys. required.max_dca_multiplier: floating-point total multiplier (1 initial + N adds). e.g. 27.0 for 1 + 26. must equal 1 + max_entry_position_adjustment. required.strategy callbacks (custom code):
custom_stake_amount: divides proposed stake by max_dca_multiplier so the initial entry leaves room for future adds. required.adjust_trade_position: the calendar trigger. returns (stake, tag) to add, None to skip. required.external connections: none (all local or exchange API via freqtrade's existing auth).
configure freqtrade wallet and exchange. set dry_run_wallet ≥ stake_amount and point to your exchange (hyperliquid, binance, etc.). set max_open_trades: 1.
set freqtrade position adjustment flags. ensure position_adjustment_enable = True, max_entry_position_adjustment = 26 (or your chosen count), and max_dca_multiplier = 27.0 (or 1 + your adjustment count). without these, the strategy opens one trade ever and ignores repeat entry signals.
calculate initial stake from desired per-buy amount. if you want ~$37/buy per week, multiply by max_dca_multiplier: $37 × 27 = ~$999. set stake_amount: 1000 in config. the custom_stake_amount callback will divide this by 27 to get the actual first buy.
implement populate_indicators. add a day-of-week column (dataframe["dow"] = pd.to_datetime(dataframe["date"]).dt.dayofweek) so you can key on Monday (0), Tuesday (1), etc. or add a day-of-month check (dataframe["day"] = ...dt.day) for 1st-of-month triggers.
implement populate_entry_trend. mark the target calendar day (Monday for weekly) with dataframe.loc[(dataframe["dow"] == 0) & (dataframe["volume"] > 0), "enter_long"] = 1. this signal fires on every trigger day; adjust_trade_position filters out duplicates.
leave populate_exit_trend empty. return an unmodified dataframe. do not add exit signals unless the user explicitly wants one (rare for true DCA).
implement custom_stake_amount callback. return proposed_stake / self.max_dca_multiplier. this scales the initial entry to 1/27th of the configured stake, leaving budget for 26 more adds.
implement adjust_trade_position callback. check if a trade exists, current time matches your calendar trigger (e.g. current_time.weekday() == 0 for Monday), and the last filled order is not from today (guard against double-buy on entry day). if all checks pass, return (first_stake, "weekly_dca") where first_stake is the amount of the initial entry. return None otherwise.
guard against same-day double-buy in adjust_trade_position. compare the last filled order's date (filled[-1].order_filled_utc.date()) against current_time.date(). freqtrade calls adjust_trade_position on the same candle as the initial entry; without this guard you add twice on week 1. skip the adjustment if dates match.
set stoploss = -0.99 in strategy class. this prevents accidental stop-outs during drawdowns. true DCA should never exit automatically. if the user later wants a manual exit, they close the trade themselves.
set minimal_roi = {"0": 100.0} to never exit on profit targets. ditto, true DCA runs until closed manually.
backtest the strategy. run freqtrade backtest with your config and pair whitelist. inspect logs for rejected_signals: N (normal, means entry signals were ignored while a trade was open) and confirm trade count is 1 (or N if you run N separate pairs). verify each trade has multiple entry orders tagged weekly_dca.
deploy to live or dry-run. point freqtrade at your exchange (live) or keep dry_run: true (paper). the strategy opens the first position on the first trigger day, then adds every trigger day thereafter.
if position_adjustment_enable is False or missing: the strategy opens one trade on the first signal, then silently rejects all repeat entry signals. result: single buy, not a ladder. fix: set flag to True.
if adjust_trade_position lacks the same-day guard (filled[-1].order_filled_utc.date() == current_time.date()): freqtrade calls adjust_trade_position on the same candle as the initial entry, triggering an immediate second buy on week 1. result: double-buy and wallet depletion. fix: add the date comparison and return None if dates match.
if custom_stake_amount does not divide by max_dca_multiplier: the initial entry consumes the full configured stake, leaving no budget for subsequent adds. result: wallet runs out by week 5. fix: return proposed_stake / self.max_dca_multiplier.
if stoploss >= -0.5: a sharp drawdown (50%+) stops out the trade prematurely, breaking the accumulation. result: incomplete ladder. fix: use -0.99 or lower.
if populate_exit_trend has exit signals: the strategy exits the entire trade (freqtrade does not support partial exits via populate_exit_trend). result: ladder closes before user intent. fix: leave populate_exit_trend empty, or return empty dataframe.
if max_open_trades > 1: the strategy tries to open multiple pairs in parallel, splitting wallet. result: each pair gets fewer adds before wallet exhaustion. fix: set max_open_trades: 1 for single-pair DCA, or adjust max_dca_multiplier and stake_amount if running multiple pairs.
if dry_run_wallet < stake_amount: freqtrade enforces a 1% minimum reserve, so strict gate is stake_amount > dry_run_wallet × 0.99. result: first buy is rejected or partial. fix: bump dry_run_wallet to ≥ stake_amount.
if calendar trigger fires but no open trade exists (e.g. first candle of backtest): adjust_trade_position is not called. result: no add happens until an initial entry exists. fix: ensure populate_entry_trend fires on the same day or earlier so the initial trade is opened.
if trade.has_open_orders is True: pending orders (unfilled) exist from a prior call. adjust_trade_position should return None to avoid stacking orders. fix: check if trade.has_open_orders: return None at the top of the callback.
if the exchange has low liquidity or wide spreads (especially stablecoins like USDC on smaller exchanges): entry orders may fill slowly or be rejected if position size is too small. result: some calendar triggers miss their add. fix: inspect order logs and bump stake_amount if necessary, or use a more liquid pair.
if the user wants daily or monthly instead of weekly: change the calendar check in adjust_trade_position. for daily, remove the weekday guard or check every candle. for monthly, change to current_time.day == 1. fix: adjust the callback condition to match the cadence.
if the user wants to add extra on drawdown (e.g. "buy more when down 10%"): add a second condition in adjust_trade_position like if current_profit < -0.10: return (first_stake * 2, "dca_extra"). result: DCA accelerates during dips. fix: add the condition after the calendar check.
trade count: exactly 1 per pair in the backtest or live run (or N if running N pairs with adjusted max_dca_multiplier and stake_amount). if trade count is > 1 per pair, max_open_trades is misconfigured.
entry orders per trade: 1 initial + K weekly adds, where K ≤ max_entry_position_adjustment. all adds tagged weekly_dca (or your custom tag from adjust_trade_position).
stake per entry: identical across all entries in the same trade (the first stake amount). if stakes vary, custom_stake_amount or adjust_trade_position is returning inconsistent values.
total invested: sum of all entry stakes. should be ≤ configured stake_amount (the pre-division total). if total invested equals stake_amount, the custom_stake_amount division is working.
position size at end of backtest or dry-run: expressed in base currency (e.g. BTC if pair is BTC/USDC), accumulating with each fill. if size is constant, only one fill occurred (strategy misconfigured).
PnL per trade: reported as +X.X% or -X.X% in freqtrade logs and backtest results. PnL is calculated as (exit price - weighted average entry price) / weighted average entry price. while the trade is open, unrealized PnL is (current price - weighted avg entry) / weighted avg entry.
trade duration: hours or days held from first fill to close (or "still open" if not closed). expected duration for weekly DCA is (number of adds) × 7 days.
logs: inspect freqtrade logs for (rejected_signals: N) , this is normal and expected. N should be roughly equal to the number of trigger days minus the number of successful adds (e.g. 9 rejected if 10 Mondays occur and only 1 trade is open, since all 9 other Mondays are ignored). if N is 0, the calendar trigger is not firing.
backtest results show 1 trade with multiple entry orders (e.g. 1 trade, 10 entries, tags include weekly_dca). if trades = 1 and entries = 1, the strategy opened but never added (check calendar trigger and adjust_trade_position).
logs show "adjusted ... position by +X USDC" or similar message per trigger day. if no adjust messages appear, position_adjustment_enable is False or adjust_trade_position is returning None every time.
wallet balance decreases on each trigger day (dry-run). each Monday (or your cadence), a new order is placed and filled. check the wallet graph in backtest results or live logs.
trade position size grows linearly with each add. e.g. after 10 weekly adds, BTC position is 10× the size of a single buy. if position size is flat, adds are not filling.
no stop-outs or partial exits (unless the user manually closed). if the trade closes unexpectedly, check stoploss (should be -0.99) and populate_exit_trend (should be empty).
PnL is positive or negative but trade remains open (unless the user chose a manual exit condition). true DCA is a long-term accumulation, so the trade typically stays open across multiple trigger periods.
total invested approximately equals stake_amount / max_dca_multiplier × number_of_fills. e.g. if stake_amount = 1000, max_dca_multiplier = 27, and 10 fills occur, total invested ≈ 1000 / 27 × 10 ≈ $370. if total is wildly off, the stake calculation is broken.
from freqtrade.strategy import IStrategy
from freqtrade.persistence import Trade
from datetime import datetime
import pandas as pd
class WeeklyDcaBtcStrategy(IStrategy):
minimal_roi = {"0": 100.0} # never exit on profit target
stoploss = -0.99 # never exit on stop
trailing_stop = False
timeframe = "1d"
process_only_new_candles = True
startup_candle_count = 5
can_short = False
# The piece naive translations miss.
position_adjustment_enable = True
max_entry_position_adjustment = 26 # ~6 months of weekly buys
max_dca_multiplier = 27.0 # 1 initial + 26 weekly adds
def populate_indicators(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
dataframe["dow"] = pd.to_datetime(dataframe["date"]).dt.dayofweek
return dataframe
def populate_entry_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
# Initial entry on the first Monday encountered.
dataframe.loc[(dataframe["dow"] == 0) & (dataframe["volume"] > 0), "enter_long"] = 1
return dataframe
def populate_exit_trend(self, dataframe: pd.DataFrame, metadata: dict) -> pd.DataFrame:
return dataframe
def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float,
proposed_stake: float, min_stake, max_stake: float,
leverage: float, entry_tag, side: str, **kwargs) -> float:
# Reserve room for the future weekly adds.
return proposed_stake / self.max_dca_multiplier
def adjust_trade_position(self, trade: Trade, current_time: datetime,
current_rate: float, current_profit: float,
min_stake, max_stake: float,
current_entry_rate: float, current_exit_rate: float,
current_entry_profit: float, current_exit_profit: float,
**kwargs):
if trade.has_open_orders:
return None
if current_time.weekday() != 0: # Monday only
return None
# Skip the Monday on which the initial entry was placed (Freqtrade
# calls adjust_trade_position on the same candle as the initial
# entry; without this guard you double-buy on week 1).
filled = trade.select_filled_orders(trade.entry_side)
if filled:
last_dt = filled[-1].order_filled_utc
if last_dt and last_dt.date() == current_time.date():
return None
# Buy the same notional as the initial entry every Monday.
first_stake = filled[0].stake_amount_filled if filled else (min_stake or 10)
return (first_stake, "weekly_dca")
{
"exchange": { "name": "hyperliquid", "pair_whitelist": ["BTC/USDC"] },
"stake_currency": "USDC",
"stake_amount": 1000,
"dry_run_wallet": 10000,
"timeframe": "1d",
"max_open_trades": 1,
"stoploss": -0.99,
"minimal_roi": { "0": 100.0 },
"entry_pricing": { "price_side": "same" },
"exit_pricing": { "price_side": "same" },
"pairlists": [{ "method": "StaticPairList" }]
}
stake_amount is