Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion money-machine/src-python/engine/adapters/mt5.py
Original file line number Diff line number Diff line change
Expand Up @@ -742,7 +742,16 @@ def _result_from_response(
request: OrderRequest,
response: HttpResponse,
) -> OrderResult:
"""Turn an HTTP response from the Worker into an OrderResult."""
"""
Map an HTTP response from the relay Worker to an OrderResult representing the placement outcome.

Parameters:
request (OrderRequest): Original order request used to populate client-facing fields.
response (HttpResponse): HTTP response returned by the relay Worker.

Returns:
OrderResult: If the response status is 2xx, returns a `PENDING` result whose `venue_order_id` is taken directly from the parsed JSON `venue_order_id` field (may be missing or empty) and whose `metadata["relay_response"]` contains the parsed JSON dict. For non-2xx responses, returns a `REJECTED` result with `error` set to "relay HTTP {status}: {body_text}" where `body_text` is the response body decoded as UTF-8 with replacement and truncated to 256 bytes.
"""
if 200 <= response.status < 300:
parsed = _safe_parse_json_dict(response.body)
venue_id = parsed.get("venue_order_id")
Expand Down
9 changes: 8 additions & 1 deletion money-machine/src-python/engine/signal_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,14 @@ def _parse_json_response(
response_text: str,
market_data: List[List]
) -> TradingSignal:
"""Parse JSON response into a TradingSignal"""
"""
Convert a Gemini JSON response string into a TradingSignal for the given symbol.

Parses `response_text` as JSON and maps fields into a `TradingSignal`. If `entry_price` is missing or falsy, the last candle close from `market_data` is used as the entry price. The parsed `amount_pct` is placed under `metadata["amount_pct"]`. On JSON parsing or value errors, returns a fallback `TradingSignal` with `action='HOLD'`, `confidence=0.3`, and a reasoning message indicating a parsing failure.

Returns:
TradingSignal: The signal built from the parsed JSON or the fallback HOLD signal on error.
"""
try:
data = json.loads(response_text)

Expand Down
34 changes: 32 additions & 2 deletions money-machine/src-python/engine/trading_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,19 +183,49 @@ def is_connected(self) -> bool:
return self._connected

def get_uptime(self) -> float:
"""
Compute how many seconds have elapsed since the engine was started.

Returns:
uptime_seconds (float): Seconds elapsed since the engine's start time.
"""
return (datetime.now() - self.start_time).total_seconds()

async def update_config(self, new_config: dict):
"""Update safe runtime configuration on the fly."""
"""
Apply validated runtime configuration updates to the engine's configuration.

Parameters:
new_config (dict): Mapping of configuration keys to numeric values to update. Keys must be supported runtime config fields and values must be numeric (not boolean) and within the allowed ranges.

Raises:
ValueError: If `new_config` is not a dict, contains unsupported keys, contains non-numeric or boolean values, or contains values outside the allowed ranges.
"""
self.config.update(validate_config_update(new_config))

async def close(self):
"""Cleanup resources"""
"""
Close any active exchange connection and free related resources.

If an exchange client was initialized, close its connection.
"""
if self.exchange:
await self.exchange.close()


def validate_config_update(new_config: dict) -> dict:
"""
Validate and normalize runtime configuration updates against CONFIG_LIMITS.

Parameters:
new_config (dict): Mapping of configuration keys to numeric values to apply.

Returns:
dict: A new dict containing the validated configuration keys with values converted to float.

Raises:
ValueError: If `new_config` is not a dict, if a key is unsupported, if a value is not a numeric (non-boolean) type, or if a value falls outside the allowed range defined in `CONFIG_LIMITS`.
"""
if not isinstance(new_config, dict):
raise ValueError("config update must be an object")
validated: dict = {}
Expand Down
19 changes: 18 additions & 1 deletion money-machine/src-python/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,24 @@ async def cmd_get_shadow_report(self, payload: dict) -> dict:
return self.shadow_monitor.summary(window_minutes=window_minutes)

