RayMelius Claude Opus 4.6 commited on
Commit
44c2214
Β·
1 Parent(s): 04e24fd

Add RL trading strategy, regime-based MDF, dynamic strategy switching

Browse files

- Integrate Adilbai/stock-trading-rl-agent PPO model as alternative CH
trading strategy (new ch_rl_trader.py)
- CH_AI_STRATEGY: llm/rl/hybrid (default hybrid, dynamically switchable
via POST /ch/api/strategy)
- MDF now uses regime-driven price dynamics (trending/mean-reverting/
volatile/calm) producing meaningful SMA, EMA, MACD, RSI, Bollinger
Band signals embedded in snapshots
- Rename source CLEARINGHOUSE β†’ CLRH in order messages
- Add stable-baselines3, pandas, scikit-learn, huggingface_hub to Docker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Dockerfile CHANGED
@@ -33,7 +33,12 @@ RUN pip install --no-cache-dir \
33
  kafka-python==2.0.2 \
34
  Flask==2.2.5 \
35
  requests==2.31.0 \
36
- quickfix
 
 
 
 
 
37
 
38
  # ── Application code (flat layout matching /app container paths) ──────────────
39
  WORKDIR /app
 
33
  kafka-python==2.0.2 \
34
  Flask==2.2.5 \
35
  requests==2.31.0 \
36
+ quickfix \
37
+ numpy \
38
+ pandas \
39
+ scikit-learn \
40
+ "stable-baselines3[extra]" \
41
+ huggingface_hub
42
 
43
  # ── Application code (flat layout matching /app container paths) ──────────────
44
  WORKDIR /app
clearing_house/Dockerfile CHANGED
@@ -1,6 +1,7 @@
1
  FROM python:3.11-slim
2
 
3
- RUN pip install --no-cache-dir flask kafka-python requests
 
4
 
5
  WORKDIR /app
6
 
 
1
  FROM python:3.11-slim
2
 
3
+ RUN pip install --no-cache-dir flask kafka-python requests \
4
+ numpy pandas scikit-learn "stable-baselines3[extra]" huggingface_hub
5
 
6
  WORKDIR /app
7
 
clearing_house/app.py CHANGED
@@ -258,7 +258,7 @@ def submit_order():
258
  "ord_type": "LIMIT",
259
  "time_in_force": "DAY",
260
  "timestamp": time.time(),
261
- "source": "CLEARINGHOUSE",
262
  }
263
  get_producer().send(Config.ORDERS_TOPIC, msg)
264
  return jsonify({"status": "ok", "cl_ord_id": cl_ord_id})
@@ -401,6 +401,24 @@ def api_market():
401
  return jsonify(_get_bbos())
402
 
403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
404
  # ── SSE ────────────────────────────────────────────────────────────────────────
405
 
406
  @app.route("/ch/stream")
 
258
  "ord_type": "LIMIT",
259
  "time_in_force": "DAY",
260
  "timestamp": time.time(),
261
+ "source": "CLRH",
262
  }
263
  get_producer().send(Config.ORDERS_TOPIC, msg)
264
  return jsonify({"status": "ok", "cl_ord_id": cl_ord_id})
 
401
  return jsonify(_get_bbos())
402
 
403
 
404
+ @app.route("/ch/api/config")
405
+ def api_config():
406
+ return jsonify({
407
+ "strategy": ai_trader.get_strategy(),
408
+ "obligation": db.CH_DAILY_OBLIGATION,
409
+ "ai_interval": int(os.getenv("CH_AI_INTERVAL", "45")),
410
+ })
411
+
412
+
413
+ @app.route("/ch/api/strategy", methods=["POST"])
414
+ def api_set_strategy():
415
+ data = request.get_json(force=True)
416
+ strategy = data.get("strategy", "")
417
+ result = ai_trader.set_strategy(strategy)
418
+ _broadcast("config", {"strategy": result})
419
+ return jsonify({"status": "ok", "strategy": result})
420
+
421
+
422
  # ── SSE ────────────────────────────────────────────────────────────────────────
423
 
424
  @app.route("/ch/stream")
clearing_house/ch_ai_trader.py CHANGED
@@ -1,10 +1,13 @@
1
  """AI-driven simulation of CH members not currently controlled by a real user.
2
 
3
- Two background threads:
4
  1. _trade_consumer_thread – Kafka consumer on 'trades' topic; attributes
5
  trades back to CH members whose cl_ord_id starts with USRxx-.
6
  2. _simulation_thread – every CH_AI_INTERVAL seconds, picks an order
7
- for each unoccupied member using the LLM (Groq β†’ HF β†’ Ollama fallback).
 
 
 
8
 
9
  Call start() once from app.py after init_db().
10
  Call set_human_active(member_id) / set_human_inactive(member_id) on login/logout.
@@ -28,9 +31,18 @@ from shared.kafka_utils import create_consumer, create_producer
28
 
29
  import ch_database as db
30
 
 
 
 
 
 
 
 
 
31
  # ── Config ─────────────────────────────────────────────────────────────────────
32
  CH_AI_INTERVAL = int(os.getenv("CH_AI_INTERVAL", "45")) # seconds between AI cycles
33
- CH_SOURCE = "CLEARINGHOUSE"
 
34
 
35
  OLLAMA_HOST = os.getenv("OLLAMA_HOST", "")
36
  OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b")
@@ -71,6 +83,24 @@ def is_human_active(member_id: str) -> bool:
71
  return member_id in _active_humans
72
 
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  def start() -> None:
75
  """Start background threads. Call once after app startup."""
76
  global _running
@@ -78,7 +108,11 @@ def start() -> None:
78
  threading.Thread(target=_trade_consumer_thread, daemon=True, name="ch-trade-consumer").start()
79
  threading.Thread(target=_simulation_thread, daemon=True, name="ch-ai-sim").start()
80
  threading.Thread(target=_control_listener_thread, daemon=True, name="ch-control").start()
81
- print("[CH-AI] Background threads started")
 
 
 
 
82
 
83
 
84
  # ── Kafka helpers ──────────────────────────────────────────────────────────────
@@ -127,6 +161,13 @@ def _trade_consumer_thread():
127
  if not symbol or price <= 0 or qty <= 0:
128
  continue
129
 
 
 
 
 
 
 
 
130
  # Detect CH member orders by cl_ord_id prefix pattern USRxx-
131
  for order_id, side in [(buy_id, "BUY"), (sell_id, "SELL")]:
132
  m = re.match(r"^(USR\d{2})-", order_id)
@@ -207,12 +248,44 @@ def _run_simulation_cycle():
207
  if obligation_remaining == 0 and random.random() > 0.3:
208
  continue # occasionally trade even after obligation met
209
 
210
- order = _decide_order_llm(mid, capital, holdings, dt, bbos, obligation_remaining)
211
  if order:
212
  _submit_order(mid, order)
213
  time.sleep(0.5) # stagger submissions
214
 
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  def _load_reference_prices() -> dict:
217
  """Load reference prices from securities.txt as fallback when books are empty."""
218
  ref = {}
 
1
  """AI-driven simulation of CH members not currently controlled by a real user.
