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. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VUnCqjWBuvKRSjLwHeB9BJ
This commit is contained in:
+842
@@ -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
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user