def _record_shadow_decision(self, symbol: str, signal: dict) -> None:
"""Store dry-run decision and compare against baseline strategy."""
"""
Record an AI-generated (dry-run) trading decision and record a derived baseline decision for shadow-mode comparison.

Creates and records a non-baseline ShadowDecision using the provided signal and current portfolio balance (position size = balance * metadata['amount_pct'] or 0.0). Then creates and records a baseline ShadowDecision that:
- uses "HOLD" when confidence < 0.55, otherwise uses the AI action,
- scales `size` and `risk` by 0.9,
- preserves timestamp, symbol, entry, exit, and confidence.

Parameters:
symbol (str): Trading symbol for the decision (e.g., "BTC/USDT").
signal (dict): Signal payload; expected keys include:
- "metadata" (dict) with optional "amount_pct" (interpreted as fraction of balance),
- "timestamp" (optional numeric),
- "entry_price",
- "take_profit",
- "action" (optional),
- "confidence" (optional numeric).
"""
balance = float(self.engine.portfolio.get_balance())
metadata = signal.get("metadata") or {}
risk_pct = float(metadata.get("amount_pct") or 0.0)
Expand Down
5 changes: 5 additions & 0 deletions money-machine/src-python/tests/test_config_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@


def test_save_config_redacts_nested_secrets(tmp_path, monkeypatch) -> None:
"""
Verifies that save_config persists a config.json with secret fields removed.

Calls save_config with a nested configuration containing exchange.api_key, exchange.secret, and gemini_api_key and asserts the written config.json retains only non-secret fields (exchange.name and max_risk_per_trade).
"""
fake_config_module_path = tmp_path / "utils" / "config.py"
fake_config_module_path.parent.mkdir()
fake_config_module_path.write_text("")
Expand Down
31 changes: 31 additions & 0 deletions money-machine/src-python/tests/test_ipc_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ async def _send_raw(host: str, port: int, raw: bytes) -> dict:
async def _start_server(
rate: float = 100.0, burst: float = 200.0, read_timeout_seconds: float = 5.0
) -> Tuple[IPCServer, asyncio.Task, Tuple[str, int]]:
"""
Start a test IPCServer bound to an ephemeral loopback port and return the server instance, the background serve task, and the resolved (host, port) address.

The server is configured for tests (uses the module's `_echo_handler` and `TEST_TOKEN`) and is started in a background task; the function waits until the server socket is bound before returning.

Parameters:
rate (float): Token refill rate (tokens per second) for the server's rate limiter.
burst (float): Burst capacity (maximum tokens) for the server's rate limiter.
read_timeout_seconds (float): Number of seconds the server will wait for a request body before timing out.

Returns:
Tuple[IPCServer, asyncio.Task, Tuple[str, int]]: A tuple containing
- the started IPCServer instance,
- the asyncio.Task running the server's serve loop,
- a (host, port) tuple for the bound ephemeral endpoint.
"""
server = IPCServer(
command_handler=_echo_handler,
host="127.0.0.1",
Expand Down Expand Up @@ -189,6 +205,11 @@ async def scenario() -> None:


def test_rate_limit_returns_429_after_burst() -> None:
"""
Verifies the server enforces the configured token-bucket rate limit by allowing requests up to the burst size and returning a 429 error once the burst is exhausted.

The test sends multiple authenticated requests: the first N requests (where N equals the burst limit) must succeed, and the subsequent request must receive a response with code 429 and an error message referencing rate limiting.
"""
async def scenario() -> None:
# Tiny bucket so we can exhaust it in a handful of requests.
server, task, (host, port) = await _start_server(rate=1.0, burst=3.0)
Expand All @@ -212,6 +233,11 @@ async def scenario() -> None:


def test_oversized_ipc_body_returns_413() -> None:
"""
Verifies the IPC server returns a 413 error when the request body exceeds IPCServer.MAX_BODY_BYTES.

Sends an authenticated request whose JSON body is one byte larger than MAX_BODY_BYTES and asserts the server responds with code 413.
"""
async def scenario() -> None:
server, task, (host, port) = await _start_server()
try:
Expand All @@ -226,6 +252,11 @@ async def scenario() -> None:


def test_missing_body_times_out() -> None:
"""
Verifies the IPC server responds with code 408 when the request body is not received before the read timeout.

Starts an IPCServer with a short read timeout, sends only the authentication header (no JSON body), and asserts the server returns `code == 408`.
"""
async def scenario() -> None:
server, task, (host, port) = await _start_server(read_timeout_seconds=0.05)
try:
Expand Down
42 changes: 39 additions & 3 deletions money-machine/src-python/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,14 @@


def load_config() -> Dict[str, Any]:
"""Load configuration from environment variables and config file"""
"""
Builds the application configuration from environment variables and an optional config.json file.

If a config.json file is present, its values are merged into the environment-based defaults after removing secret-bearing keys; if the file cannot be read or parsed a warning is printed and the environment defaults are used unchanged.

Returns:
dict: Configuration dictionary with defaults from environment variables, updated by non-secret values from config.json when available.
"""

config = {
# Default values
Expand Down Expand Up @@ -49,6 +56,15 @@ def load_config() -> Dict[str, Any]:


def _deep_merge(base: Dict[str, Any], update: Dict[str, Any]) -> None:
"""
Recursively merge keys from `update` into `base`, mutating `base` in place.

For keys present in both mappings whose values are dictionaries, their mappings are merged recursively; for all other keys the value from `update` replaces the value in `base`.

Parameters:
base (Dict[str, Any]): The target mapping to be updated; this object is modified in place.
update (Dict[str, Any]): The source mapping whose keys and values are merged into `base`.
"""
for key, value in update.items():
if isinstance(value, dict) and isinstance(base.get(key), dict):
_deep_merge(base[key], value)
Expand All @@ -57,7 +73,17 @@ def _deep_merge(base: Dict[str, Any], update: Dict[str, Any]) -> None:


def _without_secrets(value: Any) -> Any:
"""Return a copy with secret-bearing keys removed before disk use."""
"""
Produce a copy of `value` with any dictionary entries whose key (case-insensitive) is in `SECRET_KEYS` removed.

Recursively processes dictionaries and lists; non-dict/list values are returned unchanged.

Parameters:
value (Any): The input structure (dict, list, or other) to cleanse of secret-bearing keys.

Returns:
Any: The cleaned value with secret keys removed, preserving the input's structure types.
"""
if isinstance(value, dict):
cleaned: Dict[str, Any] = {}
for key, item in value.items():
Expand All @@ -71,7 +97,17 @@ def _without_secrets(value: Any) -> Any:


def save_config(config: Dict[str, Any]) -> bool:
"""Save non-secret configuration to config file."""
"""
Write the provided configuration to the project's config.json after removing secret keys.

The file is written to the repository-level config.json (Path(__file__).parent.parent / "config.json"). Secret-bearing keys (case-insensitive matches against SECRET_KEYS) are removed from the data before it is persisted.

Parameters:
config (Dict[str, Any]): Configuration mapping to save; secret fields will be stripped before writing.

Returns:
bool: `True` if the file was written successfully, `False` otherwise.
"""
config_path = Path(__file__).parent.parent / "config.json"

try:
Expand Down
47 changes: 45 additions & 2 deletions money-machine/src-python/utils/ipc_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,18 @@ def __init__(
burst_limit: Optional[float] = None,
read_timeout_seconds: float = DEFAULT_READ_TIMEOUT_SECONDS,
):
"""
Initialize the IPC server with network, authentication, rate-limiting, and read timeout configuration.

Parameters:
command_handler (CommandHandler): Async callable invoked as (command, payload) to handle requests.
host (str): IP address to bind the server to.
port (int): TCP port to listen on.
auth_token (Optional[str]): Hex authentication token to require from clients; if None a default token is resolved.
rate_limit (Optional[float]): Tokens-per-second rate for the global token bucket; uses DEFAULT_RATE when None.
burst_limit (Optional[float]): Capacity (burst size) for the token bucket; uses DEFAULT_BURST when None.
read_timeout_seconds (float): Per-read timeout in seconds applied when reading the auth header and JSON body.
"""
self.host = host
self.port = port
self.command_handler = command_handler
Expand Down Expand Up @@ -104,7 +116,17 @@ async def handle_client(
logger.debug("Connection closed from %s", addr)

async def _process_request(self, reader: asyncio.StreamReader) -> dict:
"""Read auth header + JSON body, return a response dict."""
"""
Process a single IPC request from the provided StreamReader using the two-line protocol: an auth header line followed by a JSON body line.

Reads and validates the auth header, applies the global token-bucket rate limit after successful authentication, reads and parses the JSON request body, ensures a non-empty "command" field is present, and dispatches to the configured command handler. On protocol, authentication, rate-limit, or JSON-parsing errors, returns an error dictionary with keys "error" and "code".

Parameters:
reader (asyncio.StreamReader): Stream reader to read the incoming request lines from.

Returns:
dict: The response produced by the command handler, or an error dictionary containing "error" and "code".
"""
auth_line_bytes, auth_error = await self._read_limited_line(
reader, self.MAX_AUTH_LINE_BYTES, "auth header"
)
Expand Down Expand Up @@ -152,6 +174,23 @@ async def _read_limited_line(
limit: int,
label: str,
) -> tuple[bytes, Optional[dict]]:
"""
Read a single newline-terminated line from `reader`, enforcing a per-read timeout and a maximum byte limit.

Parameters:
reader (asyncio.StreamReader): Stream reader to read the line from.
limit (int): Maximum allowed bytes for the line (inclusive). Lines longer than this produce an error.
label (str): Human-readable label used in error messages (e.g., "auth header" or "request body").

Returns:
tuple[bytes, Optional[dict]]: A pair (line, error). On success `line` contains the bytes read (including the trailing newline) and `error` is `None`. On failure `line` is `b""` and `error` is a dict with keys `error` (message) and `code` (HTTP-like status code):
- Timeout reading the line → code 408.
- Line exceeds `limit` → code 413.
- Other read failures → code 400.

Notes:
If the underlying read raises `asyncio.IncompleteReadError`, the function returns the partial bytes read as `line` (and no error) so the caller can decide how to handle an unterminated stream.
"""
try:
line = await asyncio.wait_for(
reader.readuntil(b"\n"),
Expand All @@ -171,7 +210,11 @@ async def _read_limited_line(
return line, None

async def stop(self) -> None:
"""Stop the server."""
"""
Close the running asyncio server and wait for it to finish closing.

If the server was not started, this is a no-op.
"""
if self.server:
self.server.close()
await self.server.wait_closed()
13 changes: 13 additions & 0 deletions money-machine/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,19 @@ use std::sync::Mutex;

static KEEP_AWAKE_HANDLE: Lazy<Mutex<Option<KeepAwake>>> = Lazy::new(|| Mutex::new(None));

/// Enables the OS keep-alive handle to prevent system idle and sleep for the application.
///
/// If a keep-alive handle is already active this returns `Ok("Keep-Alive already active")`.
/// On success it returns `Ok("Keep-Alive enabled")`. On failure it returns `Err` with a string describing the error (mutex lock failure or keep-awake creation failure).
///
/// # Examples
///
/// ```
/// // Call and check that a keep-alive response was returned.
/// let res = enable_keep_alive();
/// assert!(res.is_ok());
/// assert!(res.unwrap().contains("Keep-Alive"));
/// ```
#[tauri::command]
fn enable_keep_alive() -> Result<String, String> {
let mut handle = KEEP_AWAKE_HANDLE.lock().map_err(|e| e.to_string())?;
Expand Down
6 changes: 6 additions & 0 deletions money-machine/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ export const metadata: Metadata = {
description: "AI-powered trading overlay for AlphaAxiom.",
};

/**
* Top-level HTML layout that applies global font variables and renders the app content.
*
* @param children - The page or route content to render inside the document body.
* @returns The root `<html>` element containing a `<body>` with global font classes and `children`.
*/
export default function RootLayout({
children,
}: Readonly<{
Expand Down
8 changes: 8 additions & 0 deletions money-machine/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ import { ControlPanel } from '@/components/ControlPanel';
import { TradesTable } from '@/components/TradesTable';
import { useEnginePolling } from '@/hooks/useEnginePolling';

/**
* Render the main dashboard UI for the Money Machine application.
*
* Initializes engine polling with a 5000ms interval and composes the header, metrics
* panels, control and status widgets, trades and PnL sections, and footer.
*
* @returns The React element for the application's main dashboard interface.
*/
export default function Dashboard() {
useEnginePolling(5000);
return (
Expand Down
7 changes: 7 additions & 0 deletions money-machine/src/components/ControlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ import { motion } from 'framer-motion';
import { useAppStore } from '@/store/useAppStore';
import { startTrading, stopTrading, setAlwaysOnTop, setIgnoreMouseEvents } from '@/lib/tauri';

/**
* Render the Mission Control panel with trading and window controls.
*
* Displays connection status, a supervised "shadow trading" toggle, window behavior toggles (Pinned/Floating and Ghost/Interactive), and quick action buttons; interactive controls are disabled when the app is not connected.
*
* @returns A JSX element that renders the Mission Control UI with trading controls, window behavior toggles, and quick actions.
*/
export function ControlPanel() {
const { tradingActive, setTradingActive, connected } = useAppStore();
const [loading, setLoading] = useState(false);
Expand Down
Loading