2
 
3
+ Three background threads:
4
  1. _trade_consumer_thread – Kafka consumer on 'trades' topic; attributes
5
  trades back to CH members whose cl_ord_id starts with USRxx-.
6
  2. _simulation_thread – every CH_AI_INTERVAL seconds, picks an order
7
+ for each unoccupied member using the configured strategy.
8
+ 3. _control_listener_thread – listens for session start/stop/suspend/resume.
9
+
10
+ Strategy is selected via CH_AI_STRATEGY env var: "llm", "rl", or "hybrid".
11
 
12
  Call start() once from app.py after init_db().
13
  Call set_human_active(member_id) / set_human_inactive(member_id) on login/logout.
 
31
 
32
  import ch_database as db
33
 
34
+ # RL trader (optional – graceful fallback if deps not installed)
35
+ try:
36
+ import ch_rl_trader as rl_trader
37
+ _rl_available = True
38
+ except ImportError:
39
+ _rl_available = False
40
+ print("[CH-AI] RL dependencies not installed β€” RL strategy unavailable")
41
+
42
  # ── Config ─────────────────────────────────────────────────────────────────────
43
  CH_AI_INTERVAL = int(os.getenv("CH_AI_INTERVAL", "45")) # seconds between AI cycles
44
+ CH_AI_STRATEGY = os.getenv("CH_AI_STRATEGY", "hybrid") # "llm", "rl", or "hybrid"
45
+ CH_SOURCE = "CLRH"
46
 
47
  OLLAMA_HOST = os.getenv("OLLAMA_HOST", "")
48
  OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3.1:8b")
 
83
  return member_id in _active_humans
84
 
85
 
86
+ def get_strategy() -> str:
87
+ return CH_AI_STRATEGY
88
+
89
+
90
+ def set_strategy(strategy: str) -> str:
91
+ """Dynamically switch AI strategy. Returns the active strategy."""
92
+ global CH_AI_STRATEGY
93
+ strategy = strategy.lower().strip()
94
+ if strategy not in ("llm", "rl", "hybrid"):
95
+ return CH_AI_STRATEGY
96
+ if strategy in ("rl", "hybrid") and not _rl_available:
97
+ print(f"[CH-AI] Cannot switch to {strategy}: RL deps not installed")
98
+ return CH_AI_STRATEGY
99
+ CH_AI_STRATEGY = strategy
100
+ print(f"[CH-AI] Strategy switched to: {strategy}")
101
+ return CH_AI_STRATEGY
102
+
103
+
104
  def start() -> None:
105
  """Start background threads. Call once after app startup."""
106
  global _running
 
108
  threading.Thread(target=_trade_consumer_thread, daemon=True, name="ch-trade-consumer").start()
109
  threading.Thread(target=_simulation_thread, daemon=True, name="ch-ai-sim").start()
110
  threading.Thread(target=_control_listener_thread, daemon=True, name="ch-control").start()
111
+ strategy = CH_AI_STRATEGY
112
+ if strategy in ("rl", "hybrid") and not _rl_available:
113
+ strategy = "llm"
114
+ print("[CH-AI] WARNING: RL requested but deps missing, falling back to LLM")
115
+ print(f"[CH-AI] Background threads started (strategy={strategy})")
116
 
117
 
118
  # ── Kafka helpers ──────────────────────────────────────────────────────────────
 
161
  if not symbol or price <= 0 or qty <= 0:
162
  continue
163
 
164
+ # Feed every trade into RL price history (regardless of strategy)
165
+ if _rl_available:
166
+ try:
167
+ rl_trader.feed_trade(symbol, price, qty)
168
+ except Exception:
169
+ pass
170
+
171
  # Detect CH member orders by cl_ord_id prefix pattern USRxx-
172
  for order_id, side in [(buy_id, "BUY"), (sell_id, "SELL")]:
173
  m = re.match(r"^(USR\d{2})-", order_id)
 
248
  if obligation_remaining == 0 and random.random() > 0.3:
249
  continue # occasionally trade even after obligation met
250
 
251
+ order = _decide_order(mid, capital, holdings, dt, bbos, obligation_remaining)
252
  if order:
253
  _submit_order(mid, order)
254
  time.sleep(0.5) # stagger submissions
255
 
256
 
257
+ def _decide_order(member_id, capital, holdings, daily_trades, bbos, obligation_remaining):
258
+ """Dispatch to the configured strategy."""
259
+ strategy = CH_AI_STRATEGY
260
+
261
+ # Hybrid: split members between RL and LLM
262
+ if strategy == "hybrid" and _rl_available:
263
+ member_num = int(member_id[-2:])
264
+ strategy = "rl" if member_num <= 5 else "llm"
265
+
266
+ if strategy == "rl" and _rl_available:
267
+ try:
268
+ order = rl_trader.decide_order_rl(
269
+ member_id, capital, holdings, bbos, obligation_remaining,
270
+ )
271
+ if order and _validate_order(order, capital, holdings, bbos):
272
+ try:
273
+ db.record_ai_decision(member_id, f"RL: {order}", order, source="rl")
274
+ except Exception:
275
+ pass
276
+ return order
277
+ except Exception as e:
278
+ print(f"[CH-AI] RL strategy error for {member_id}: {e}")
279
+ # Fall through to LLM on RL failure
280
+ return _decide_order_llm(
281
+ member_id, capital, holdings, daily_trades, bbos, obligation_remaining,
282
+ )
283
+
284
+ return _decide_order_llm(
285
+ member_id, capital, holdings, daily_trades, bbos, obligation_remaining,
286
+ )
287
+
288
+
289
  def _load_reference_prices() -> dict:
