Oanda demo daemon: auto-execution on practice account (XAU/USD)

New independent trading daemon (oanda_daemon.py) that places orders on
an Oanda v20 practice account instead of sending Telegram alerts.
Oanda-only market data (oanda_provider.py, mid candles + real bid/ask
spread) through the unchanged signal pipeline; order execution and
trade management via oanda_executor.py (raw requests, no new deps).

Book parity with the backtester: no broker-side TP (exits = SL/TRAIL/
TIMEOUT only), bar-close-only trail ratchet with each closed bar
processed exactly once, 5-bar per-TF entry cooldown, structural SL
from the setup. Accepted divergence: one position total (skipped
signals logged for book reconciliation).

Safety/ops: fail-fast boot on rejected credentials (transient outages
continue), market orders never retried (double-fill risk), entry lock
against aligned-boundary races, restart recovery + adoption via
oanda_state.json, hourly heartbeat, asyncio exception handler, every
failure path logged. Prices formatted at instrument precision.

Config: env OANDA_* -> config.json "oanda" section (validated in
app_config). Tests: Module 15 (30 tests), suite 240 green.

Claude-Session: https://claude.ai/code/session_01VUnCqjWBuvKRSjLwHeB9BJ
This commit is contained in:
Harwinder Sekhon
2026-07-24 14:10:45 -04:00
parent c3329d6cd5
commit d669227ddc
7 changed files with 2245 additions and 1 deletions
+842
View File
@@ -8,6 +8,7 @@ Tests every function in the codebase without requiring live API connections.
import sys
import os
import time
import threading
import traceback
from datetime import datetime, timedelta, timezone
from unittest.mock import patch, MagicMock
@@ -3121,6 +3122,847 @@ def _():
tb._autoscan_chats.clear()
# ══════════════════════════════════════════════════════════════════════
# MODULE 15: Oanda provider / executor / daemon
# ══════════════════════════════════════════════════════════════════════
print("\n═══ Module 15: Oanda provider / executor / daemon ═══")
import oanda_provider
import oanda_executor
import oanda_daemon
@test("oanda_provider: _is_forex_weekend matches data_provider's")
def _():
from data_provider import _is_forex_weekend as dp_weekend
from oanda_provider import _is_forex_weekend as oa_weekend
import pandas as pd
for ts_str in [
"2026-07-17 12:00", # Friday noon
"2026-07-17 22:30", # Friday after close
"2026-07-18 15:00", # Saturday
"2026-07-19 12:00", # Sunday before open
"2026-07-19 22:30", # Sunday after open
"2026-07-20 08:00", # Monday
]:
ts = pd.Timestamp(ts_str, tz="UTC")
assert dp_weekend(ts) == oa_weekend(ts), \
f"Weekend check mismatch at {ts_str}"
@test("oanda_provider: OandaDataProvider parses Oanda candle JSON into correct DataFrame")
def _():
sample_candles = [
{
"complete": True,
"time": "2026-07-20T10:00:00.000000000Z",
"mid": {"o": "2400.00", "h": "2405.00", "l": "2398.00", "c": "2403.00"},
"bid": {"o": "2399.85", "h": "2404.85", "l": "2397.85", "c": "2402.85"},
"ask": {"o": "2400.15", "h": "2405.15", "l": "2398.15", "c": "2403.15"},
"volume": 1234,
},
{
"complete": True,
"time": "2026-07-20T11:00:00.000000000Z",
"mid": {"o": "2403.00", "h": "2408.00", "l": "2401.00", "c": "2406.00"},
"bid": {"o": "2402.85", "h": "2407.85", "l": "2400.85", "c": "2405.85"},
"ask": {"o": "2403.15", "h": "2408.15", "l": "2401.15", "c": "2406.15"},
"volume": 5678,
},
{
"complete": False,
"time": "2026-07-20T12:00:00.000000000Z",
"mid": {"o": "2406.00", "h": "2406.50", "l": "2405.50", "c": "2406.20"},
"bid": {"o": "2405.85", "h": "2406.35", "l": "2405.35", "c": "2406.05"},
"ask": {"o": "2406.15", "h": "2406.65", "l": "2405.65", "c": "2406.35"},
"volume": 100,
},
]
mock_resp = type("MockResp", (), {
"status_code": 200,
"json": lambda self: {"candles": sample_candles, "instrument": "XAU_USD"},
"raise_for_status": lambda self: None,
})()
provider2 = oanda_provider.OandaDataProvider.__new__(
oanda_provider.OandaDataProvider)
provider2.config = PlatformConfig()
provider2._base_url = "https://test"
provider2._cache = {}
provider2._cache_timestamps = {}
provider2._cache_lock = threading.Lock()
provider2._spread_cache = {}
provider2._session = type("S", (), {
"get": lambda self, *a, **k: mock_resp,
"headers": {},
})()
df = provider2._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
assert len(df) == 2, f"Expected 2 complete bars, got {len(df)}"
assert list(df.columns) == ["Open", "High", "Low", "Close", "Volume"], \
f"Wrong columns: {list(df.columns)}"
assert df.index.name == "Datetime"
assert df.index.tz is not None, "Index must be tz-aware"
assert df.iloc[0]["Open"] == 2400.0
assert df.iloc[1]["Close"] == 2406.0
assert df.iloc[0]["Volume"] == 1234
spread = provider2._spread_cache.get("XAU/USD")
assert spread is not None, "Spread should be cached"
assert abs(spread - 0.30) < 0.01, f"Expected spread ~0.30, got {spread}"
@test("oanda_provider: instrument name mapping")
def _():
provider = oanda_provider.OandaDataProvider.__new__(
oanda_provider.OandaDataProvider)
assert provider._to_oanda_instrument("XAU/USD") == "XAU_USD"
assert provider._to_oanda_instrument("EUR/USD") == "EUR_USD"
@test("oanda_provider: granularity mapping covers all scan timeframes")
def _():
for tf in [Timeframe.M5, Timeframe.M15, Timeframe.H1, Timeframe.H4]:
assert tf in oanda_provider.OandaDataProvider.OANDA_GRANULARITY, \
f"{tf} missing from OANDA_GRANULARITY"
@test("oanda_provider: get_spread returns default when no cache")
def _():
provider = oanda_provider.OandaDataProvider.__new__(
oanda_provider.OandaDataProvider)
provider._spread_cache = {}
spread = provider.get_spread("XAU/USD")
assert spread == 0.30, f"Expected default spread 0.30, got {spread}"
@test("oanda_executor: market order body construction")
def _():
executor = oanda_executor.OandaExecutor.__new__(
oanda_executor.OandaExecutor)
executor._account_id = "test-account"
executor._base_url = "https://test"
executor._lot_size = 1
executor._price_precision = 2
executor._session = None
captured = {}
def mock_request(self, method, url, **kwargs):
captured["method"] = method
captured["url"] = url
captured["json"] = kwargs.get("json")
return type("R", (), {
"status_code": 201,
"json": lambda self: {
"orderFillTransaction": {
"price": "2400.50",
"tradeOpened": {"tradeID": "123"},
}
},
})()
executor._session = type("S", (), {
"request": mock_request,
"headers": {},
})()
result = executor.place_market_order(
instrument="XAU_USD",
direction="BUY",
stop_loss=2392.50,
take_profit=2420.00,
)
body = captured["json"]
order = body["order"]
assert order["type"] == "MARKET"
assert order["instrument"] == "XAU_USD"
assert order["units"] == "1"
# XAU price precision is 2 — Oanda rejects excess decimals.
assert order["stopLossOnFill"]["price"] == "2392.50"
assert order["takeProfitOnFill"]["price"] == "2420.00"
fill = result.get("orderFillTransaction")
assert fill is not None
assert fill["tradeOpened"]["tradeID"] == "123"
@test("oanda_executor: SELL order uses negative units")
def _():
executor = oanda_executor.OandaExecutor.__new__(
oanda_executor.OandaExecutor)
executor._account_id = "test-account"
executor._base_url = "https://test"
executor._lot_size = 1
executor._price_precision = 2
captured = {}
def mock_request(self, method, url, **kwargs):
captured["json"] = kwargs.get("json")
return type("R", (), {
"status_code": 201,
"json": lambda self: {
"orderFillTransaction": {
"price": "2400.50",
"tradeOpened": {"tradeID": "456"},
}
},
})()
executor._session = type("S", (), {
"request": mock_request,
"headers": {},
})()
executor.place_market_order(
instrument="XAU_USD", direction="SELL", stop_loss=2410.00)
order = captured["json"]["order"]
assert order["units"] == "-1", f"SELL should be -1, got {order['units']}"
assert "takeProfitOnFill" not in order, "No TP should be set when None"
@test("oanda_executor: 4xx error raises immediately (no retry)")
def _():
executor = oanda_executor.OandaExecutor.__new__(
oanda_executor.OandaExecutor)
executor._account_id = "test"
executor._base_url = "https://test"
executor._lot_size = 1
call_count = {"n": 0}
def mock_request(self, method, url, **kwargs):
call_count["n"] += 1
return type("R", (), {
"status_code": 400,
"text": "INVALID_ORDER",
})()
executor._session = type("S", (), {
"request": mock_request,
"headers": {},
})()
try:
executor._request_with_retry("POST", "https://test/orders")
assert False, "Should have raised OandaAPIError"
except oanda_executor.OandaAPIError as e:
assert e.status_code == 400
assert call_count["n"] == 1, "4xx should not retry"
@test("oanda_daemon: trailing stop ratchet matches backtester logic")
def _():
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
info_buy = {
"direction": "BUY",
"entry": 2400.0,
"sl_initial": 2384.0,
"current_sl": 2384.0,
"risk": 16.0,
"peak": 2400.0,
"trail_armed": False,
"trail_arm_r": 0.6,
"trail_dist_r": 0.75,
}
# Bar 1: price moves up but not enough to arm (< 0.6R = $9.60)
new_sl = daemon._compute_trail(info_buy, bar_high=2408.0, bar_low=2399.0)
assert new_sl is None, "Should not arm yet (excursion 0.5R)"
assert info_buy["peak"] == 2408.0
# Bar 2: excursion reaches 0.625R = $10 — arms, trail = 2408 - 0.75*16 = 2396
new_sl = daemon._compute_trail(info_buy, bar_high=2410.0, bar_low=2403.0)
assert new_sl is not None, "Should arm at 0.625R"
assert abs(new_sl - 2398.0) < 0.01, f"Expected trail ~2398, got {new_sl}"
assert info_buy["trail_armed"] is True
# Bar 3: price goes higher, trail ratchets up
info_buy["current_sl"] = new_sl
new_sl2 = daemon._compute_trail(info_buy, bar_high=2420.0, bar_low=2410.0)
assert new_sl2 is not None
assert new_sl2 > new_sl, "Trail must ratchet up"
expected = 2420.0 - 0.75 * 16.0 # 2408.0
assert abs(new_sl2 - expected) < 0.01, f"Expected {expected}, got {new_sl2}"
# Bar 4: price pulls back — trail must NOT ratchet down
info_buy["current_sl"] = new_sl2
new_sl3 = daemon._compute_trail(info_buy, bar_high=2412.0, bar_low=2405.0)
assert new_sl3 is None, "Trail must not ratchet backwards"
@test("oanda_daemon: trailing stop for SELL direction")
def _():
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
info_sell = {
"direction": "SELL",
"entry": 2400.0,
"sl_initial": 2416.0,
"current_sl": 2416.0,
"risk": 16.0,
"peak": 2400.0,
"trail_armed": False,
"trail_arm_r": 0.6,
"trail_dist_r": 0.75,
}
# Not enough excursion
new_sl = daemon._compute_trail(info_sell, bar_high=2401.0, bar_low=2393.0)
assert new_sl is None, "Should not arm yet"
assert info_sell["peak"] == 2393.0
# Arms: peak at 2390, excursion = 10/16 = 0.625R
new_sl = daemon._compute_trail(info_sell, bar_high=2395.0, bar_low=2390.0)
assert new_sl is not None
expected = 2390.0 + 0.75 * 16.0 # 2402.0
assert abs(new_sl - expected) < 0.01, f"Expected {expected}, got {new_sl}"
assert info_sell["trail_armed"] is True
# Ratchets down
info_sell["current_sl"] = new_sl
new_sl2 = daemon._compute_trail(info_sell, bar_high=2392.0, bar_low=2380.0)
assert new_sl2 is not None
assert new_sl2 < new_sl, "SELL trail must ratchet down"
@test("oanda_daemon: _should_execute respects quality gate")
def _():
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
daemon._quality_gate = "B"
daemon._state = {"trades": {}, "last_entries": {}}
daemon._state_lock = threading.Lock()
from signal_aggregator import TradeSetup
setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=2400.0, stop_loss=2384.0,
take_profit_1=2416.0, take_profit_2=2432.0,
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
pip_value=0.10)
report_c = SignalReport(
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
report_c.final_signal = SignalStrength.BUY
report_c.signal_quality = "C"
report_c.trade_setup = setup
assert not daemon._should_execute(report_c, Timeframe.H1), \
"C should be blocked by gate=B"
report_a = SignalReport(
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
report_a.final_signal = SignalStrength.BUY
report_a.signal_quality = "A"
report_a.trade_setup = setup
assert daemon._should_execute(report_a, Timeframe.H1), \
"A should pass gate=B"
@test("oanda_daemon: _should_execute blocks when position exists")
def _():
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
daemon._quality_gate = "C"
daemon._state = {"trades": {"12345": {"direction": "BUY"}},
"last_entries": {}}
daemon._state_lock = threading.Lock()
from signal_aggregator import TradeSetup
setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction="BUY",
entry_price=2400.0, stop_loss=2384.0,
take_profit_1=2416.0, take_profit_2=2432.0,
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
pip_value=0.10)
report = SignalReport(
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
report.final_signal = SignalStrength.BUY
report.signal_quality = "A"
report.trade_setup = setup
assert not daemon._should_execute(report, Timeframe.H1), \
"Should block when a position already exists"
@test("oanda_daemon: NEUTRAL signal is never executed")
def _():
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
daemon._quality_gate = "C"
daemon._state = {"trades": {}, "last_entries": {}}
daemon._state_lock = threading.Lock()
report = SignalReport(
symbol="XAU/USD", display_name="XAU/USD", timestamp="2026-07-20")
report.final_signal = SignalStrength.NEUTRAL
report.signal_quality = "F"
report.trade_setup = None
assert not daemon._should_execute(report, Timeframe.H1)
def _make_daemon_report(quality="A", direction="BUY"):
from signal_aggregator import TradeSetup
setup = TradeSetup(
pair="XAU/USD", display_name="XAU/USD", direction=direction,
entry_price=2400.0, stop_loss=2384.0,
take_profit_1=2416.0, take_profit_2=2432.0,
risk_reward=1.0, risk_pips=160.0, reward_pips=160.0,
pip_value=0.10, trail_arm_r=0.6, trail_dist_r=0.85)
report = SignalReport(
symbol="XAU/USD", display_name="XAU/USD",
timestamp="2026-07-20 10:00:00+00:00")
report.final_signal = (SignalStrength.BUY if direction == "BUY"
else SignalStrength.SELL)
report.signal_quality = quality
report.trade_setup = setup
return report
@test("oanda_daemon: 5-bar entry cooldown blocks re-entry (backtest parity)")
def _():
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
daemon._quality_gate = "C"
daemon._state = {"trades": {}, "last_entries": {}}
daemon._state_lock = threading.Lock()
report = _make_daemon_report()
# Entry 2 minutes ago on M5 → inside 5×5min cooldown → blocked
recent = (datetime.now(timezone.utc) - timedelta(minutes=2)).isoformat()
daemon._state["last_entries"]["5min"] = recent
assert not daemon._should_execute(report, Timeframe.M5), \
"Entry 2min ago must block M5 (cooldown 25min)"
# Entry 30 minutes ago on M5 → past 5×5min cooldown → allowed
old = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat()
daemon._state["last_entries"]["5min"] = old
assert daemon._should_execute(report, Timeframe.M5), \
"Entry 30min ago must clear the 25min M5 cooldown"
# M5 cooldown must not block H1
daemon._state["last_entries"] = {"5min": recent}
assert daemon._should_execute(report, Timeframe.H1), \
"Cooldown is per-timeframe"
@test("oanda_daemon: orders carry NO take-profit (trailing book parity)")
def _():
import tempfile
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
daemon._state = {"trades": {}, "last_entries": {}}
daemon._state_lock = threading.Lock()
daemon._entry_lock = threading.Lock()
captured = {}
class MockExecutor:
def place_market_order(self, instrument, direction, stop_loss,
take_profit=None, units=None):
captured["take_profit"] = take_profit
captured["stop_loss"] = stop_loss
return {
"orderFillTransaction": {
"price": "2400.60",
"tradeOpened": {"tradeID": "777"},
}
}
daemon._executor = MockExecutor()
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
tmp_path = f.name
orig_state_file = oanda_daemon.STATE_FILE
try:
oanda_daemon.STATE_FILE = tmp_path
daemon._execute_trade(_make_daemon_report(), Timeframe.M15)
finally:
oanda_daemon.STATE_FILE = orig_state_file
os.unlink(tmp_path)
assert captured.get("take_profit") is None, \
"Backtested trailing model has no hard TP — order must not carry one"
assert captured["stop_loss"] == 2384.0
trade = daemon._state["trades"]["777"]
assert trade["entry"] == 2400.60, "Risk must be computed from actual fill"
assert abs(trade["risk"] - 16.60) < 0.01
assert trade["trail_arm_r"] == 0.6 and trade["trail_dist_r"] == 0.85, \
"Trail params must come from the setup stamp (per-TF factory values)"
assert daemon._state["last_entries"]["15min"], \
"Entry must be recorded for the cooldown"
@test("oanda_daemon: entry-lock recheck prevents double entry from concurrent scans")
def _():
import tempfile
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
# A concurrent TF loop already opened a trade after our _should_execute
daemon._state = {"trades": {"111": {"direction": "BUY"}},
"last_entries": {}}
daemon._state_lock = threading.Lock()
daemon._entry_lock = threading.Lock()
placed = {"n": 0}
class MockExecutor:
def place_market_order(self, *a, **k):
placed["n"] += 1
return {}
daemon._executor = MockExecutor()
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
tmp_path = f.name
orig_state_file = oanda_daemon.STATE_FILE
try:
oanda_daemon.STATE_FILE = tmp_path
daemon._execute_trade(_make_daemon_report(), Timeframe.M5)
finally:
oanda_daemon.STATE_FILE = orig_state_file
os.unlink(tmp_path)
assert placed["n"] == 0, \
"Recheck under the entry lock must skip when a position exists"
@test("oanda_daemon: same closed bar is never processed twice")
def _():
import tempfile
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
daemon._max_hold_bars = 300
daemon._price_precision = 2
daemon._state_lock = threading.Lock()
bar_time = pd.Timestamp("2026-07-20 10:00:00", tz="UTC")
df = pd.DataFrame(
{"Open": [2400.0], "High": [2405.0], "Low": [2398.0],
"Close": [2403.0], "Volume": [100.0]},
index=pd.DatetimeIndex([bar_time], name="Datetime"))
class MockProvider:
def fetch_ohlcv(self, symbol, tf):
return df
daemon._provider = MockProvider()
daemon._executor = None # trail must not arm on this bar
daemon._state = {
"trades": {
"555": {
"tf": "5min", "direction": "BUY", "entry": 2400.0,
"sl_initial": 2384.0, "current_sl": 2384.0, "risk": 16.0,
"peak": 2400.0, "trail_armed": False,
"trail_arm_r": 0.6, "trail_dist_r": 0.85,
"bars_held": 0, "last_bar_ts": None, "signal_bar_ts": None,
}
},
"last_entries": {},
}
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
tmp_path = f.name
orig_state_file = oanda_daemon.STATE_FILE
try:
oanda_daemon.STATE_FILE = tmp_path
daemon._manage_trailing(Timeframe.M5)
daemon._manage_trailing(Timeframe.M5) # same bar re-served (weekend/lag)
daemon._manage_trailing(Timeframe.M5)
finally:
oanda_daemon.STATE_FILE = orig_state_file
os.unlink(tmp_path)
assert daemon._state["trades"]["555"]["bars_held"] == 1, \
"A re-served bar must not inflate bars_held (weekend timeout bug)"
@test("oanda_daemon: failed timeout-close keeps the trade tracked")
def _():
import tempfile
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
daemon._max_hold_bars = 1
daemon._price_precision = 2
daemon._state_lock = threading.Lock()
bar_time = pd.Timestamp("2026-07-20 10:00:00", tz="UTC")
df = pd.DataFrame(
{"Open": [2400.0], "High": [2405.0], "Low": [2398.0],
"Close": [2403.0], "Volume": [100.0]},
index=pd.DatetimeIndex([bar_time], name="Datetime"))
class MockProvider:
def fetch_ohlcv(self, symbol, tf):
return df
class MockExecutor:
def close_trade(self, trade_id):
raise oanda_executor.OandaAPIError("market halted", 503)
daemon._provider = MockProvider()
daemon._executor = MockExecutor()
daemon._state = {
"trades": {
"666": {
"tf": "5min", "direction": "BUY", "entry": 2400.0,
"sl_initial": 2384.0, "current_sl": 2384.0, "risk": 16.0,
"peak": 2400.0, "trail_armed": False,
"trail_arm_r": 0.6, "trail_dist_r": 0.85,
"bars_held": 0, "last_bar_ts": None, "signal_bar_ts": None,
}
},
"last_entries": {},
}
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
tmp_path = f.name
orig_state_file = oanda_daemon.STATE_FILE
try:
oanda_daemon.STATE_FILE = tmp_path
daemon._manage_trailing(Timeframe.M5)
finally:
oanda_daemon.STATE_FILE = orig_state_file
os.unlink(tmp_path)
assert "666" in daemon._state["trades"], \
"A failed broker close must keep the trade tracked for retry"
@test("app_config: invalid oanda section values raise AppConfigError")
def _():
import tempfile
for bad in (
{"oanda": {"quality_gate": "Z"}},
{"oanda": {"lot_size": 0}},
{"oanda": {"max_hold_bars": -5}},
{"oanda": {"unknown_key": 1}},
):
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False) as f:
json.dump(bad, f)
tmp_path = f.name
try:
app_config.load_app_config(tmp_path)
assert False, f"Should have raised for {bad}"
except app_config.AppConfigError:
pass
finally:
os.unlink(tmp_path)
@test("oanda_daemon: boundary timing matches telegram_bot")
def _():
from telegram_bot import _seconds_until_boundary as tb_boundary
from oanda_daemon import _seconds_until_boundary as od_boundary
for interval in (5, 15, 60, 240):
tb_val = tb_boundary(interval, buffer=10)
od_val = od_boundary(interval, buffer=10)
assert abs(tb_val - od_val) < 0.01, \
f"Boundary timing mismatch for {interval}min: {tb_val} vs {od_val}"
@test("oanda_daemon: state file round-trip")
def _():
import tempfile
state = {
"trades": {
"999": {
"tf": "M15",
"direction": "BUY",
"entry": 2400.0,
"sl_initial": 2384.0,
"current_sl": 2392.0,
"risk": 16.0,
"peak": 2412.0,
"trail_armed": True,
"trail_arm_r": 0.6,
"trail_dist_r": 0.75,
"bars_held": 25,
}
}
}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False) as f:
tmp_path = f.name
orig_state_file = oanda_daemon.STATE_FILE
try:
oanda_daemon.STATE_FILE = tmp_path
oanda_daemon._save_state(state)
loaded = oanda_daemon._load_state()
assert loaded["trades"]["999"]["entry"] == 2400.0
assert loaded["trades"]["999"]["trail_armed"] is True
assert loaded["trades"]["999"]["bars_held"] == 25
finally:
oanda_daemon.STATE_FILE = orig_state_file
os.unlink(tmp_path)
@test("app_config: oanda in _KNOWN_TOP (no unknown-key error)")
def _():
assert "oanda" in app_config._KNOWN_TOP, \
"'oanda' must be in _KNOWN_TOP to avoid config.json validation errors"
@test("app_config: oanda resolver helpers return defaults without config")
def _():
url = app_config.oanda_base_url()
assert "oanda.com" in url, f"Expected Oanda URL, got {url}"
@test("oanda_provider: HTTP error surfaces status code and body")
def _():
mock_resp = type("MockResp", (), {
"status_code": 401,
"text": '{"errorMessage":"Insufficient authorization to perform request"}',
})()
provider = oanda_provider.OandaDataProvider.__new__(
oanda_provider.OandaDataProvider)
provider._base_url = "https://test"
provider._spread_cache = {}
provider._session = type("S", (), {
"get": lambda self, *a, **k: mock_resp,
"headers": {},
})()
try:
provider._fetch_oanda_candles("XAU/USD", Timeframe.H1, 300)
assert False, "Should have raised OandaDataError"
except oanda_provider.OandaDataError as e:
msg = str(e)
assert "401" in msg, f"Status code missing from error: {msg}"
assert "Insufficient authorization" in msg, \
f"Body detail missing from error: {msg}"
@test("oanda_provider: boundary-retry failure keeps original data")
def _():
provider = oanda_provider.OandaDataProvider.__new__(
oanda_provider.OandaDataProvider)
provider.config = PlatformConfig()
provider._cache = {}
provider._cache_timestamps = {}
provider._cache_lock = threading.Lock()
provider._spread_cache = {}
provider.FRESH_FETCH_RETRY_SECONDS = 0 # instance override — no sleep
call_count = {"n": 0}
idx = pd.date_range("2026-07-01", periods=250, freq="5min", tz="UTC")
original_df = pd.DataFrame({
"Open": 2400.0, "High": 2401.0, "Low": 2399.0,
"Close": 2400.5, "Volume": 100.0,
}, index=idx)
def _fetch_oanda_candles(symbol, timeframe, count):
call_count["n"] += 1
if call_count["n"] == 1:
return original_df
raise oanda_provider.OandaDataError("boom")
def _validate_and_clean(df, symbol, timeframe):
return df
def _has_latest_closed_bar(df, timeframe):
return False # forces the retry path every call
def _is_data_fresh(df, symbol, timeframe):
return True
# Plain functions assigned to the instance bypass the descriptor
# protocol — they run unbound, so they take exactly the params
# fetch_ohlcv passes (no self).
provider._fetch_oanda_candles = _fetch_oanda_candles
provider._validate_and_clean = _validate_and_clean
provider._has_latest_closed_bar = _has_latest_closed_bar
provider._is_data_fresh = _is_data_fresh
result = provider.fetch_ohlcv("XAU/USD", Timeframe.M5)
assert call_count["n"] == 2, \
f"Expected original fetch + one retry attempt, got {call_count['n']} calls"
assert len(result) == 250, \
f"Retry failure must keep the original 250-bar df, got {len(result)}"
@test("oanda_executor: non-JSON response raises OandaAPIError with status")
def _():
def _bad_json(self):
raise ValueError("Expecting value: line 1 column 1 (char 0)")
mock_resp = type("MockResp", (), {
"status_code": 200,
"text": "<html>gateway error</html>",
"json": _bad_json,
})()
executor = oanda_executor.OandaExecutor.__new__(
oanda_executor.OandaExecutor)
executor._account_id = "test-account"
executor._base_url = "https://test"
executor._session = type("S", (), {
"request": lambda self, method, url, **kwargs: mock_resp,
"headers": {},
})()
try:
executor.get_open_trades()
assert False, "Should have raised OandaAPIError"
except oanda_executor.OandaAPIError as e:
assert e.status_code == 200, f"Expected status 200, got {e.status_code}"
assert "non-JSON" in str(e), f"Expected non-JSON in message: {e}"
@test("oanda_daemon: boot fails fast on rejected credentials (4xx)")
def _():
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
class MockExecutor401:
def get_account_summary(self):
raise oanda_executor.OandaAPIError("unauthorized", 401)
daemon._executor = MockExecutor401()
try:
daemon._verify_account_access()
assert False, "A 4xx credential rejection must raise SystemExit"
except SystemExit:
pass
class MockExecutorConnFail:
def get_account_summary(self):
raise oanda_executor.OandaAPIError("conn refused", 0)
daemon._executor = MockExecutorConnFail()
daemon._verify_account_access() # transient — must NOT raise
@test("oanda_daemon: heartbeat never raises and logs on account failure")
def _():
daemon = oanda_daemon.TradingDaemon.__new__(oanda_daemon.TradingDaemon)
daemon._state = {"trades": {}, "last_entries": {}}
daemon._state_lock = threading.Lock()
class MockExecutorDown:
def get_account_summary(self):
raise oanda_executor.OandaAPIError("down", 503)
daemon._executor = MockExecutorDown()
daemon._heartbeat_once() # must not raise
class MockExecutorUp:
def get_account_summary(self):
return {"balance": "1000"}
daemon._executor = MockExecutorUp()
daemon._heartbeat_once() # must not raise
# ══════════════════════════════════════════════════════════════════════
# SUMMARY
# ══════════════════════════════════════════════════════════════════════