290
  """Load reference prices from securities.txt as fallback when books are empty."""
291
  ref = {}
clearing_house/ch_rl_trader.py ADDED
@@ -0,0 +1,410 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RL-based trading strategy using Adilbai/stock-trading-rl-agent (PPO).
2
+
3
+ Provides decide_order_rl() with the same return type as _decide_order_llm()
4
+ so it can be used as a drop-in alternative in ch_ai_trader.py.
5
+ """
6
+
7
+ import os
8
+ import pickle
9
+ import random
10
+ import threading
11
+ import time
12
+ from collections import deque
13
+ from typing import Optional
14
+
15
+ import numpy as np
16
+
17
+ # ── Config ────────────────────────────────────────────────────────────────────
18
+ RL_MODEL_REPO = os.getenv("CH_RL_MODEL_REPO", "Adilbai/stock-trading-rl-agent")
19
+ RL_MODEL_CACHE = os.getenv("CH_RL_MODEL_CACHE", "/app/data/rl_model")
20
+ RL_BAR_INTERVAL = int(os.getenv("CH_RL_BAR_INTERVAL", "60")) # seconds per bar
21
+ RL_MIN_BARS = int(os.getenv("CH_RL_MIN_BARS", "30")) # min bars before RL kicks in
22
+ RL_LOOKBACK = 60
23
+
24
+ # ── Shared state ──────────────────────────────────────────────────────────────
25
+ _model = None
26
+ _scaler = None
27
+ _model_lock = threading.Lock()
28
+ _load_attempted = False
29
+
30
+ # Per-symbol rolling price bars: {symbol: deque of {open, high, low, close, volume}}
31
+ _price_bars: dict[str, deque] = {}
32
+ _bars_lock = threading.Lock()
33
+ # Accumulator for current bar being built
34
+ _current_bar: dict[str, dict] = {}
35
+
36
+
37
+ # ── Model loading ─────────────────────────────────────────────────────────────
38
+
39
+ def _load_model():
40
+ """Download and load the PPO model + scaler from HuggingFace Hub."""
41
+ global _model, _scaler, _load_attempted
42
+ with _model_lock:
43
+ if _load_attempted:
44
+ return _model is not None
45
+ _load_attempted = True
46
+
47
+ try:
48
+ from huggingface_hub import hf_hub_download
49
+ from stable_baselines3 import PPO
50
+
51
+ os.makedirs(RL_MODEL_CACHE, exist_ok=True)
52
+ print(f"[CH-RL] Downloading model from {RL_MODEL_REPO}...")
53
+
54
+ model_path = hf_hub_download(
55
+ repo_id=RL_MODEL_REPO, filename="final_model.zip",
56
+ cache_dir=RL_MODEL_CACHE,
57
+ )
58
+ scaler_path = hf_hub_download(
59
+ repo_id=RL_MODEL_REPO, filename="scaler.pkl",
60
+ cache_dir=RL_MODEL_CACHE,
61
+ )
62
+
63
+ with _model_lock:
64
+ _model = PPO.load(model_path)
65
+ with open(scaler_path, "rb") as f:
66
+ _scaler = pickle.load(f)
67
+
68
+ print("[CH-RL] Model loaded successfully")
69
+ return True
70
+ except Exception as e:
71
+ print(f"[CH-RL] Failed to load model: {e}")
72
+ return False
73
+
74
+
75
+ def is_available() -> bool:
76
+ """Check if RL model is loaded and ready."""
77
+ return _model is not None
78
+
79
+
80
+ # ── Price history ─────────────────────────────────────────────────────────────
81
+
82
+ def feed_trade(symbol: str, price: float, quantity: int):
83
+ """Feed a trade into the price bar accumulator. Called from trade consumer."""
84
+ with _bars_lock:
85
+ if symbol not in _current_bar:
86
+ _current_bar[symbol] = {
87
+ "open": price, "high": price, "low": price,
88
+ "close": price, "volume": quantity,
89
+ "start_time": time.time(),
90
+ }
91
+ else:
92
+ bar = _current_bar[symbol]
93
+ bar["high"] = max(bar["high"], price)
94
+ bar["low"] = min(bar["low"], price)
95
+ bar["close"] = price
96
+ bar["volume"] += quantity
97
+
98
+ # Finalize bar if interval elapsed
99
+ bar = _current_bar[symbol]
100
+ if time.time() - bar["start_time"] >= RL_BAR_INTERVAL:
101
+ _finalize_bar(symbol)
102
+
103
+
104
+ def seed_price(symbol: str, ref_price: float):
105
+ """Seed initial bars from reference price when no trade history exists."""
106
+ with _bars_lock:
107
+ if symbol in _price_bars and len(_price_bars[symbol]) > 0:
108
+ return
109
+ if symbol not in _price_bars:
110
+ _price_bars[symbol] = deque(maxlen=120)
111
+ # Create flat bars with small noise for indicator computation
112
+ for i in range(RL_LOOKBACK):
113
+ noise = random.uniform(-0.02, 0.02) * ref_price
114
+ p = ref_price + noise
115
+ _price_bars[symbol].append({
116
+ "open": round(p, 2),
117
+ "high": round(p + abs(noise) * 0.5, 2),
118
+ "low": round(p - abs(noise) * 0.5, 2),
119
+ "close": round(p, 2),
120
+ "volume": random.randint(100, 500),
121
+ })
122
+
123
+
124
+ def _finalize_bar(symbol: str):
125
+ """Move current accumulator into the history deque. Must hold _bars_lock."""
126
+ bar = _current_bar.pop(symbol, None)
127
+ if not bar:
128
+ return
129
+ if symbol not in _price_bars:
130
+ _price_bars[symbol] = deque(maxlen=120)
131
+ _price_bars[symbol].append({
132
+ "open": bar["open"], "high": bar["high"],
133
+ "low": bar["low"], "close": bar["close"],
134
+ "volume": bar["volume"],
135
+ })
136
+
137
+
138
+ def get_bar_count(symbol: str) -> int:
139
+ with _bars_lock:
140
+ return len(_price_bars.get(symbol, []))
141
+
142
+
143
+ # ── Technical indicators ──────────────────────────────────────────────────────
144
+
145
+ def _compute_indicators(bars: list[dict]) -> np.ndarray:
146
+ """Compute technical indicators from OHLCV bars. Returns (n_bars, n_features) array."""
147
+ n = len(bars)
148
+ close = np.array([b["close"] for b in bars], dtype=np.float64)
149
+ high = np.array([b["high"] for b in bars], dtype=np.float64)
150
+ low = np.array([b["low"] for b in bars], dtype=np.float64)
151
+ opn = np.array([b["open"] for b in bars], dtype=np.float64)
152
+ volume = np.array([b["volume"] for b in bars], dtype=np.float64)
153
+
154
+ def sma(arr, w):
155
+ out = np.full(n, np.nan)
156
+ if n >= w:
157
+ cs = np.cumsum(arr)
158
+ out[w - 1:] = (cs[w - 1:] - np.concatenate([[0], cs[:-w]])) / w
159
+ # Fill NaN with first valid
160
+ for i in range(n):
161
+ if np.isnan(out[i]):
162
+ out[i] = arr[i]
163
+ else:
164
+ break
165
+ out[:] = np.where(np.isnan(out), out[np.argmax(~np.isnan(out))], out)
166
+ return out
167
+
168
+ def ema(arr, span):
169
+ out = np.empty(n)
170
+ alpha = 2.0 / (span + 1)
171
+ out[0] = arr[0]
172
+ for i in range(1, n):
173
+ out[i] = alpha * arr[i] + (1 - alpha) * out[i - 1]
174
+ return out
175
+
176
+ sma5 = sma(close, 5)
177
+ sma10 = sma(close, 10)
178
+ sma20 = sma(close, 20)
179
+ sma50 = sma(close, 50)
180
+ ema12 = ema(close, 12)
181
+ ema26 = ema(close, 26)
182
+
183
+ macd = ema12 - ema26
184
+ signal = ema(macd, 9)
185
+ histogram = macd - signal
186
+
187
+ # RSI
188
+ delta = np.diff(close, prepend=close[0])
189
+ gain = np.where(delta > 0, delta, 0.0)
190
+ loss = np.where(delta < 0, -delta, 0.0)
191
+ avg_gain = ema(gain, 14)
192
+ avg_loss = ema(loss, 14)
193
+ rs = np.where(avg_loss > 0, avg_gain / avg_loss, 100.0)
194
+ rsi = 100.0 - 100.0 / (1.0 + rs)
195
+
196
+ # Bollinger Bands
197
+ bb_mid = sma20
198
+ bb_std = np.full(n, 0.01)
199
+ for i in range(n):
200
+ start = max(0, i - 19)
201
+ bb_std[i] = max(np.std(close[start:i + 1]), 0.01)
202
+ bb_upper = bb_mid + 2 * bb_std
203
+ bb_lower = bb_mid - 2 * bb_std
204
+ bb_width = (bb_upper - bb_lower) / np.where(bb_mid > 0, bb_mid, 1.0)
205
+ bb_pos = (close - bb_lower) / np.where(bb_upper - bb_lower > 0, bb_upper - bb_lower, 1.0)
206
+
207
+ # Volatility
208
+ vol20 = np.full(n, 0.01)
209
+ for i in range(n):
210
+ start = max(0, i - 19)
211
+ vol20[i] = max(np.std(close[start:i + 1]), 0.01)
212
+
213
+ # Price changes
214
+ price_change = np.diff(close, prepend=close[0]) / np.where(close > 0, close, 1.0)
215
+ price_change_5 = np.zeros(n)
216
+ for i in range(5, n):
217
+ price_change_5[i] = (close[i] - close[i - 5]) / close[i - 5] if close[i - 5] > 0 else 0
218
+
219
+ hl_ratio = high / np.where(low > 0, low, 1.0)
220
+ oc_ratio = close / np.where(opn > 0, opn, 1.0)
221
+
222
+ vol_sma = sma(volume, 20)
223
+ vol_ratio = volume / np.where(vol_sma > 0, vol_sma, 1.0)
224
+
225
+ # Base features (20 columns)
226
+ features = np.column_stack([
227
+ close, volume, sma5, sma10, sma20, sma50,
228
+ ema12, ema26, rsi, macd, signal, histogram,
229
+ bb_upper, bb_lower, bb_width, bb_pos,
230
+ vol20, price_change, hl_ratio, vol_ratio,
231
+ ])
232
+
233
+ # Lagged features: close, volume, price_change, rsi, macd, vol20 at lags 1,2,3,5,10
234
+ lag_sources = np.column_stack([close, volume, price_change, rsi, macd, vol20])
235
+ lag_cols = []
236
+ for lag in [1, 2, 3, 5, 10]:
237
+ shifted = np.roll(lag_sources, lag, axis=0)
238
+ shifted[:lag] = lag_sources[0] # fill with first value
239
+ lag_cols.append(shifted)
240
+ lags = np.hstack(lag_cols) # (n, 30)
241
+
242
+ return np.hstack([features, lags]) # (n, 50)
243
+
244
+
245
+ # ── Observation builder ───────────────────────────────────────────────────────
246
+
247
+ def _build_observation(
248
+ symbol: str,
249
+ capital: float,
250
+ holdings: list,
251
+ bbos: dict,
252
+ ) -> Optional[np.ndarray]:
253
+ """Build the 3008-dim observation vector for one symbol."""
254
+ with _bars_lock:
255
+ bars = list(_price_bars.get(symbol, []))
256
+
257
+ if len(bars) < RL_MIN_BARS:
258
+ return None
259
+
260
+ # Pad to RL_LOOKBACK if needed
261
+ while len(bars) < RL_LOOKBACK:
262
+ bars.insert(0, bars[0])
263
+
264
+ bars = bars[-RL_LOOKBACK:]
265
+
266
+ indicators = _compute_indicators(bars) # (60, 50)
267
+
268
+ # Scale using the loaded scaler
269
+ if _scaler is not None:
270
+ try:
271
+ indicators = _scaler.transform(indicators)
272
+ except Exception:
273
+ # Shape mismatch β€” normalize manually
274
+ mean = indicators.mean(axis=0)
275
+ std = indicators.std(axis=0)
276
+ std[std == 0] = 1.0
277
+ indicators = (indicators - mean) / std
278
+
279
+ market_state = indicators.flatten() # 3000
280
+
281
+ # Portfolio state (8 features)
282
+ held_qty = 0
283
+ held_value = 0.0
284
+ for h in holdings:
285
+ if h["symbol"] == symbol:
286
+ held_qty = h["quantity"]
287
+ bbo = bbos.get(symbol, {})
288
+ price = bbo.get("best_bid") or h["avg_cost"]
289
+ held_value = held_qty * price
290
+ break
291
+
292
+ net_worth = capital + sum(
293
+ h["quantity"] * (bbos.get(h["symbol"], {}).get("best_bid") or h["avg_cost"])
294
+ for h in holdings
295
+ )
296
+ returns = (net_worth - 100_000) / 100_000 # relative to starting capital
297
+
298
+ portfolio_state = np.array([
299
+ capital, held_qty, net_worth, returns,
300
+ held_value, float(held_qty > 0),
301
+ capital / max(net_worth, 1.0),
302
+ held_value / max(net_worth, 1.0),
303
+ ], dtype=np.float64)
304
+
305
+ obs = np.concatenate([market_state, portfolio_state])
306
+ return obs.astype(np.float32)
307
+
308
+
309
+ # ── Main decision function ────────────────────────────────────────────────────
310
+
311
+ def decide_order_rl(
312
+ member_id: str,
313
+ capital: float,
314
+ holdings: list,
315
+ bbos: dict,
316
+ obligation_remaining: int,
317
+ ) -> Optional[dict]:
318
+ """Use the RL model to decide a trade. Returns order dict or None."""
319
+ if not _model:
320
+ if not _load_model():
321
+ return None
322
+
323
+ # Seed price history from BBOs for symbols we haven't seen
324
+ for sym, bbo in bbos.items():
325
+ mid = None
326
+ if bbo.get("best_bid") and bbo.get("best_ask"):
327
+ mid = (bbo["best_bid"] + bbo["best_ask"]) / 2
328
+ elif bbo.get("best_bid"):
329
+ mid = bbo["best_bid"]
330
+ elif bbo.get("best_ask"):
331
+ mid = bbo["best_ask"]
332
+ if mid:
333
+ seed_price(sym, mid)
334
+
335
+ # Run prediction for each symbol, pick the best actionable one
336
+ candidates = []
337
+
338
+ for sym, bbo in bbos.items():
339
+ obs = _build_observation(sym, capital, holdings, bbos)
340
+ if obs is None:
341
+ continue
342
+
343
+ try:
344
+ action, _ = _model.predict(obs, deterministic=False)
345
+ action_type = int(round(float(action[0])))
346
+ position_size = float(np.clip(action[1], 0.05, 0.5))
347
+
348
+ action_type = max(0, min(2, action_type))
349
+
350
+ if action_type == 0:
351
+ continue # Hold
352
+
353
+ if action_type == 1: # Buy
354
+ ask = bbo.get("best_ask")
355
+ if not ask or ask <= 0:
356
+ continue
357
+ affordable = int(capital // ask)
358
+ qty = max(10, int(affordable * position_size))
359
+ qty = min(qty, 200)
360
+ if qty * ask > capital:
361
+ qty = int(capital // ask)
362
+ if qty < 10:
363
+ continue
364
+ candidates.append({
365
+ "symbol": sym, "side": "BUY",
366
+ "quantity": qty, "price": round(ask, 2),
367
+ "score": position_size,
368
+ })
369
+
370
+ elif action_type == 2: # Sell
371
+ held = next((h["quantity"] for h in holdings if h["symbol"] == sym), 0)
372
+ if held <= 0:
373
+ continue
374
+ bid = bbo.get("best_bid")
375
+ if not bid or bid <= 0:
376
+ continue
377
+ qty = max(10, int(held * position_size))
378
+ qty = min(qty, held, 200)
379
+ if qty < 10:
380
+ continue
381
+ candidates.append({
382
+ "symbol": sym, "side": "SELL",
383
+ "quantity": qty, "price": round(bid, 2),
384
+ "score": position_size,
385
+ })
386
+ except Exception as e:
387
+ print(f"[CH-RL] Prediction error for {sym}: {e}")
388
+ continue
389
+
390
+ if not candidates:
391
+ # If obligation remains, force a buy on a random affordable symbol
392
+ if obligation_remaining > 0:
393
+ affordable = [
394
+ (sym, bbo) for sym, bbo in bbos.items()
395
+ if bbo.get("best_ask") and 10 * bbo["best_ask"] <= capital
396
+ ]
397
+ if affordable:
398
+ sym, bbo = random.choice(affordable)
399
+ return {
400
+ "symbol": sym, "side": "BUY",
401
+ "quantity": random.randint(10, 50),
402
+ "price": round(bbo["best_ask"], 2),
403
+ }
404
+ return None
405
+
406
+ # Pick highest-confidence candidate
407
+ candidates.sort(key=lambda c: c["score"], reverse=True)
408
+ best = candidates[0]
409
+ best.pop("score")
410
+ return best
docker-compose.yml CHANGED
@@ -208,6 +208,7 @@ services:
208
  - GROQ_API_KEY=${GROQ_API_KEY:-}
209
  - GROQ_MODEL=${GROQ_MODEL:-llama-3.1-8b-instant}
210
  - OLLAMA_HOST=${OLLAMA_HOST:-}
 
211
  extra_hosts:
212
  - "host.docker.internal:host-gateway"
213
 
 
208
  - GROQ_API_KEY=${GROQ_API_KEY:-}
209
  - GROQ_MODEL=${GROQ_MODEL:-llama-3.1-8b-instant}
210
  - OLLAMA_HOST=${OLLAMA_HOST:-}
211
+ - CH_AI_STRATEGY=${CH_AI_STRATEGY:-hybrid}
212
  extra_hosts:
213
  - "host.docker.internal:host-gateway"
214
 
md_feeder/mdf_simulator.py CHANGED
@@ -1,8 +1,16 @@
1
  #!/usr/bin/env python3
 
 
 
 
 
 
 
2
  import sys
3
  sys.path.insert(0, "/app")
4
 
5
- import json, time, random, os, threading
 
6
 
7
  from shared.config import Config
8
  from shared.kafka_utils import create_producer, create_consumer
@@ -15,6 +23,158 @@ _running = True
15
  _suspended = False
16
 
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  def load_securities():
19
  """Load securities from file: SYMBOL start_price current_price"""
20
  securities = {}
@@ -56,7 +216,8 @@ def make_order(symbol, side, price, qty):
56
  }
57
 
58
 
59
- def make_snapshot(symbol, best_bid, best_ask, bid_size, ask_size):
 
60
  return {
61
  "symbol": symbol,
62
  "best_bid": round(best_bid, 2),
@@ -64,13 +225,23 @@ def make_snapshot(symbol, best_bid, best_ask, bid_size, ask_size):
64
  "bid_size": bid_size,
65
  "ask_size": ask_size,
66
  "timestamp": time.time(),
67
- "source": "MDF"
 
 
 
 
 
 
 
 
 
 
68
  }
69
 
70
 
71
  def listen_control(ctrl_consumer):
72
  """Background thread: listen for start/stop/suspend/resume control messages."""
73
- global _running, _suspended, _securities
74
  print("[MDF] Control listener started")
75
  for msg in ctrl_consumer:
76
  action = (msg.value or {}).get("action")
@@ -83,6 +254,10 @@ def listen_control(ctrl_consumer):
83
  new_secs = load_securities()
84
  _securities.clear()
85
  _securities.update(new_secs)
 
 
 
 
86
  print(f"[MDF] START signal – reloaded securities: {list(_securities.keys())}")
87
  except Exception as e:
88
  print(f"[MDF] Error reloading securities on start: {e}")
@@ -100,10 +275,11 @@ def listen_control(ctrl_consumer):
100
  if __name__ == "__main__":
101
  producer = create_producer(component_name="MDF")
102
 
103
- # Load securities and snapshot start prices
104
  _securities = load_securities()
105
- for sym in _securities:
106
- _securities[sym]["start"] = _securities[sym]["current"]
 
107
  save_securities(_securities)
108
  print(f"[MDF] Loaded securities: {list(_securities.keys())}")
109
 
@@ -129,49 +305,72 @@ if __name__ == "__main__":
129
  if not _running or _suspended:
130
  break
131
 
132
- mid = vals["current"]
133
- half_spread = 0.10
 
 
 
 
 
134
  tick = Config.TICK_SIZE
 
 
 
 
 
 
 
135
 
136
- # Always place a resting BID and ASK to maintain book depth
 
 
 
 
 
 
 
 
 
 
 
 
137
  for depth_level in range(3):
138
  offset = random.randint(1 + depth_level * 3, 3 + depth_level * 5) * tick
139
  bid_price = round(mid - half_spread - offset, 2)
140
  ask_price = round(mid + half_spread + offset, 2)
141
- bid_qty = random.choice([50, 100, 150, 200])
142
- ask_qty = random.choice([50, 100, 150, 200])
 
 
 
 
 
 
143
 
144
  bid_order = make_order(sym, "BUY", bid_price, bid_qty)
145
  ask_order = make_order(sym, "SELL", ask_price, ask_qty)
146
  producer.send(Config.ORDERS_TOPIC, bid_order)
147
  producer.send(Config.ORDERS_TOPIC, ask_order)
148
- print(f"[MDF] Depth: {sym} BID {bid_qty}@{bid_price:.2f} ASK {ask_qty}@{ask_price:.2f}")
149
 
150
- # Occasionally add an aggressive order to generate trades (20%)
151
- if random.random() < 0.20:
152
- side = random.choice(["BUY", "SELL"])
 
153
  if side == "BUY":
154
  price = round(mid + half_spread + random.randint(1, 3) * tick, 2)
155
  else:
156
  price = round(mid - half_spread - random.randint(1, 3) * tick, 2)
157
- qty = random.choice([50, 100, 150])
158
  aggr_order = make_order(sym, side, price, qty)
159
  producer.send(Config.ORDERS_TOPIC, aggr_order)
160
- print(f"[MDF] Aggr: {sym} {side} {qty}@{price:.2f}")
161
-
162
- # Simulate small price drift (10% chance, max 2 ticks)
163
- if random.random() < 0.10:
164
- drift = random.choice([-2, -1, 1, 2]) * tick
165
- new_price = vals["current"] + drift
166
- if new_price >= 1.00:
167
- vals["current"] = round(new_price, 2)
168
- save_securities(_securities)
169
 
 
 
170
  best_bid = round(mid - half_spread, 2)
171
  best_ask = round(mid + half_spread, 2)
172
- bid_size = random.choice([100, 200, 300])
173
- ask_size = random.choice([100, 200, 300])
174
- snap = make_snapshot(sym, best_bid, best_ask, bid_size, ask_size)
175
  producer.send(Config.SNAPSHOTS_TOPIC, snap)
176
 
177
  time.sleep(ORDER_INTERVAL)
 
1
  #!/usr/bin/env python3
2
+ """Market Data Feeder β€” generates synthetic orders with realistic price dynamics.
3
+
4
+ Price evolution uses regime-based simulation (trending / mean-reverting / volatile)
5
+ so that technical indicators (SMA, EMA, MACD, RSI, Bollinger Bands) produce
6
+ meaningful, non-random signals for the RL and LLM trading strategies.
7
+ """
8
+
9
  import sys
10
  sys.path.insert(0, "/app")
11
 
12
+ import json, math, time, random, os, threading
13
+ from collections import deque
14
 
15
  from shared.config import Config
16
  from shared.kafka_utils import create_producer, create_consumer
 
23
  _suspended = False
24
 
25
 
26
+ # ── Price dynamics engine ─────────────────────────────────────────────────────
27
+
28
+ class PriceDynamics:
29
+ """Per-symbol state for regime-driven price simulation."""
30
+
31
+ REGIMES = ("trending_up", "trending_down", "mean_revert", "volatile", "calm")
32
+
33
+ def __init__(self, symbol: str, start_price: float):
34
+ self.symbol = symbol
35
+ self.start_price = start_price
36
+ self.price = start_price
37
+ self.regime = random.choice(self.REGIMES)
38
+ self.regime_ticks = 0
39
+ self.regime_duration = random.randint(20, 60)
40
+ # Rolling history for indicator-aware order generation
41
+ self.history = deque(maxlen=60)
42
+ self.history.append(start_price)
43
+ # Momentum / mean-reversion state
44
+ self.momentum = 0.0
45
+ self.volatility = start_price * 0.005 # ~0.5% base volatility
46
+
47
+ def tick(self) -> float:
48
+ """Advance one tick and return the new mid price."""
49
+ tick_size = Config.TICK_SIZE
50
+ self.regime_ticks += 1
51
+
52
+ # Switch regime when duration expires
53
+ if self.regime_ticks >= self.regime_duration:
54
+ self._switch_regime()
55
+
56
+ # Compute drift + noise based on regime
57
+ if self.regime == "trending_up":
58
+ drift = self.volatility * random.uniform(0.2, 0.8)
59
+ noise = random.gauss(0, self.volatility * 0.5)
60
+ self.momentum = max(self.momentum * 0.95 + drift * 0.05, 0)
61
+ elif self.regime == "trending_down":
62
+ drift = -self.volatility * random.uniform(0.2, 0.8)
63
+ noise = random.gauss(0, self.volatility * 0.5)
64
+ self.momentum = min(self.momentum * 0.95 + drift * 0.05, 0)
65
+ elif self.regime == "mean_revert":
66
+ # Pull toward start price
67
+ pull = (self.start_price - self.price) * 0.03
68
+ drift = pull
69
+ noise = random.gauss(0, self.volatility * 0.3)
70
+ self.momentum *= 0.8
71
+ elif self.regime == "volatile":
72
+ drift = random.gauss(0, self.volatility * 0.5)
73
+ noise = random.gauss(0, self.volatility * 1.5)
74
+ self.momentum *= 0.9
75
+ else: # calm
76
+ drift = random.gauss(0, self.volatility * 0.1)
77
+ noise = random.gauss(0, self.volatility * 0.2)
78
+ self.momentum *= 0.95
79
+
80
+ delta = drift + noise + self.momentum
81
+ # Quantize to tick size
82
+ delta = round(delta / tick_size) * tick_size
83
+ new_price = self.price + delta
84
+
85
+ # Floor at 1.00
86
+ new_price = max(1.00, round(new_price, 2))
87
+
88
+ # Adaptive volatility: expand in volatile regime, contract in calm
89
+ if self.regime == "volatile":
90
+ self.volatility = min(self.volatility * 1.002, self.price * 0.02)
91
+ elif self.regime == "calm":
92
+ self.volatility = max(self.volatility * 0.998, self.price * 0.002)
93
+
94
+ self.price = new_price
95
+ self.history.append(new_price)
96
+ return new_price
97
+
98
+ def _switch_regime(self):
99
+ """Transition to a new regime with weighted probabilities."""
100
+ # Bias: if price far from start, favor mean reversion
101
+ deviation = (self.price - self.start_price) / self.start_price
102
+ if abs(deviation) > 0.15:
103
+ weights = [0.05, 0.05, 0.50, 0.20, 0.20]
104
+ elif abs(deviation) > 0.08:
105
+ weights = [0.15, 0.15, 0.30, 0.20, 0.20]
106
+ else:
107
+ weights = [0.25, 0.25, 0.10, 0.15, 0.25]
108
+
109
+ self.regime = random.choices(self.REGIMES, weights=weights)[0]
110
+ self.regime_ticks = 0
111
+ self.regime_duration = random.randint(15, 50)
112
+
113
+ def sma(self, period: int) -> float:
114
+ h = list(self.history)
115
+ if len(h) < period:
116
+ return sum(h) / len(h)
117
+ return sum(h[-period:]) / period
118
+
119
+ def ema(self, period: int) -> float:
120
+ h = list(self.history)
121
+ alpha = 2.0 / (period + 1)
122
+ ema_val = h[0]
123
+ for p in h[1:]:
124
+ ema_val = alpha * p + (1 - alpha) * ema_val
125
+ return ema_val
126
+
127
+ def rsi(self, period: int = 14) -> float:
128
+ h = list(self.history)
129
+ if len(h) < 2:
130
+ return 50.0
131
+ gains, losses = [], []
132
+ for i in range(1, len(h)):
133
+ d = h[i] - h[i - 1]
134
+ gains.append(max(d, 0))
135
+ losses.append(max(-d, 0))
136
+ n = min(period, len(gains))
137
+ avg_gain = sum(gains[-n:]) / n if n > 0 else 0
138
+ avg_loss = sum(losses[-n:]) / n if n > 0 else 0.001
139
+ rs = avg_gain / avg_loss if avg_loss > 0 else 100
140
+ return 100 - 100 / (1 + rs)
141
+
142
+ def bollinger_position(self, period: int = 20) -> float:
143
+ """Returns 0..1 position within Bollinger Bands (0.5 = at SMA)."""
144
+ h = list(self.history)
145
+ n = min(period, len(h))
146
+ window = h[-n:]
147
+ mean = sum(window) / n
148
+ std = max((sum((x - mean) ** 2 for x in window) / n) ** 0.5, 0.01)
149
+ upper = mean + 2 * std
150
+ lower = mean - 2 * std
151
+ if upper == lower:
152
+ return 0.5
153
+ return max(0.0, min(1.0, (self.price - lower) / (upper - lower)))
154
+
155
+ def spread_and_depth(self) -> tuple:
156
+ """Compute adaptive spread and depth quantities based on regime."""
157
+ base_spread = 0.10
158
+ if self.regime == "volatile":
159
+ spread = base_spread * random.uniform(1.5, 2.5)
160
+ depth_qty_range = (30, 100)
161
+ elif self.regime == "calm":
162
+ spread = base_spread * random.uniform(0.6, 1.0)
163
+ depth_qty_range = (100, 300)
164
+ elif self.regime in ("trending_up", "trending_down"):
165
+ spread = base_spread * random.uniform(0.8, 1.5)
166
+ depth_qty_range = (50, 200)
167
+ else:
168
+ spread = base_spread
169
+ depth_qty_range = (80, 250)
170
+ return round(spread, 2), depth_qty_range
171
+
172
+
173
+ _dynamics: dict[str, PriceDynamics] = {}
174
+
175
+
176
+ # ── Securities I/O ────────────────────────────────────────────────────────────
177
+
178
  def load_securities():
179
  """Load securities from file: SYMBOL start_price current_price"""
180
  securities = {}
 
216
  }
217
 
218
 
219
+ def make_snapshot(symbol, best_bid, best_ask, bid_size, ask_size, dyn: PriceDynamics):
220
+ """Build snapshot with embedded technical indicators."""
221
  return {
222
  "symbol": symbol,
223
  "best_bid": round(best_bid, 2),
 
225
  "bid_size": bid_size,
226
  "ask_size": ask_size,
227
  "timestamp": time.time(),
228
+ "source": "MDF",
229
+ "indicators": {
230
+ "sma_5": round(dyn.sma(5), 4),
231
+ "sma_20": round(dyn.sma(20), 4),
232
+ "ema_12": round(dyn.ema(12), 4),
233
+ "ema_26": round(dyn.ema(26), 4),
234
+ "macd": round(dyn.ema(12) - dyn.ema(26), 4),
235
+ "rsi_14": round(dyn.rsi(14), 2),
236
+ "bb_pos": round(dyn.bollinger_position(20), 4),
237
+ "regime": dyn.regime,
238
+ },
239
  }
240
 
241
 
242
  def listen_control(ctrl_consumer):
243
  """Background thread: listen for start/stop/suspend/resume control messages."""
244
+ global _running, _suspended, _securities, _dynamics
245
  print("[MDF] Control listener started")
246
  for msg in ctrl_consumer:
247
  action = (msg.value or {}).get("action")
 
254
  new_secs = load_securities()
255
  _securities.clear()
256
  _securities.update(new_secs)
257
+ # Re-init dynamics for new session
258
+ _dynamics.clear()
259
+ for sym, vals in _securities.items():
260
+ _dynamics[sym] = PriceDynamics(sym, vals["current"])
261
  print(f"[MDF] START signal – reloaded securities: {list(_securities.keys())}")
262
  except Exception as e:
263
  print(f"[MDF] Error reloading securities on start: {e}")
 
275
  if __name__ == "__main__":
276
  producer = create_producer(component_name="MDF")
277
 
278
+ # Load securities and init dynamics
279
  _securities = load_securities()
280
+ for sym, vals in _securities.items():
281
+ vals["start"] = vals["current"]
282
+ _dynamics[sym] = PriceDynamics(sym, vals["current"])
283
  save_securities(_securities)
284
  print(f"[MDF] Loaded securities: {list(_securities.keys())}")
285
 
 
305
  if not _running or _suspended:
306
  break
307
 
308
+ dyn = _dynamics.get(sym)
309
+ if not dyn:
310
+ continue
311
+
312
+ # Advance price via regime engine
313
+ mid = dyn.tick()
314
+ vals["current"] = mid
315
  tick = Config.TICK_SIZE
316
+ spread, depth_qty_range = dyn.spread_and_depth()
317
+ half_spread = spread / 2
318
+
319
+ # ── Indicator-aware order bias ────────────────────────────
320
+ rsi = dyn.rsi(14)
321
+ bb_pos = dyn.bollinger_position(20)
322
+ macd = dyn.ema(12) - dyn.ema(26)
323
 
324
+ # Bias aggressive side based on indicators
325
+ if rsi > 70 and bb_pos > 0.85:
326
+ aggr_bias = "SELL" # overbought β†’ sellers step in
327
+ elif rsi < 30 and bb_pos < 0.15:
328
+ aggr_bias = "BUY" # oversold β†’ buyers step in
329
+ elif macd > 0 and dyn.regime == "trending_up":
330
+ aggr_bias = "BUY"
331
+ elif macd < 0 and dyn.regime == "trending_down":
332
+ aggr_bias = "SELL"
333
+ else:
334
+ aggr_bias = random.choice(["BUY", "SELL"])
335
+
336
+ # ── Place resting depth on both sides ─────────────────────
337
  for depth_level in range(3):
338
  offset = random.randint(1 + depth_level * 3, 3 + depth_level * 5) * tick
339
  bid_price = round(mid - half_spread - offset, 2)
340
  ask_price = round(mid + half_spread + offset, 2)
341
+ bid_qty = random.randint(*depth_qty_range)
342
+ ask_qty = random.randint(*depth_qty_range)
343
+
344
+ # In trending regimes, skew depth: thicker on the passive side
345
+ if dyn.regime == "trending_up" and depth_level == 0:
346
+ bid_qty = int(bid_qty * 1.5)
347
+ elif dyn.regime == "trending_down" and depth_level == 0:
348
+ ask_qty = int(ask_qty * 1.5)
349
 
350
  bid_order = make_order(sym, "BUY", bid_price, bid_qty)
351
  ask_order = make_order(sym, "SELL", ask_price, ask_qty)
352
  producer.send(Config.ORDERS_TOPIC, bid_order)
353
  producer.send(Config.ORDERS_TOPIC, ask_order)
 
354
 
355
+ # ── Aggressive orders to generate trades (20-35%) ─────────
356
+ aggr_prob = 0.35 if dyn.regime == "volatile" else 0.20
357
+ if random.random() < aggr_prob:
358
+ side = aggr_bias
359
  if side == "BUY":
360
  price = round(mid + half_spread + random.randint(1, 3) * tick, 2)
361
  else:
362
  price = round(mid - half_spread - random.randint(1, 3) * tick, 2)
363
+ qty = random.randint(*depth_qty_range)
364
  aggr_order = make_order(sym, side, price, qty)
365
  producer.send(Config.ORDERS_TOPIC, aggr_order)
 
 
 
 
 
 
 
 
 
366
 
367
+ # ── Persist and publish snapshot with indicators ───────────
368
+ save_securities(_securities)
369
  best_bid = round(mid - half_spread, 2)
370
  best_ask = round(mid + half_spread, 2)
371
+ bid_size = random.randint(100, 400)
372
+ ask_size = random.randint(100, 400)
373
+ snap = make_snapshot(sym, best_bid, best_ask, bid_size, ask_size, dyn)
374
  producer.send(Config.SNAPSHOTS_TOPIC, snap)
375
 
376
  time.sleep(ORDER_INTERVAL)