feat: Add Phase 4 advanced metrics and components
- Add advanced metrics dashboard with trade analytics - Add new trading components (EntryTypeAnalysis, MultiDayPositionTracker, NewsEventTracker, etc.) - Add strategy mode selector and trend confirmation - Add risk automation panel and slippage correlation analysis - Add daily trading plan enhancements with modal components - Add custom hooks (useApi, useLocalStorage, useAdvancedTradeMetrics) - Add broker service integration and trading API - Add test setup and vitest configuration - Include parquet data files for live market data - Add comprehensive documentation in docs/ folder
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
<#
|
||||
.Synopsis
|
||||
Activate a Python virtual environment for the current PowerShell session.
|
||||
|
||||
.Description
|
||||
Pushes the python executable for a virtual environment to the front of the
|
||||
$Env:PATH environment variable and sets the prompt to signify that you are
|
||||
in a Python virtual environment. Makes use of the command line switches as
|
||||
well as the `pyvenv.cfg` file values present in the virtual environment.
|
||||
|
||||
.Parameter VenvDir
|
||||
Path to the directory that contains the virtual environment to activate. The
|
||||
default value for this is the parent of the directory that the Activate.ps1
|
||||
script is located within.
|
||||
|
||||
.Parameter Prompt
|
||||
The prompt prefix to display when this virtual environment is activated. By
|
||||
default, this prompt is the name of the virtual environment folder (VenvDir)
|
||||
surrounded by parentheses and followed by a single space (ie. '(.venv) ').
|
||||
|
||||
.Example
|
||||
Activate.ps1
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Verbose
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and shows extra information about the activation as it executes.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv
|
||||
Activates the Python virtual environment located in the specified location.
|
||||
|
||||
.Example
|
||||
Activate.ps1 -Prompt "MyPython"
|
||||
Activates the Python virtual environment that contains the Activate.ps1 script,
|
||||
and prefixes the current prompt with the specified string (surrounded in
|
||||
parentheses) while the virtual environment is active.
|
||||
|
||||
.Notes
|
||||
On Windows, it may be required to enable this Activate.ps1 script by setting the
|
||||
execution policy for the user. You can do this by issuing the following PowerShell
|
||||
command:
|
||||
|
||||
PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
|
||||
For more information on Execution Policies:
|
||||
https://go.microsoft.com/fwlink/?LinkID=135170
|
||||
|
||||
#>
|
||||
Param(
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$VenvDir,
|
||||
[Parameter(Mandatory = $false)]
|
||||
[String]
|
||||
$Prompt
|
||||
)
|
||||
|
||||
<# Function declarations --------------------------------------------------- #>
|
||||
|
||||
<#
|
||||
.Synopsis
|
||||
Remove all shell session elements added by the Activate script, including the
|
||||
addition of the virtual environment's Python executable from the beginning of
|
||||
the PATH variable.
|
||||
|
||||
.Parameter NonDestructive
|
||||
If present, do not remove this function from the global namespace for the
|
||||
session.
|
||||
|
||||
#>
|
||||
function global:deactivate ([switch]$NonDestructive) {
|
||||
# Revert to original values
|
||||
|
||||
# The prior prompt:
|
||||
if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) {
|
||||
Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt
|
||||
Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
|
||||
# The prior PYTHONHOME:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
}
|
||||
|
||||
# The prior PATH:
|
||||
if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) {
|
||||
Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH
|
||||
Remove-Item -Path Env:_OLD_VIRTUAL_PATH
|
||||
}
|
||||
|
||||
# Just remove the VIRTUAL_ENV altogether:
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV
|
||||
}
|
||||
|
||||
# Just remove VIRTUAL_ENV_PROMPT altogether.
|
||||
if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) {
|
||||
Remove-Item -Path env:VIRTUAL_ENV_PROMPT
|
||||
}
|
||||
|
||||
# Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether:
|
||||
if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) {
|
||||
Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force
|
||||
}
|
||||
|
||||
# Leave deactivate function in the global namespace if requested:
|
||||
if (-not $NonDestructive) {
|
||||
Remove-Item -Path function:deactivate
|
||||
}
|
||||
}
|
||||
|
||||
<#
|
||||
.Description
|
||||
Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the
|
||||
given folder, and returns them in a map.
|
||||
|
||||
For each line in the pyvenv.cfg file, if that line can be parsed into exactly
|
||||
two strings separated by `=` (with any amount of whitespace surrounding the =)
|
||||
then it is considered a `key = value` line. The left hand string is the key,
|
||||
the right hand is the value.
|
||||
|
||||
If the value starts with a `'` or a `"` then the first and last character is
|
||||
stripped from the value before being captured.
|
||||
|
||||
.Parameter ConfigDir
|
||||
Path to the directory that contains the `pyvenv.cfg` file.
|
||||
#>
|
||||
function Get-PyVenvConfig(
|
||||
[String]
|
||||
$ConfigDir
|
||||
) {
|
||||
Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg"
|
||||
|
||||
# Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue).
|
||||
$pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue
|
||||
|
||||
# An empty map will be returned if no config file is found.
|
||||
$pyvenvConfig = @{ }
|
||||
|
||||
if ($pyvenvConfigPath) {
|
||||
|
||||
Write-Verbose "File exists, parse `key = value` lines"
|
||||
$pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath
|
||||
|
||||
$pyvenvConfigContent | ForEach-Object {
|
||||
$keyval = $PSItem -split "\s*=\s*", 2
|
||||
if ($keyval[0] -and $keyval[1]) {
|
||||
$val = $keyval[1]
|
||||
|
||||
# Remove extraneous quotations around a string value.
|
||||
if ("'""".Contains($val.Substring(0, 1))) {
|
||||
$val = $val.Substring(1, $val.Length - 2)
|
||||
}
|
||||
|
||||
$pyvenvConfig[$keyval[0]] = $val
|
||||
Write-Verbose "Adding Key: '$($keyval[0])'='$val'"
|
||||
}
|
||||
}
|
||||
}
|
||||
return $pyvenvConfig
|
||||
}
|
||||
|
||||
|
||||
<# Begin Activate script --------------------------------------------------- #>
|
||||
|
||||
# Determine the containing directory of this script
|
||||
$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition
|
||||
$VenvExecDir = Get-Item -Path $VenvExecPath
|
||||
|
||||
Write-Verbose "Activation script is located in path: '$VenvExecPath'"
|
||||
Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)"
|
||||
Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)"
|
||||
|
||||
# Set values required in priority: CmdLine, ConfigFile, Default
|
||||
# First, get the location of the virtual environment, it might not be
|
||||
# VenvExecDir if specified on the command line.
|
||||
if ($VenvDir) {
|
||||
Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir."
|
||||
$VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/")
|
||||
Write-Verbose "VenvDir=$VenvDir"
|
||||
}
|
||||
|
||||
# Next, read the `pyvenv.cfg` file to determine any required value such
|
||||
# as `prompt`.
|
||||
$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir
|
||||
|
||||
# Next, set the prompt from the command line, or the config file, or
|
||||
# just use the name of the virtual environment folder.
|
||||
if ($Prompt) {
|
||||
Write-Verbose "Prompt specified as argument, using '$Prompt'"
|
||||
}
|
||||
else {
|
||||
Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value"
|
||||
if ($pyvenvCfg -and $pyvenvCfg['prompt']) {
|
||||
Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'"
|
||||
$Prompt = $pyvenvCfg['prompt'];
|
||||
}
|
||||
else {
|
||||
Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)"
|
||||
Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'"
|
||||
$Prompt = Split-Path -Path $venvDir -Leaf
|
||||
}
|
||||
}
|
||||
|
||||
Write-Verbose "Prompt = '$Prompt'"
|
||||
Write-Verbose "VenvDir='$VenvDir'"
|
||||
|
||||
# Deactivate any currently active virtual environment, but leave the
|
||||
# deactivate function in place.
|
||||
deactivate -nondestructive
|
||||
|
||||
# Now set the environment variable VIRTUAL_ENV, used by many tools to determine
|
||||
# that there is an activated venv.
|
||||
$env:VIRTUAL_ENV = $VenvDir
|
||||
|
||||
if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) {
|
||||
|
||||
Write-Verbose "Setting prompt to '$Prompt'"
|
||||
|
||||
# Set the prompt to include the env name
|
||||
# Make sure _OLD_VIRTUAL_PROMPT is global
|
||||
function global:_OLD_VIRTUAL_PROMPT { "" }
|
||||
Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT
|
||||
New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt
|
||||
|
||||
function global:prompt {
|
||||
Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) "
|
||||
_OLD_VIRTUAL_PROMPT
|
||||
}
|
||||
$env:VIRTUAL_ENV_PROMPT = $Prompt
|
||||
}
|
||||
|
||||
# Clear PYTHONHOME
|
||||
if (Test-Path -Path Env:PYTHONHOME) {
|
||||
Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME
|
||||
Remove-Item -Path Env:PYTHONHOME
|
||||
}
|
||||
|
||||
# Add the venv to the PATH
|
||||
Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH
|
||||
$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH"
|
||||
@@ -0,0 +1,63 @@
|
||||
# This file must be used with "source bin/activate" *from bash*
|
||||
# you cannot run it directly
|
||||
|
||||
deactivate () {
|
||||
# reset old environment variables
|
||||
if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then
|
||||
PATH="${_OLD_VIRTUAL_PATH:-}"
|
||||
export PATH
|
||||
unset _OLD_VIRTUAL_PATH
|
||||
fi
|
||||
if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then
|
||||
PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}"
|
||||
export PYTHONHOME
|
||||
unset _OLD_VIRTUAL_PYTHONHOME
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
|
||||
if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then
|
||||
PS1="${_OLD_VIRTUAL_PS1:-}"
|
||||
export PS1
|
||||
unset _OLD_VIRTUAL_PS1
|
||||
fi
|
||||
|
||||
unset VIRTUAL_ENV
|
||||
unset VIRTUAL_ENV_PROMPT
|
||||
if [ ! "${1:-}" = "nondestructive" ] ; then
|
||||
# Self destruct!
|
||||
unset -f deactivate
|
||||
fi
|
||||
}
|
||||
|
||||
# unset irrelevant variables
|
||||
deactivate nondestructive
|
||||
|
||||
VIRTUAL_ENV=/Users/user/Downloads/gold-trading-simulator/backend/.venv311
|
||||
export VIRTUAL_ENV
|
||||
|
||||
_OLD_VIRTUAL_PATH="$PATH"
|
||||
PATH="$VIRTUAL_ENV/"bin":$PATH"
|
||||
export PATH
|
||||
|
||||
# unset PYTHONHOME if set
|
||||
# this will fail if PYTHONHOME is set to the empty string (which is bad anyway)
|
||||
# could use `if (set -u; : $PYTHONHOME) ;` in bash
|
||||
if [ -n "${PYTHONHOME:-}" ] ; then
|
||||
_OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}"
|
||||
unset PYTHONHOME
|
||||
fi
|
||||
|
||||
if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then
|
||||
_OLD_VIRTUAL_PS1="${PS1:-}"
|
||||
PS1='(.venv311) '"${PS1:-}"
|
||||
export PS1
|
||||
VIRTUAL_ENV_PROMPT='(.venv311) '
|
||||
export VIRTUAL_ENV_PROMPT
|
||||
fi
|
||||
|
||||
# Call hash to forget past commands. Without forgetting
|
||||
# past commands the $PATH changes we made may not be respected
|
||||
hash -r 2> /dev/null
|
||||
@@ -0,0 +1,26 @@
|
||||
# This file must be used with "source bin/activate.csh" *from csh*.
|
||||
# You cannot run it directly.
|
||||
# Created by Davide Di Blasi <davidedb@gmail.com>.
|
||||
# Ported to Python 3.3 venv by Andrew Svetlov <andrew.svetlov@gmail.com>
|
||||
|
||||
alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate'
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
setenv VIRTUAL_ENV /Users/user/Downloads/gold-trading-simulator/backend/.venv311
|
||||
|
||||
set _OLD_VIRTUAL_PATH="$PATH"
|
||||
setenv PATH "$VIRTUAL_ENV/"bin":$PATH"
|
||||
|
||||
|
||||
set _OLD_VIRTUAL_PROMPT="$prompt"
|
||||
|
||||
if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then
|
||||
set prompt = '(.venv311) '"$prompt"
|
||||
setenv VIRTUAL_ENV_PROMPT '(.venv311) '
|
||||
endif
|
||||
|
||||
alias pydoc python -m pydoc
|
||||
|
||||
rehash
|
||||
@@ -0,0 +1,69 @@
|
||||
# This file must be used with "source <venv>/bin/activate.fish" *from fish*
|
||||
# (https://fishshell.com/); you cannot run it directly.
|
||||
|
||||
function deactivate -d "Exit virtual environment and return to normal shell environment"
|
||||
# reset old environment variables
|
||||
if test -n "$_OLD_VIRTUAL_PATH"
|
||||
set -gx PATH $_OLD_VIRTUAL_PATH
|
||||
set -e _OLD_VIRTUAL_PATH
|
||||
end
|
||||
if test -n "$_OLD_VIRTUAL_PYTHONHOME"
|
||||
set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME
|
||||
set -e _OLD_VIRTUAL_PYTHONHOME
|
||||
end
|
||||
|
||||
if test -n "$_OLD_FISH_PROMPT_OVERRIDE"
|
||||
set -e _OLD_FISH_PROMPT_OVERRIDE
|
||||
# prevents error when using nested fish instances (Issue #93858)
|
||||
if functions -q _old_fish_prompt
|
||||
functions -e fish_prompt
|
||||
functions -c _old_fish_prompt fish_prompt
|
||||
functions -e _old_fish_prompt
|
||||
end
|
||||
end
|
||||
|
||||
set -e VIRTUAL_ENV
|
||||
set -e VIRTUAL_ENV_PROMPT
|
||||
if test "$argv[1]" != "nondestructive"
|
||||
# Self-destruct!
|
||||
functions -e deactivate
|
||||
end
|
||||
end
|
||||
|
||||
# Unset irrelevant variables.
|
||||
deactivate nondestructive
|
||||
|
||||
set -gx VIRTUAL_ENV /Users/user/Downloads/gold-trading-simulator/backend/.venv311
|
||||
|
||||
set -gx _OLD_VIRTUAL_PATH $PATH
|
||||
set -gx PATH "$VIRTUAL_ENV/"bin $PATH
|
||||
|
||||
# Unset PYTHONHOME if set.
|
||||
if set -q PYTHONHOME
|
||||
set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME
|
||||
set -e PYTHONHOME
|
||||
end
|
||||
|
||||
if test -z "$VIRTUAL_ENV_DISABLE_PROMPT"
|
||||
# fish uses a function instead of an env var to generate the prompt.
|
||||
|
||||
# Save the current fish_prompt function as the function _old_fish_prompt.
|
||||
functions -c fish_prompt _old_fish_prompt
|
||||
|
||||
# With the original prompt function renamed, we can override with our own.
|
||||
function fish_prompt
|
||||
# Save the return status of the last command.
|
||||
set -l old_status $status
|
||||
|
||||
# Output the venv prompt; color taken from the blue of the Python logo.
|
||||
printf "%s%s%s" (set_color 4B8BBE) '(.venv311) ' (set_color normal)
|
||||
|
||||
# Restore the return status of the previous command.
|
||||
echo "exit $old_status" | .
|
||||
# Output the original/"old" prompt.
|
||||
_old_fish_prompt
|
||||
end
|
||||
|
||||
set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV"
|
||||
set -gx VIRTUAL_ENV_PROMPT '(.venv311) '
|
||||
end
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from alembic.config import main
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(main())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from dotenv.__main__ import cli
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(cli())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from numpy.f2py.f2py2e import main
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(main())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from httpx import main
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(main())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from mako.cmd import cmdline
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(cmdline())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from nltk.cli import cli
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(cli())
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
Executable
+8
@@ -0,0 +1,8 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import sys
|
||||
from pip._internal.cli.main import main
|
||||
if __name__ == '__main__':
|
||||
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
|
||||
sys.exit(main())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from pytest import console_main
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(console_main())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from pytest import console_main
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(console_main())
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
python3.11
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
python3.11
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
/usr/local/opt/python@3.11/bin/python3.11
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from tqdm.cli import main
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(main())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from uvicorn.main import main
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(main())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/Users/user/Downloads/gold-trading-simulator/backend/.venv311/bin/python
|
||||
import sys
|
||||
from watchfiles.cli import cli
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[0].endswith('.exe'):
|
||||
sys.argv[0] = sys.argv[0][:-4]
|
||||
sys.exit(cli())
|
||||
@@ -0,0 +1,164 @@
|
||||
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
|
||||
|
||||
/* Greenlet object interface */
|
||||
|
||||
#ifndef Py_GREENLETOBJECT_H
|
||||
#define Py_GREENLETOBJECT_H
|
||||
|
||||
|
||||
#include <Python.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* This is deprecated and undocumented. It does not change. */
|
||||
#define GREENLET_VERSION "1.0.0"
|
||||
|
||||
#ifndef GREENLET_MODULE
|
||||
#define implementation_ptr_t void*
|
||||
#endif
|
||||
|
||||
typedef struct _greenlet {
|
||||
PyObject_HEAD
|
||||
PyObject* weakreflist;
|
||||
PyObject* dict;
|
||||
implementation_ptr_t pimpl;
|
||||
} PyGreenlet;
|
||||
|
||||
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
|
||||
|
||||
|
||||
/* C API functions */
|
||||
|
||||
/* Total number of symbols that are exported */
|
||||
#define PyGreenlet_API_pointers 12
|
||||
|
||||
#define PyGreenlet_Type_NUM 0
|
||||
#define PyExc_GreenletError_NUM 1
|
||||
#define PyExc_GreenletExit_NUM 2
|
||||
|
||||
#define PyGreenlet_New_NUM 3
|
||||
#define PyGreenlet_GetCurrent_NUM 4
|
||||
#define PyGreenlet_Throw_NUM 5
|
||||
#define PyGreenlet_Switch_NUM 6
|
||||
#define PyGreenlet_SetParent_NUM 7
|
||||
|
||||
#define PyGreenlet_MAIN_NUM 8
|
||||
#define PyGreenlet_STARTED_NUM 9
|
||||
#define PyGreenlet_ACTIVE_NUM 10
|
||||
#define PyGreenlet_GET_PARENT_NUM 11
|
||||
|
||||
#ifndef GREENLET_MODULE
|
||||
/* This section is used by modules that uses the greenlet C API */
|
||||
static void** _PyGreenlet_API = NULL;
|
||||
|
||||
# define PyGreenlet_Type \
|
||||
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
|
||||
|
||||
# define PyExc_GreenletError \
|
||||
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
|
||||
|
||||
# define PyExc_GreenletExit \
|
||||
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_New(PyObject *args)
|
||||
*
|
||||
* greenlet.greenlet(run, parent=None)
|
||||
*/
|
||||
# define PyGreenlet_New \
|
||||
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
|
||||
_PyGreenlet_API[PyGreenlet_New_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_GetCurrent(void)
|
||||
*
|
||||
* greenlet.getcurrent()
|
||||
*/
|
||||
# define PyGreenlet_GetCurrent \
|
||||
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_Throw(
|
||||
* PyGreenlet *greenlet,
|
||||
* PyObject *typ,
|
||||
* PyObject *val,
|
||||
* PyObject *tb)
|
||||
*
|
||||
* g.throw(...)
|
||||
*/
|
||||
# define PyGreenlet_Throw \
|
||||
(*(PyObject * (*)(PyGreenlet * self, \
|
||||
PyObject * typ, \
|
||||
PyObject * val, \
|
||||
PyObject * tb)) \
|
||||
_PyGreenlet_API[PyGreenlet_Throw_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
|
||||
*
|
||||
* g.switch(*args, **kwargs)
|
||||
*/
|
||||
# define PyGreenlet_Switch \
|
||||
(*(PyObject * \
|
||||
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
|
||||
_PyGreenlet_API[PyGreenlet_Switch_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
|
||||
*
|
||||
* g.parent = new_parent
|
||||
*/
|
||||
# define PyGreenlet_SetParent \
|
||||
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
|
||||
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
|
||||
|
||||
/*
|
||||
* PyGreenlet_GetParent(PyObject* greenlet)
|
||||
*
|
||||
* return greenlet.parent;
|
||||
*
|
||||
* This could return NULL even if there is no exception active.
|
||||
* If it does not return NULL, you are responsible for decrementing the
|
||||
* reference count.
|
||||
*/
|
||||
# define PyGreenlet_GetParent \
|
||||
(*(PyGreenlet* (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
|
||||
|
||||
/*
|
||||
* deprecated, undocumented alias.
|
||||
*/
|
||||
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
|
||||
|
||||
# define PyGreenlet_MAIN \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
|
||||
|
||||
# define PyGreenlet_STARTED \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
|
||||
|
||||
# define PyGreenlet_ACTIVE \
|
||||
(*(int (*)(PyGreenlet*)) \
|
||||
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
|
||||
|
||||
|
||||
|
||||
|
||||
/* Macro that imports greenlet and initializes C API */
|
||||
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
|
||||
keep the older definition to be sure older code that might have a copy of
|
||||
the header still works. */
|
||||
# define PyGreenlet_Import() \
|
||||
{ \
|
||||
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
|
||||
}
|
||||
|
||||
#endif /* GREENLET_MODULE */
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
#endif /* !Py_GREENLETOBJECT_H */
|
||||
@@ -0,0 +1,5 @@
|
||||
home = /usr/local/opt/python@3.11/bin
|
||||
include-system-site-packages = false
|
||||
version = 3.11.12
|
||||
executable = /usr/local/Cellar/python@3.11/3.11.12/Frameworks/Python.framework/Versions/3.11/bin/python3.11
|
||||
command = /usr/local/opt/python@3.11/bin/python3.11 -m venv /Users/user/Downloads/gold-trading-simulator/backend/.venv311
|
||||
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from app.services.broker_bridge import BrokerError, broker_bridge_service
|
||||
|
||||
router = APIRouter(prefix="/brokers", tags=["Brokers"])
|
||||
|
||||
|
||||
class ConnectRequest(BaseModel):
|
||||
provider_id: str = Field(..., description="Broker provider identifier")
|
||||
api_key: str = Field(..., description="API key or session token")
|
||||
account_id: str = Field(..., description="Broker account identifier/login")
|
||||
demo: bool = Field(True, description="If true, stays in practice/demo mode when supported")
|
||||
|
||||
@validator("provider_id")
|
||||
def _trim(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("provider_id is required")
|
||||
return value
|
||||
|
||||
|
||||
class OrderRequest(BaseModel):
|
||||
action: str
|
||||
symbol: str
|
||||
quantity: float
|
||||
price: float
|
||||
type: str | None = None
|
||||
stopLoss: float | None = None
|
||||
takeProfit: float | None = None
|
||||
|
||||
|
||||
@router.get("/providers")
|
||||
async def list_providers():
|
||||
return broker_bridge_service.list_providers()
|
||||
|
||||
|
||||
@router.get("/session")
|
||||
async def get_session():
|
||||
return broker_bridge_service.get_session()
|
||||
|
||||
|
||||
@router.post("/connect")
|
||||
async def connect(request: ConnectRequest):
|
||||
try:
|
||||
return await broker_bridge_service.connect(
|
||||
request.provider_id,
|
||||
{
|
||||
"api_key": request.api_key,
|
||||
"account_id": request.account_id,
|
||||
"demo": request.demo,
|
||||
},
|
||||
)
|
||||
except BrokerError as exc: # pragma: no cover - depends on environment
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def disconnect():
|
||||
await broker_bridge_service.disconnect()
|
||||
return {"status": "disconnected"}
|
||||
|
||||
|
||||
@router.post("/orders")
|
||||
async def place_order(request: OrderRequest):
|
||||
try:
|
||||
return await broker_bridge_service.place_order(request.dict())
|
||||
except BrokerError as exc: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_positions():
|
||||
try:
|
||||
return await broker_bridge_service.sync_positions()
|
||||
except BrokerError as exc: # pragma: no cover
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
@@ -0,0 +1,531 @@
|
||||
"""
|
||||
Trading Journal API
|
||||
Handles daily/weekly plans, manual trade logging, journal entries, and decision logging
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, desc
|
||||
from typing import List, Optional
|
||||
from datetime import date, datetime, timedelta
|
||||
from pydantic import BaseModel
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.models.models import (
|
||||
TradingPlan,
|
||||
ManualTrade,
|
||||
JournalEntry,
|
||||
DecisionLog,
|
||||
WeeklyPlan,
|
||||
TradeAction
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/journal", tags=["Trading Journal"])
|
||||
|
||||
|
||||
# Pydantic Schemas
|
||||
|
||||
class TradingPlanCreate(BaseModel):
|
||||
plan_date: date
|
||||
plan_type: str = "daily"
|
||||
market_bias: str
|
||||
daily_target: Optional[float] = None
|
||||
max_loss: Optional[float] = None
|
||||
entry_zone_min: Optional[float] = None
|
||||
entry_zone_max: Optional[float] = None
|
||||
target_price: Optional[float] = None
|
||||
stop_loss: Optional[float] = None
|
||||
support_levels: List[float] = []
|
||||
resistance_levels: List[float] = []
|
||||
trading_notes: Optional[str] = None
|
||||
max_trades: int = 3
|
||||
ai_generated: bool = False
|
||||
ai_confidence: Optional[float] = None
|
||||
context_metrics: Optional[dict] = None
|
||||
|
||||
|
||||
class TradingPlanUpdate(BaseModel):
|
||||
market_bias: Optional[str] = None
|
||||
daily_target: Optional[float] = None
|
||||
max_loss: Optional[float] = None
|
||||
entry_zone_min: Optional[float] = None
|
||||
entry_zone_max: Optional[float] = None
|
||||
target_price: Optional[float] = None
|
||||
stop_loss: Optional[float] = None
|
||||
support_levels: Optional[List[float]] = None
|
||||
resistance_levels: Optional[List[float]] = None
|
||||
trading_notes: Optional[str] = None
|
||||
max_trades: Optional[int] = None
|
||||
actual_trades: Optional[int] = None
|
||||
actual_pnl: Optional[float] = None
|
||||
plan_followed: Optional[bool] = None
|
||||
|
||||
|
||||
class ManualTradeCreate(BaseModel):
|
||||
plan_id: Optional[int] = None
|
||||
symbol: str = "XAUUSD"
|
||||
action: str # BUY or SELL
|
||||
entry_price: float
|
||||
exit_price: Optional[float] = None
|
||||
quantity: float
|
||||
broker: Optional[str] = None
|
||||
pnl: Optional[float] = None
|
||||
pnl_percent: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
followed_plan: bool = True
|
||||
entry_time: Optional[datetime] = None
|
||||
exit_time: Optional[datetime] = None
|
||||
|
||||
|
||||
class ManualTradeUpdate(BaseModel):
|
||||
exit_price: Optional[float] = None
|
||||
pnl: Optional[float] = None
|
||||
pnl_percent: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
exit_time: Optional[datetime] = None
|
||||
|
||||
|
||||
class JournalEntryCreate(BaseModel):
|
||||
entry_date: date
|
||||
mood: Optional[str] = None
|
||||
energy_level: Optional[int] = None
|
||||
stress_level: Optional[int] = None
|
||||
lessons_learned: Optional[str] = None
|
||||
what_went_well: Optional[str] = None
|
||||
what_to_improve: Optional[str] = None
|
||||
tomorrow_focus: Optional[str] = None
|
||||
mistakes_made: Optional[str] = None
|
||||
market_conditions: Optional[str] = None
|
||||
market_notes: Optional[str] = None
|
||||
|
||||
|
||||
class DecisionLogCreate(BaseModel):
|
||||
ai_recommendation: Optional[str] = None
|
||||
ai_confidence: Optional[float] = None
|
||||
ai_reasoning: Optional[str] = None
|
||||
trader_action: Optional[str] = None
|
||||
trade_id: Optional[int] = None
|
||||
outcome: Optional[str] = None
|
||||
outcome_pnl: Optional[float] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class WeeklyPlanCreate(BaseModel):
|
||||
week_start_date: date
|
||||
year: int
|
||||
week_number: int
|
||||
market_outlook: Optional[str] = None
|
||||
key_events: List[dict] = []
|
||||
major_levels: List[float] = []
|
||||
weekly_target: Optional[float] = None
|
||||
max_weekly_loss: Optional[float] = None
|
||||
target_trade_count: Optional[int] = None
|
||||
primary_strategy: Optional[str] = None
|
||||
focus_areas: Optional[str] = None
|
||||
risks_to_watch: Optional[str] = None
|
||||
|
||||
|
||||
# Trading Plans Endpoints
|
||||
|
||||
@router.post("/plans", status_code=201)
|
||||
async def create_trading_plan(
|
||||
plan: TradingPlanCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a new daily/weekly trading plan"""
|
||||
db_plan = TradingPlan(**plan.dict())
|
||||
db.add(db_plan)
|
||||
db.commit()
|
||||
db.refresh(db_plan)
|
||||
return db_plan
|
||||
|
||||
|
||||
@router.get("/plans/today")
|
||||
async def get_today_plan(db: Session = Depends(get_db)):
|
||||
"""Get today's trading plan"""
|
||||
today = date.today()
|
||||
plan = db.query(TradingPlan).filter(
|
||||
and_(
|
||||
TradingPlan.plan_date == today,
|
||||
TradingPlan.plan_type == "daily"
|
||||
)
|
||||
).first()
|
||||
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="No plan found for today")
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@router.get("/plans/date/{plan_date}")
|
||||
async def get_plan_by_date(
|
||||
plan_date: date,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get trading plan for a specific date"""
|
||||
plan = db.query(TradingPlan).filter(
|
||||
TradingPlan.plan_date == plan_date
|
||||
).first()
|
||||
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail=f"No plan found for {plan_date}")
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
async def get_plans(
|
||||
limit: int = 30,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get recent trading plans"""
|
||||
plans = db.query(TradingPlan).order_by(
|
||||
desc(TradingPlan.plan_date)
|
||||
).limit(limit).offset(offset).all()
|
||||
|
||||
return {"plans": plans, "total": db.query(TradingPlan).count()}
|
||||
|
||||
|
||||
@router.put("/plans/{plan_id}")
|
||||
async def update_trading_plan(
|
||||
plan_id: int,
|
||||
plan_update: TradingPlanUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update an existing trading plan"""
|
||||
db_plan = db.query(TradingPlan).filter(TradingPlan.id == plan_id).first()
|
||||
|
||||
if not db_plan:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
update_data = plan_update.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(db_plan, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_plan)
|
||||
return db_plan
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}")
|
||||
async def delete_trading_plan(
|
||||
plan_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Delete a trading plan"""
|
||||
db_plan = db.query(TradingPlan).filter(TradingPlan.id == plan_id).first()
|
||||
|
||||
if not db_plan:
|
||||
raise HTTPException(status_code=404, detail="Plan not found")
|
||||
|
||||
db.delete(db_plan)
|
||||
db.commit()
|
||||
return {"message": "Plan deleted successfully"}
|
||||
|
||||
|
||||
# Manual Trades Endpoints
|
||||
|
||||
@router.post("/trades", status_code=201)
|
||||
async def create_manual_trade(
|
||||
trade: ManualTradeCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Log a manual trade from broker platform"""
|
||||
try:
|
||||
action_enum = TradeAction[trade.action.upper()]
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid action: {trade.action}")
|
||||
|
||||
trade_dict = trade.dict()
|
||||
trade_dict['action'] = action_enum
|
||||
|
||||
db_trade = ManualTrade(**trade_dict)
|
||||
db.add(db_trade)
|
||||
|
||||
# Update plan if linked
|
||||
if trade.plan_id:
|
||||
plan = db.query(TradingPlan).filter(TradingPlan.id == trade.plan_id).first()
|
||||
if plan:
|
||||
plan.actual_trades += 1
|
||||
if trade.pnl is not None:
|
||||
plan.actual_pnl += trade.pnl
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_trade)
|
||||
return db_trade
|
||||
|
||||
|
||||
@router.get("/trades")
|
||||
async def get_manual_trades(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
plan_id: Optional[int] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get manual trades, optionally filtered by plan"""
|
||||
query = db.query(ManualTrade)
|
||||
|
||||
if plan_id:
|
||||
query = query.filter(ManualTrade.plan_id == plan_id)
|
||||
|
||||
trades = query.order_by(desc(ManualTrade.created_at)).limit(limit).offset(offset).all()
|
||||
total = query.count()
|
||||
|
||||
return {"trades": trades, "total": total}
|
||||
|
||||
|
||||
@router.get("/trades/{trade_id}")
|
||||
async def get_manual_trade(
|
||||
trade_id: int,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get a specific manual trade"""
|
||||
trade = db.query(ManualTrade).filter(ManualTrade.id == trade_id).first()
|
||||
|
||||
if not trade:
|
||||
raise HTTPException(status_code=404, detail="Trade not found")
|
||||
|
||||
return trade
|
||||
|
||||
|
||||
@router.put("/trades/{trade_id}")
|
||||
async def update_manual_trade(
|
||||
trade_id: int,
|
||||
trade_update: ManualTradeUpdate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Update a manual trade (e.g., closing a position)"""
|
||||
db_trade = db.query(ManualTrade).filter(ManualTrade.id == trade_id).first()
|
||||
|
||||
if not db_trade:
|
||||
raise HTTPException(status_code=404, detail="Trade not found")
|
||||
|
||||
update_data = trade_update.dict(exclude_unset=True)
|
||||
|
||||
# Calculate PnL if exit price provided
|
||||
if 'exit_price' in update_data and db_trade.exit_price is None:
|
||||
exit_price = update_data['exit_price']
|
||||
if db_trade.action == TradeAction.BUY:
|
||||
pnl = (exit_price - db_trade.entry_price) * db_trade.quantity
|
||||
else: # SELL
|
||||
pnl = (db_trade.entry_price - exit_price) * db_trade.quantity
|
||||
|
||||
update_data['pnl'] = round(pnl, 2)
|
||||
update_data['pnl_percent'] = round((pnl / (db_trade.entry_price * db_trade.quantity)) * 100, 2)
|
||||
|
||||
# Update plan PnL
|
||||
if db_trade.plan_id:
|
||||
plan = db.query(TradingPlan).filter(TradingPlan.id == db_trade.plan_id).first()
|
||||
if plan:
|
||||
plan.actual_pnl += pnl
|
||||
|
||||
for key, value in update_data.items():
|
||||
setattr(db_trade, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_trade)
|
||||
return db_trade
|
||||
|
||||
|
||||
@router.post("/trades/{trade_id}/screenshot")
|
||||
async def upload_trade_screenshot(
|
||||
trade_id: int,
|
||||
file: UploadFile = File(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Upload a screenshot for a trade"""
|
||||
db_trade = db.query(ManualTrade).filter(ManualTrade.id == trade_id).first()
|
||||
|
||||
if not db_trade:
|
||||
raise HTTPException(status_code=404, detail="Trade not found")
|
||||
|
||||
# Create uploads directory if it doesn't exist
|
||||
upload_dir = "uploads/trade_screenshots"
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
# Generate unique filename
|
||||
file_extension = os.path.splitext(file.filename)[1]
|
||||
unique_filename = f"{trade_id}_{uuid.uuid4()}{file_extension}"
|
||||
file_path = os.path.join(upload_dir, unique_filename)
|
||||
|
||||
# Save file
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# Update trade record
|
||||
db_trade.screenshot_url = file_path
|
||||
db.commit()
|
||||
|
||||
return {"filename": unique_filename, "path": file_path}
|
||||
|
||||
|
||||
# Journal Entries Endpoints
|
||||
|
||||
@router.post("/entries", status_code=201)
|
||||
async def create_journal_entry(
|
||||
entry: JournalEntryCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a daily journal entry"""
|
||||
# Check if entry for this date already exists
|
||||
existing = db.query(JournalEntry).filter(
|
||||
JournalEntry.entry_date == entry.entry_date
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# Update existing entry
|
||||
update_data = entry.dict(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(existing, key, value)
|
||||
db.commit()
|
||||
db.refresh(existing)
|
||||
return existing
|
||||
|
||||
db_entry = JournalEntry(**entry.dict())
|
||||
db.add(db_entry)
|
||||
db.commit()
|
||||
db.refresh(db_entry)
|
||||
return db_entry
|
||||
|
||||
|
||||
@router.get("/entries/today")
|
||||
async def get_today_journal(db: Session = Depends(get_db)):
|
||||
"""Get today's journal entry"""
|
||||
today = date.today()
|
||||
entry = db.query(JournalEntry).filter(
|
||||
JournalEntry.entry_date == today
|
||||
).first()
|
||||
|
||||
if not entry:
|
||||
raise HTTPException(status_code=404, detail="No journal entry for today")
|
||||
|
||||
return entry
|
||||
|
||||
|
||||
@router.get("/entries")
|
||||
async def get_journal_entries(
|
||||
limit: int = 30,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get recent journal entries"""
|
||||
entries = db.query(JournalEntry).order_by(
|
||||
desc(JournalEntry.entry_date)
|
||||
).limit(limit).offset(offset).all()
|
||||
|
||||
return {"entries": entries, "total": db.query(JournalEntry).count()}
|
||||
|
||||
|
||||
# Decision Log Endpoints
|
||||
|
||||
@router.post("/decisions", status_code=201)
|
||||
async def create_decision_log(
|
||||
decision: DecisionLogCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Log a trading decision"""
|
||||
db_decision = DecisionLog(**decision.dict())
|
||||
db.add(db_decision)
|
||||
db.commit()
|
||||
db.refresh(db_decision)
|
||||
return db_decision
|
||||
|
||||
|
||||
@router.get("/decisions")
|
||||
async def get_decisions(
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get recent decisions"""
|
||||
decisions = db.query(DecisionLog).order_by(
|
||||
desc(DecisionLog.decision_time)
|
||||
).limit(limit).offset(offset).all()
|
||||
|
||||
return {"decisions": decisions, "total": db.query(DecisionLog).count()}
|
||||
|
||||
|
||||
@router.get("/decisions/accuracy")
|
||||
async def get_ai_accuracy(
|
||||
days: int = 30,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Calculate AI recommendation accuracy"""
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
decisions = db.query(DecisionLog).filter(
|
||||
and_(
|
||||
DecisionLog.decision_time >= cutoff_date,
|
||||
DecisionLog.trader_action == "FOLLOWED",
|
||||
DecisionLog.outcome.isnot(None)
|
||||
)
|
||||
).all()
|
||||
|
||||
if not decisions:
|
||||
return {
|
||||
"total_decisions": 0,
|
||||
"accuracy": 0.0,
|
||||
"win_rate": 0.0,
|
||||
"avg_pnl": 0.0
|
||||
}
|
||||
|
||||
wins = sum(1 for d in decisions if d.outcome == "WIN")
|
||||
total_pnl = sum(d.outcome_pnl for d in decisions if d.outcome_pnl is not None)
|
||||
|
||||
return {
|
||||
"total_decisions": len(decisions),
|
||||
"wins": wins,
|
||||
"losses": len(decisions) - wins,
|
||||
"win_rate": round((wins / len(decisions)) * 100, 2),
|
||||
"avg_pnl": round(total_pnl / len(decisions), 2) if decisions else 0,
|
||||
"total_pnl": round(total_pnl, 2)
|
||||
}
|
||||
|
||||
|
||||
# Weekly Plans Endpoints
|
||||
|
||||
@router.post("/weekly-plans", status_code=201)
|
||||
async def create_weekly_plan(
|
||||
plan: WeeklyPlanCreate,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create a weekly trading plan"""
|
||||
db_plan = WeeklyPlan(**plan.dict())
|
||||
db.add(db_plan)
|
||||
db.commit()
|
||||
db.refresh(db_plan)
|
||||
return db_plan
|
||||
|
||||
|
||||
@router.get("/weekly-plans/current")
|
||||
async def get_current_week_plan(db: Session = Depends(get_db)):
|
||||
"""Get this week's plan"""
|
||||
today = date.today()
|
||||
# Get Monday of current week
|
||||
monday = today - timedelta(days=today.weekday())
|
||||
|
||||
plan = db.query(WeeklyPlan).filter(
|
||||
WeeklyPlan.week_start_date == monday
|
||||
).first()
|
||||
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="No plan found for current week")
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@router.get("/weekly-plans")
|
||||
async def get_weekly_plans(
|
||||
limit: int = 12,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get recent weekly plans"""
|
||||
plans = db.query(WeeklyPlan).order_by(
|
||||
desc(WeeklyPlan.week_start_date)
|
||||
).limit(limit).all()
|
||||
|
||||
return {"plans": plans, "total": db.query(WeeklyPlan).count()}
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Live Performance Dashboard API - Real-time plan monitoring and alerts
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Any, Dict, List, Optional, Literal
|
||||
from datetime import datetime, date, timezone
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.models.models import DailyChecklist, UserProfile
|
||||
from app.services.simulation_state import load_simulation_state
|
||||
|
||||
router = APIRouter(prefix="/api/live-dashboard", tags=["Live Dashboard"])
|
||||
|
||||
|
||||
class DailyPlanStatus(BaseModel):
|
||||
"""Current status of today's trading plan"""
|
||||
date: str
|
||||
target: float
|
||||
actual_pnl: float
|
||||
progress_percent: float
|
||||
max_loss: float
|
||||
current_drawdown: float
|
||||
max_trades: int
|
||||
actual_trades: int
|
||||
trades_remaining: int
|
||||
status: Literal["on-track", "near-limit", "limit-reached", "target-met"]
|
||||
alerts: List[str]
|
||||
|
||||
|
||||
class PerformanceWidget(BaseModel):
|
||||
"""Sticky dashboard widget data"""
|
||||
daily_plan: DailyPlanStatus
|
||||
position_summary: Dict
|
||||
risk_metrics: Dict
|
||||
alerts: List[Dict]
|
||||
recommendations: List[str]
|
||||
|
||||
|
||||
class AlertConfig(BaseModel):
|
||||
"""Alert configuration"""
|
||||
alert_type: str # trade_limit, loss_limit, target_achieved, break_recommended
|
||||
enabled: bool
|
||||
threshold: Optional[float] = None
|
||||
message: str
|
||||
|
||||
|
||||
# In-memory simulation state (shared with trading.py)
|
||||
|
||||
|
||||
def _get_today_plan_from_storage() -> Optional[Dict]:
|
||||
"""Get today's trading plan blueprint (defaults until persistence is added)."""
|
||||
# In production, this would query the database
|
||||
# For now, we'll use a default plan structure
|
||||
return {
|
||||
"date": date.today().isoformat(),
|
||||
"daily_target": 500.0,
|
||||
"max_loss": 250.0,
|
||||
"max_trades": 3,
|
||||
"bias": "NEUTRAL",
|
||||
}
|
||||
|
||||
|
||||
def _calculate_daily_pnl(trades: List[Dict[str, Any]], target_date: date | None = None) -> float:
|
||||
"""Calculate P&L for trades executed on the target date"""
|
||||
target_date = target_date or date.today()
|
||||
daily_pnl = 0.0
|
||||
for trade in trades:
|
||||
trade_ts = trade.get("timestamp", 0)
|
||||
trade_date = datetime.fromtimestamp(trade_ts, tz=timezone.utc).date()
|
||||
if trade_date == target_date:
|
||||
pnl = trade.get("pnl", 0.0)
|
||||
if pnl:
|
||||
daily_pnl += pnl
|
||||
return daily_pnl
|
||||
|
||||
|
||||
def _count_today_trades(trades: List[Dict[str, Any]], target_date: date | None = None) -> int:
|
||||
"""Count trades executed on the target date"""
|
||||
target_date = target_date or date.today()
|
||||
count = 0
|
||||
for trade in trades:
|
||||
trade_ts = trade.get("timestamp", 0)
|
||||
trade_date = datetime.fromtimestamp(trade_ts, tz=timezone.utc).date()
|
||||
if trade_date == target_date:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def _generate_alerts(plan: Dict, actual_pnl: float, trades_count: int) -> List[str]:
|
||||
"""Generate smart alerts based on plan vs actual"""
|
||||
alerts = []
|
||||
|
||||
target = plan.get("daily_target", 500.0)
|
||||
max_loss = plan.get("max_loss", 250.0)
|
||||
max_trades = plan.get("max_trades", 3)
|
||||
|
||||
# Trade limit alerts
|
||||
trades_remaining = max_trades - trades_count
|
||||
if trades_remaining == 1:
|
||||
alerts.append(f"⚠️ Only 1 trade remaining before daily limit")
|
||||
elif trades_remaining <= 0:
|
||||
alerts.append(f"🛑 Daily trade limit reached ({max_trades} trades)")
|
||||
|
||||
# Loss alerts
|
||||
if actual_pnl < 0:
|
||||
loss_percent = (abs(actual_pnl) / max_loss) * 100
|
||||
if loss_percent >= 100:
|
||||
alerts.append(f"🚨 Max loss limit reached (${abs(actual_pnl):.2f})")
|
||||
elif loss_percent >= 80:
|
||||
alerts.append(f"⚠️ Near max loss limit ({loss_percent:.0f}% of ${max_loss})")
|
||||
elif loss_percent >= 50:
|
||||
alerts.append(f"⚡ Drawdown at {loss_percent:.0f}% of max loss")
|
||||
|
||||
# Target achievement alerts
|
||||
if actual_pnl > 0:
|
||||
progress_percent = (actual_pnl / target) * 100
|
||||
if progress_percent >= 100:
|
||||
alerts.append(f"🎉 Daily target achieved! (+${actual_pnl:.2f})")
|
||||
elif progress_percent >= 80:
|
||||
alerts.append(f"🎯 ${target - actual_pnl:.2f} away from daily target")
|
||||
|
||||
# Trading duration alerts (if 2+ hours and significant losses)
|
||||
if trades_count >= 2 and actual_pnl < -100:
|
||||
alerts.append(f"💡 Consider taking a break. ${abs(actual_pnl):.2f} in losses after {trades_count} trades")
|
||||
|
||||
return alerts
|
||||
|
||||
|
||||
def _determine_status(
|
||||
actual_pnl: float,
|
||||
target: float,
|
||||
max_loss: float,
|
||||
trades_count: int,
|
||||
max_trades: int
|
||||
) -> Literal["on-track", "near-limit", "limit-reached", "target-met"]:
|
||||
"""Determine overall plan status"""
|
||||
|
||||
# Target met
|
||||
if actual_pnl >= target:
|
||||
return "target-met"
|
||||
|
||||
# Limits reached
|
||||
if trades_count >= max_trades:
|
||||
return "limit-reached"
|
||||
|
||||
if actual_pnl <= -max_loss:
|
||||
return "limit-reached"
|
||||
|
||||
# Near limits
|
||||
loss_percent = (abs(actual_pnl) / max_loss) * 100 if actual_pnl < 0 else 0
|
||||
trades_percent = (trades_count / max_trades) * 100
|
||||
|
||||
if loss_percent >= 80 or trades_percent >= 80:
|
||||
return "near-limit"
|
||||
|
||||
# On track
|
||||
return "on-track"
|
||||
|
||||
|
||||
@router.get("/status", response_model=DailyPlanStatus)
|
||||
async def get_dashboard_status(db: Session = Depends(get_db)) -> DailyPlanStatus:
|
||||
"""
|
||||
Get current status of today's trading plan with real-time metrics
|
||||
"""
|
||||
try:
|
||||
plan = _get_today_plan_from_storage()
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="No trading plan found for today")
|
||||
|
||||
state = load_simulation_state(db)
|
||||
trades = state.get("trades", [])
|
||||
actual_pnl = _calculate_daily_pnl(trades)
|
||||
trades_count = _count_today_trades(trades)
|
||||
|
||||
target = plan.get("daily_target", 500.0)
|
||||
max_loss = plan.get("max_loss", 250.0)
|
||||
max_trades = plan.get("max_trades", 3)
|
||||
|
||||
progress_percent = (actual_pnl / target) * 100 if target > 0 else 0
|
||||
current_drawdown = abs(actual_pnl) if actual_pnl < 0 else 0
|
||||
trades_remaining = max(0, max_trades - trades_count)
|
||||
|
||||
alerts = _generate_alerts(plan, actual_pnl, trades_count)
|
||||
status = _determine_status(actual_pnl, target, max_loss, trades_count, max_trades)
|
||||
|
||||
return DailyPlanStatus(
|
||||
date=plan["date"],
|
||||
target=target,
|
||||
actual_pnl=actual_pnl,
|
||||
progress_percent=round(progress_percent, 1),
|
||||
max_loss=max_loss,
|
||||
current_drawdown=current_drawdown,
|
||||
max_trades=max_trades,
|
||||
actual_trades=trades_count,
|
||||
trades_remaining=trades_remaining,
|
||||
status=status,
|
||||
alerts=alerts,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get dashboard status: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/widget", response_model=PerformanceWidget)
|
||||
async def get_performance_widget(db: Session = Depends(get_db)) -> PerformanceWidget:
|
||||
"""
|
||||
Get complete performance widget data for sticky dashboard
|
||||
"""
|
||||
try:
|
||||
# Get daily plan status
|
||||
daily_plan = await get_dashboard_status(db=db)
|
||||
|
||||
state = load_simulation_state(db)
|
||||
position = state.get("position")
|
||||
cash = float(state.get("cash", 100000.0))
|
||||
position_value = 0.0
|
||||
if position:
|
||||
position_value = float(position.get("quantity", 0.0)) * float(position.get("avg_price", 0.0))
|
||||
|
||||
total_equity = cash + position_value
|
||||
position_summary = {
|
||||
"has_position": position is not None,
|
||||
"quantity": float(position.get("quantity", 0.0)) if position else 0,
|
||||
"avg_price": float(position.get("avg_price", 0.0)) if position else 0,
|
||||
"cash": cash,
|
||||
"total_equity": total_equity,
|
||||
}
|
||||
|
||||
# Calculate risk metrics
|
||||
initial_capital = float(state.get("initial_capital", 100000.0))
|
||||
safe_equity = total_equity if total_equity != 0 else 1
|
||||
total_return = ((total_equity - initial_capital) / initial_capital) * 100 if initial_capital else 0
|
||||
|
||||
risk_metrics = {
|
||||
"total_equity": total_equity,
|
||||
"total_return_percent": round(total_return, 2),
|
||||
"position_size_percent": round((position_value / safe_equity * 100), 2) if position else 0,
|
||||
"cash_percent": round((cash / safe_equity * 100), 2),
|
||||
}
|
||||
|
||||
# Generate smart recommendations
|
||||
recommendations = []
|
||||
|
||||
if daily_plan.status == "target-met":
|
||||
recommendations.append("🎉 Consider closing for the day - target achieved!")
|
||||
elif daily_plan.status == "limit-reached":
|
||||
recommendations.append("🛑 Trading halt recommended - daily limits reached")
|
||||
elif daily_plan.status == "near-limit":
|
||||
if daily_plan.trades_remaining == 1:
|
||||
recommendations.append("⚠️ Last trade available - make it count")
|
||||
if daily_plan.current_drawdown > daily_plan.max_loss * 0.8:
|
||||
recommendations.append("🔻 Consider defensive position sizing")
|
||||
else:
|
||||
if daily_plan.actual_pnl > daily_plan.target * 0.7:
|
||||
recommendations.append("🎯 Near target - consider taking profits")
|
||||
|
||||
# Alert objects with metadata
|
||||
alert_objects = [
|
||||
{
|
||||
"type": "info",
|
||||
"message": alert,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
for alert in daily_plan.alerts
|
||||
]
|
||||
|
||||
return PerformanceWidget(
|
||||
daily_plan=daily_plan,
|
||||
position_summary=position_summary,
|
||||
risk_metrics=risk_metrics,
|
||||
alerts=alert_objects,
|
||||
recommendations=recommendations,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get performance widget: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/check-limits")
|
||||
async def check_trading_limits(db: Session = Depends(get_db)) -> Dict:
|
||||
"""
|
||||
Check if trading should be halted based on plan limits
|
||||
Returns: {can_trade: bool, reason: str}
|
||||
"""
|
||||
try:
|
||||
plan = _get_today_plan_from_storage()
|
||||
if not plan:
|
||||
return {"can_trade": True, "reason": "No plan configured"}
|
||||
|
||||
state = load_simulation_state(db)
|
||||
trades = state.get("trades", [])
|
||||
actual_pnl = _calculate_daily_pnl(trades)
|
||||
trades_count = _count_today_trades(trades)
|
||||
|
||||
max_loss = plan.get("max_loss", 250.0)
|
||||
max_trades = plan.get("max_trades", 3)
|
||||
target = plan.get("daily_target", 500.0)
|
||||
|
||||
if actual_pnl <= -max_loss:
|
||||
return {
|
||||
"can_trade": False,
|
||||
"reason": f"Max loss limit reached (${abs(actual_pnl):.2f})",
|
||||
"limit_type": "loss",
|
||||
}
|
||||
|
||||
if trades_count >= max_trades:
|
||||
return {
|
||||
"can_trade": False,
|
||||
"reason": f"Max trades limit reached ({trades_count}/{max_trades})",
|
||||
"limit_type": "trades",
|
||||
}
|
||||
|
||||
if actual_pnl >= target:
|
||||
return {
|
||||
"can_trade": True,
|
||||
"reason": f"Target achieved (+${actual_pnl:.2f}) - consider closing for the day",
|
||||
"warning": True,
|
||||
}
|
||||
|
||||
return {
|
||||
"can_trade": True,
|
||||
"reason": "Within limits",
|
||||
"remaining_trades": max_trades - trades_count,
|
||||
"remaining_loss_buffer": max_loss + actual_pnl,
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to check trading limits: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/session-summary")
|
||||
async def get_session_summary(db: Session = Depends(get_db)) -> Dict:
|
||||
"""
|
||||
Get end-of-day session summary with AI coaching suggestions
|
||||
"""
|
||||
try:
|
||||
plan = _get_today_plan_from_storage()
|
||||
state = load_simulation_state(db)
|
||||
trades = state.get("trades", [])
|
||||
actual_pnl = _calculate_daily_pnl(trades)
|
||||
trades_count = _count_today_trades(trades)
|
||||
|
||||
if not plan:
|
||||
raise HTTPException(status_code=404, detail="No trading plan found")
|
||||
|
||||
target = plan.get("daily_target", 500.0)
|
||||
max_loss = plan.get("max_loss", 250.0)
|
||||
|
||||
target_achieved = actual_pnl >= target
|
||||
within_limits = actual_pnl > -max_loss and trades_count <= plan.get("max_trades", 3)
|
||||
|
||||
today = date.today()
|
||||
today_trades = [
|
||||
t for t in trades
|
||||
if datetime.fromtimestamp(t.get("timestamp", 0), tz=timezone.utc).date() == today
|
||||
]
|
||||
|
||||
winning_trades = sum(1 for t in today_trades if t.get("pnl", 0) > 0)
|
||||
win_rate = (winning_trades / len(today_trades) * 100) if today_trades else 0
|
||||
|
||||
coaching = []
|
||||
if target_achieved:
|
||||
coaching.append("✅ Excellent discipline - you met your daily target!")
|
||||
else:
|
||||
deficit = target - actual_pnl
|
||||
coaching.append(f"📊 ${deficit:.2f} short of target. Review your entry setups.")
|
||||
|
||||
if win_rate >= 60:
|
||||
coaching.append(f"🎯 Strong win rate ({win_rate:.0f}%). Keep following your strategy.")
|
||||
elif win_rate < 40:
|
||||
coaching.append(f"⚠️ Low win rate ({win_rate:.0f}%). Review your trade selection criteria.")
|
||||
|
||||
if not within_limits:
|
||||
coaching.append("🔻 Limits exceeded. Focus on risk management tomorrow.")
|
||||
if trades_count > plan.get("max_trades", 3):
|
||||
coaching.append("⚠️ Over-trading detected. Stick to your max trades limit.")
|
||||
|
||||
return {
|
||||
"date": plan["date"],
|
||||
"summary": {
|
||||
"target": target,
|
||||
"actual_pnl": actual_pnl,
|
||||
"target_achieved": target_achieved,
|
||||
"within_limits": within_limits,
|
||||
"trades_count": trades_count,
|
||||
"win_rate": round(win_rate, 1),
|
||||
},
|
||||
"coaching": coaching,
|
||||
"next_session_suggestions": [
|
||||
"Review today's winning trades for patterns",
|
||||
"Adjust stop loss strategy if needed",
|
||||
"Focus on high-probability setups only",
|
||||
],
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate session summary: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Ollama API endpoints for local AI status and simple tasks.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from app.services.ollama_service import ollama_service
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter(prefix="/api/ollama", tags=["Local AI"])
|
||||
|
||||
|
||||
class OllamaStatus(BaseModel):
|
||||
available: bool
|
||||
model: str
|
||||
embed_model: str
|
||||
base_url: str
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
prompt: str
|
||||
system: Optional[str] = None
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 500
|
||||
|
||||
|
||||
class GenerateResponse(BaseModel):
|
||||
response: Optional[str]
|
||||
model: str
|
||||
success: bool
|
||||
|
||||
|
||||
class SentimentRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class SentimentResponse(BaseModel):
|
||||
sentiment: Optional[str]
|
||||
confidence: Optional[float]
|
||||
success: bool
|
||||
|
||||
|
||||
class ClassifyRequest(BaseModel):
|
||||
text: str
|
||||
categories: List[str]
|
||||
|
||||
|
||||
class ClassifyResponse(BaseModel):
|
||||
category: Optional[str]
|
||||
success: bool
|
||||
|
||||
|
||||
class SummarizeRequest(BaseModel):
|
||||
text: str
|
||||
max_sentences: int = 2
|
||||
|
||||
|
||||
class SummarizeResponse(BaseModel):
|
||||
summary: Optional[str]
|
||||
success: bool
|
||||
|
||||
|
||||
@router.get("/status", response_model=OllamaStatus)
|
||||
async def get_ollama_status():
|
||||
"""Check if Ollama is available and configured."""
|
||||
available = await ollama_service.is_available()
|
||||
return OllamaStatus(
|
||||
available=available,
|
||||
model=settings.OLLAMA_MODEL,
|
||||
embed_model=settings.OLLAMA_MODEL_EMBED,
|
||||
base_url=settings.OLLAMA_BASE_URL
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate", response_model=GenerateResponse)
|
||||
async def generate_text(request: GenerateRequest):
|
||||
"""Generate text using local Ollama model."""
|
||||
result = await ollama_service.generate(
|
||||
prompt=request.prompt,
|
||||
system=request.system,
|
||||
temperature=request.temperature,
|
||||
max_tokens=request.max_tokens
|
||||
)
|
||||
return GenerateResponse(
|
||||
response=result,
|
||||
model=settings.OLLAMA_MODEL,
|
||||
success=result is not None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sentiment", response_model=SentimentResponse)
|
||||
async def analyze_sentiment(request: SentimentRequest):
|
||||
"""Quick sentiment analysis using local model."""
|
||||
result = await ollama_service.quick_sentiment(request.text)
|
||||
if result:
|
||||
return SentimentResponse(
|
||||
sentiment=result.get("sentiment"),
|
||||
confidence=result.get("confidence"),
|
||||
success=True
|
||||
)
|
||||
return SentimentResponse(sentiment=None, confidence=None, success=False)
|
||||
|
||||
|
||||
@router.post("/classify", response_model=ClassifyResponse)
|
||||
async def classify_text(request: ClassifyRequest):
|
||||
"""Classify text into one of the provided categories."""
|
||||
result = await ollama_service.quick_classify(request.text, request.categories)
|
||||
return ClassifyResponse(
|
||||
category=result,
|
||||
success=result is not None
|
||||
)
|
||||
|
||||
|
||||
@router.post("/summarize", response_model=SummarizeResponse)
|
||||
async def summarize_text(request: SummarizeRequest):
|
||||
"""Quick text summarization using local model."""
|
||||
result = await ollama_service.quick_summarize(
|
||||
text=request.text,
|
||||
max_sentences=request.max_sentences
|
||||
)
|
||||
return SummarizeResponse(
|
||||
summary=result,
|
||||
success=result is not None
|
||||
)
|
||||
@@ -0,0 +1,558 @@
|
||||
"""
|
||||
Position Management Assistant API
|
||||
Provides intelligent mitigation plans, exit strategies, and risk monitoring for active positions
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Dict, Literal
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import numpy as np
|
||||
|
||||
router = APIRouter(prefix="/api/position-assistant", tags=["Position Assistant"])
|
||||
|
||||
|
||||
class ActivePosition(BaseModel):
|
||||
"""Current active position details"""
|
||||
symbol: str = Field(default="XAU/USD")
|
||||
direction: Literal["LONG", "SHORT"]
|
||||
entry_price: float
|
||||
quantity: float
|
||||
stop_loss: float
|
||||
take_profit: Optional[float] = None
|
||||
entry_time: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class MitigationStrategy(BaseModel):
|
||||
"""Smart mitigation strategy for managing risk"""
|
||||
strategy_name: str
|
||||
priority: int # 1 = highest priority
|
||||
action: str
|
||||
trigger_price: float
|
||||
reasoning: str
|
||||
expected_benefit: str
|
||||
risk_level: Literal["LOW", "MEDIUM", "HIGH"]
|
||||
|
||||
|
||||
class PriceReversal(BaseModel):
|
||||
"""Predicted price reversal levels and timing"""
|
||||
level: float
|
||||
probability: float # 0-1
|
||||
timeframe: str # e.g., "2-4 hours", "End of day"
|
||||
reasoning: str
|
||||
confluences: List[str]
|
||||
|
||||
|
||||
class PositionHealth(BaseModel):
|
||||
"""Real-time position health assessment"""
|
||||
status: Literal["HEALTHY", "AT_RISK", "CRITICAL", "WINNING"]
|
||||
current_pnl: float
|
||||
current_pnl_percent: float
|
||||
distance_to_stop_loss: float
|
||||
distance_to_stop_loss_percent: float
|
||||
time_in_trade: str
|
||||
recommendation: str
|
||||
urgency: Literal["LOW", "MEDIUM", "HIGH", "URGENT"]
|
||||
|
||||
|
||||
class PositionManagementPlan(BaseModel):
|
||||
"""Complete position management plan"""
|
||||
position: ActivePosition
|
||||
current_price: float
|
||||
health: PositionHealth
|
||||
mitigation_strategies: List[MitigationStrategy]
|
||||
reversal_zones: List[PriceReversal]
|
||||
exit_plan: Dict
|
||||
alerts: List[str]
|
||||
next_actions: List[str]
|
||||
|
||||
|
||||
def _calculate_position_health(
|
||||
position: ActivePosition,
|
||||
current_price: float
|
||||
) -> PositionHealth:
|
||||
"""Calculate real-time position health"""
|
||||
|
||||
# Calculate P&L
|
||||
if position.direction == "SHORT":
|
||||
pnl = (position.entry_price - current_price) * position.quantity
|
||||
pnl_percent = ((position.entry_price - current_price) / position.entry_price) * 100
|
||||
distance_to_sl = position.stop_loss - current_price
|
||||
else: # LONG
|
||||
pnl = (current_price - position.entry_price) * position.quantity
|
||||
pnl_percent = ((current_price - position.entry_price) / position.entry_price) * 100
|
||||
distance_to_sl = current_price - position.stop_loss
|
||||
|
||||
distance_to_sl_percent = (distance_to_sl / position.entry_price) * 100
|
||||
|
||||
# Calculate time in trade
|
||||
entry_dt = datetime.fromisoformat(position.entry_time.replace('Z', '+00:00'))
|
||||
now_dt = datetime.now(timezone.utc)
|
||||
time_diff = now_dt - entry_dt
|
||||
hours = time_diff.total_seconds() / 3600
|
||||
|
||||
if hours < 1:
|
||||
time_in_trade = f"{int(time_diff.total_seconds() / 60)} minutes"
|
||||
elif hours < 24:
|
||||
time_in_trade = f"{hours:.1f} hours"
|
||||
else:
|
||||
time_in_trade = f"{hours/24:.1f} days"
|
||||
|
||||
# Determine status and urgency
|
||||
if pnl > 0:
|
||||
if pnl_percent > 2:
|
||||
status = "WINNING"
|
||||
urgency = "LOW"
|
||||
recommendation = "Consider taking partial profits to secure gains"
|
||||
else:
|
||||
status = "HEALTHY"
|
||||
urgency = "LOW"
|
||||
recommendation = "Monitor for continuation or reversal signals"
|
||||
else:
|
||||
loss_percent_of_sl = abs(pnl_percent) / abs((position.stop_loss - position.entry_price) / position.entry_price * 100)
|
||||
|
||||
if loss_percent_of_sl > 0.8:
|
||||
status = "CRITICAL"
|
||||
urgency = "URGENT"
|
||||
recommendation = "CLOSE POSITION NOW or implement emergency mitigation"
|
||||
elif loss_percent_of_sl > 0.5:
|
||||
status = "AT_RISK"
|
||||
urgency = "HIGH"
|
||||
recommendation = "Consider scaling out or tightening stop loss"
|
||||
else:
|
||||
status = "AT_RISK"
|
||||
urgency = "MEDIUM"
|
||||
recommendation = "Watch for reversal signals, keep stop loss in place"
|
||||
|
||||
return PositionHealth(
|
||||
status=status,
|
||||
current_pnl=round(pnl, 2),
|
||||
current_pnl_percent=round(pnl_percent, 2),
|
||||
distance_to_stop_loss=round(distance_to_sl, 2),
|
||||
distance_to_stop_loss_percent=round(distance_to_sl_percent, 2),
|
||||
time_in_trade=time_in_trade,
|
||||
recommendation=recommendation,
|
||||
urgency=urgency
|
||||
)
|
||||
|
||||
|
||||
def _generate_mitigation_strategies(
|
||||
position: ActivePosition,
|
||||
current_price: float,
|
||||
health: PositionHealth
|
||||
) -> List[MitigationStrategy]:
|
||||
"""Generate smart mitigation strategies"""
|
||||
|
||||
strategies = []
|
||||
|
||||
if position.direction == "SHORT":
|
||||
# SHORT position mitigation strategies
|
||||
|
||||
# Strategy 1: Partial close at break-even
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Break-Even Exit (Partial)",
|
||||
priority=1,
|
||||
action=f"Close 50% of position at ${position.entry_price:.2f}",
|
||||
trigger_price=position.entry_price,
|
||||
reasoning="Lock in zero loss on half the position if price retraces to entry",
|
||||
expected_benefit="Reduces risk by 50% while keeping upside exposure",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
# Strategy 2: Scale out in profit
|
||||
if current_price < position.entry_price:
|
||||
target_1 = position.entry_price - (position.entry_price - current_price) * 1.5
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Scale Out (First Target)",
|
||||
priority=2,
|
||||
action=f"Close 30% of position at ${target_1:.2f}",
|
||||
trigger_price=target_1,
|
||||
reasoning="Take partial profits at 1.5x current movement",
|
||||
expected_benefit="Secure profits while maintaining exposure",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
# Strategy 3: Move stop to break-even
|
||||
if health.current_pnl > 0:
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Move Stop to Break-Even",
|
||||
priority=3,
|
||||
action=f"Move stop loss from ${position.stop_loss:.2f} to ${position.entry_price:.2f}",
|
||||
trigger_price=current_price,
|
||||
reasoning="Eliminate downside risk once in profit",
|
||||
expected_benefit="Cannot lose money on this trade anymore",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
# Strategy 4: Emergency hedge
|
||||
if health.status == "CRITICAL":
|
||||
hedge_price = position.entry_price + (position.stop_loss - position.entry_price) * 0.5
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Emergency Hedge (LONG)",
|
||||
priority=1,
|
||||
action=f"Open LONG position at ${current_price:.2f} (same size)",
|
||||
trigger_price=current_price,
|
||||
reasoning="Neutralize the position to stop bleeding while you reassess",
|
||||
expected_benefit="Stop further losses immediately",
|
||||
risk_level="HIGH"
|
||||
))
|
||||
|
||||
# Strategy 5: Widen stop temporarily
|
||||
if health.status == "AT_RISK" and health.urgency == "HIGH":
|
||||
new_sl = position.stop_loss + (position.stop_loss - position.entry_price) * 0.3
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Temporary Stop Widening",
|
||||
priority=4,
|
||||
action=f"Widen stop loss to ${new_sl:.2f} temporarily",
|
||||
trigger_price=current_price,
|
||||
reasoning="Give position room to breathe during volatility spike",
|
||||
expected_benefit="Avoid premature stop-out if reversal is coming",
|
||||
risk_level="MEDIUM"
|
||||
))
|
||||
|
||||
else: # LONG position
|
||||
# LONG position mitigation strategies (mirror of SHORT)
|
||||
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Break-Even Exit (Partial)",
|
||||
priority=1,
|
||||
action=f"Close 50% of position at ${position.entry_price:.2f}",
|
||||
trigger_price=position.entry_price,
|
||||
reasoning="Lock in zero loss on half the position if price retraces to entry",
|
||||
expected_benefit="Reduces risk by 50% while keeping upside exposure",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
if current_price > position.entry_price:
|
||||
target_1 = position.entry_price + (current_price - position.entry_price) * 1.5
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Scale Out (First Target)",
|
||||
priority=2,
|
||||
action=f"Close 30% of position at ${target_1:.2f}",
|
||||
trigger_price=target_1,
|
||||
reasoning="Take partial profits at 1.5x current movement",
|
||||
expected_benefit="Secure profits while maintaining exposure",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
if health.current_pnl > 0:
|
||||
strategies.append(MitigationStrategy(
|
||||
strategy_name="Move Stop to Break-Even",
|
||||
priority=3,
|
||||
action=f"Move stop loss from ${position.stop_loss:.2f} to ${position.entry_price:.2f}",
|
||||
trigger_price=current_price,
|
||||
reasoning="Eliminate downside risk once in profit",
|
||||
expected_benefit="Cannot lose money on this trade anymore",
|
||||
risk_level="LOW"
|
||||
))
|
||||
|
||||
# Sort by priority
|
||||
strategies.sort(key=lambda x: x.priority)
|
||||
|
||||
return strategies
|
||||
|
||||
|
||||
def _predict_reversal_zones(
|
||||
position: ActivePosition,
|
||||
current_price: float
|
||||
) -> List[PriceReversal]:
|
||||
"""Predict potential reversal zones using technical analysis"""
|
||||
|
||||
reversals = []
|
||||
|
||||
if position.direction == "SHORT":
|
||||
# For SHORT: Looking for price to drop (reversal down from current)
|
||||
|
||||
# Support level 1: 0.5 Fibonacci from entry to current
|
||||
fib_50 = position.entry_price - (position.entry_price - current_price) * 0.5
|
||||
if current_price > position.entry_price: # If against us
|
||||
fib_50 = current_price - (current_price - position.entry_price) * 0.382
|
||||
reversals.append(PriceReversal(
|
||||
level=round(fib_50, 2),
|
||||
probability=0.65,
|
||||
timeframe="2-4 hours",
|
||||
reasoning="38.2% Fibonacci retracement - common reversal zone",
|
||||
confluences=["Fibonacci level", "Potential exhaustion zone"]
|
||||
))
|
||||
|
||||
# Support level 2: Round number below entry
|
||||
round_number = (int(position.entry_price / 100) * 100) - 100
|
||||
if round_number < current_price:
|
||||
reversals.append(PriceReversal(
|
||||
level=round(round_number, 2),
|
||||
probability=0.55,
|
||||
timeframe="4-8 hours",
|
||||
reasoning="Major round number psychological support",
|
||||
confluences=["Round number", "Psychological level"]
|
||||
))
|
||||
|
||||
# Support level 3: Previous day low (simulated)
|
||||
prev_day_low = position.entry_price - (position.entry_price * 0.015) # 1.5% below entry
|
||||
reversals.append(PriceReversal(
|
||||
level=round(prev_day_low, 2),
|
||||
probability=0.70,
|
||||
timeframe="End of day",
|
||||
reasoning="Estimated previous day low - strong support",
|
||||
confluences=["Previous low", "Session support"]
|
||||
))
|
||||
|
||||
else: # LONG
|
||||
# For LONG: Looking for price to rise (reversal up from current)
|
||||
|
||||
fib_50 = position.entry_price + (current_price - position.entry_price) * 0.5
|
||||
if current_price < position.entry_price: # If against us
|
||||
fib_50 = current_price + (position.entry_price - current_price) * 0.382
|
||||
reversals.append(PriceReversal(
|
||||
level=round(fib_50, 2),
|
||||
probability=0.65,
|
||||
timeframe="2-4 hours",
|
||||
reasoning="38.2% Fibonacci retracement - common reversal zone",
|
||||
confluences=["Fibonacci level", "Potential exhaustion zone"]
|
||||
))
|
||||
|
||||
round_number = (int(position.entry_price / 100) * 100) + 100
|
||||
if round_number > current_price:
|
||||
reversals.append(PriceReversal(
|
||||
level=round(round_number, 2),
|
||||
probability=0.55,
|
||||
timeframe="4-8 hours",
|
||||
reasoning="Major round number psychological resistance",
|
||||
confluences=["Round number", "Psychological level"]
|
||||
))
|
||||
|
||||
prev_day_high = position.entry_price + (position.entry_price * 0.015)
|
||||
reversals.append(PriceReversal(
|
||||
level=round(prev_day_high, 2),
|
||||
probability=0.70,
|
||||
timeframe="End of day",
|
||||
reasoning="Estimated previous day high - strong resistance",
|
||||
confluences=["Previous high", "Session resistance"]
|
||||
))
|
||||
|
||||
# Sort by probability (highest first)
|
||||
reversals.sort(key=lambda x: x.probability, reverse=True)
|
||||
|
||||
return reversals
|
||||
|
||||
|
||||
def _create_exit_plan(
|
||||
position: ActivePosition,
|
||||
current_price: float,
|
||||
health: PositionHealth,
|
||||
reversals: List[PriceReversal]
|
||||
) -> Dict:
|
||||
"""Create comprehensive exit plan"""
|
||||
|
||||
plan = {
|
||||
"immediate_action": None,
|
||||
"optimal_exits": [],
|
||||
"emergency_exit": None,
|
||||
"time_based_exit": None
|
||||
}
|
||||
|
||||
if health.status == "CRITICAL":
|
||||
plan["immediate_action"] = {
|
||||
"action": "CLOSE IMMEDIATELY",
|
||||
"reason": "Position is critically at risk",
|
||||
"price": current_price
|
||||
}
|
||||
plan["emergency_exit"] = {
|
||||
"action": "Market order close if stop loss hit",
|
||||
"trigger": position.stop_loss,
|
||||
"loss_amount": health.current_pnl if health.current_pnl < 0 else 0
|
||||
}
|
||||
|
||||
elif health.status == "WINNING":
|
||||
# Build scaling out plan
|
||||
if position.direction == "SHORT":
|
||||
target_1 = current_price - (position.entry_price - current_price) * 0.5
|
||||
target_2 = current_price - (position.entry_price - current_price) * 1.0
|
||||
else:
|
||||
target_1 = current_price + (current_price - position.entry_price) * 0.5
|
||||
target_2 = current_price + (current_price - position.entry_price) * 1.0
|
||||
|
||||
plan["optimal_exits"] = [
|
||||
{
|
||||
"level": 1,
|
||||
"price": round(target_1, 2),
|
||||
"quantity_percent": 33,
|
||||
"reason": "First profit target - secure initial gains"
|
||||
},
|
||||
{
|
||||
"level": 2,
|
||||
"price": round(target_2, 2),
|
||||
"quantity_percent": 33,
|
||||
"reason": "Second profit target - let winners run"
|
||||
},
|
||||
{
|
||||
"level": 3,
|
||||
"price": "Trailing stop",
|
||||
"quantity_percent": 34,
|
||||
"reason": "Trail remaining with break-even stop"
|
||||
}
|
||||
]
|
||||
|
||||
else: # AT_RISK or HEALTHY
|
||||
# Exit at reversal zones
|
||||
plan["optimal_exits"] = [
|
||||
{
|
||||
"level": i + 1,
|
||||
"price": rev.level,
|
||||
"quantity_percent": 100 if i == 0 else 50,
|
||||
"reason": f"{rev.reasoning} ({int(rev.probability*100)}% probability)"
|
||||
}
|
||||
for i, rev in enumerate(reversals[:2])
|
||||
]
|
||||
|
||||
# Time-based exit (end of day or session)
|
||||
hours_in_trade = (datetime.now(timezone.utc) - datetime.fromisoformat(position.entry_time.replace('Z', '+00:00'))).total_seconds() / 3600
|
||||
|
||||
if hours_in_trade > 4 and health.status != "WINNING":
|
||||
plan["time_based_exit"] = {
|
||||
"time": "End of trading session",
|
||||
"action": "Review and consider closing if no reversal",
|
||||
"reason": "Avoid holding losing position overnight"
|
||||
}
|
||||
|
||||
return plan
|
||||
|
||||
|
||||
@router.post("/analyze", response_model=PositionManagementPlan)
|
||||
async def analyze_position(
|
||||
position: ActivePosition,
|
||||
current_price: float = Query(..., description="Current market price")
|
||||
) -> PositionManagementPlan:
|
||||
"""
|
||||
Analyze active position and provide comprehensive management plan
|
||||
|
||||
Example:
|
||||
```
|
||||
POST /api/position-assistant/analyze?current_price=4085
|
||||
{
|
||||
"direction": "SHORT",
|
||||
"entry_price": 4070,
|
||||
"quantity": 1.0,
|
||||
"stop_loss": 4109,
|
||||
"entry_time": "2025-11-24T10:00:00Z"
|
||||
}
|
||||
```
|
||||
"""
|
||||
|
||||
try:
|
||||
# Calculate position health
|
||||
health = _calculate_position_health(position, current_price)
|
||||
|
||||
# Generate mitigation strategies
|
||||
strategies = _generate_mitigation_strategies(position, current_price, health)
|
||||
|
||||
# Predict reversal zones
|
||||
reversals = _predict_reversal_zones(position, current_price)
|
||||
|
||||
# Create exit plan
|
||||
exit_plan = _create_exit_plan(position, current_price, health, reversals)
|
||||
|
||||
# Generate alerts
|
||||
alerts = []
|
||||
|
||||
if health.status == "CRITICAL":
|
||||
alerts.append("🚨 URGENT: Position at critical risk level")
|
||||
alerts.append(f"⚠️ Stop loss ${abs(health.distance_to_stop_loss):.2f} away")
|
||||
elif health.status == "AT_RISK" and health.urgency == "HIGH":
|
||||
alerts.append(f"⚠️ Position down {abs(health.current_pnl_percent):.1f}%")
|
||||
alerts.append("💡 Consider mitigation strategies")
|
||||
elif health.status == "WINNING":
|
||||
alerts.append(f"✅ Position up {health.current_pnl_percent:.1f}%")
|
||||
alerts.append("🎯 Consider taking partial profits")
|
||||
|
||||
# Generate next actions
|
||||
next_actions = []
|
||||
|
||||
if strategies:
|
||||
top_strategy = strategies[0]
|
||||
next_actions.append(f"📋 Primary: {top_strategy.action}")
|
||||
|
||||
if reversals:
|
||||
top_reversal = reversals[0]
|
||||
next_actions.append(f"🎯 Watch for reversal at ${top_reversal.level:.2f} ({top_reversal.timeframe})")
|
||||
|
||||
if exit_plan.get("immediate_action"):
|
||||
next_actions.insert(0, f"🚨 {exit_plan['immediate_action']['action']}")
|
||||
|
||||
return PositionManagementPlan(
|
||||
position=position,
|
||||
current_price=current_price,
|
||||
health=health,
|
||||
mitigation_strategies=strategies,
|
||||
reversal_zones=reversals,
|
||||
exit_plan=exit_plan,
|
||||
alerts=alerts,
|
||||
next_actions=next_actions
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to analyze position: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/quick-status")
|
||||
async def get_quick_status(
|
||||
direction: str = Query(..., description="LONG or SHORT"),
|
||||
entry_price: float = Query(...),
|
||||
current_price: float = Query(...),
|
||||
stop_loss: float = Query(...)
|
||||
) -> Dict:
|
||||
"""
|
||||
Quick position status check without full analysis
|
||||
|
||||
Example:
|
||||
```
|
||||
GET /api/position-assistant/quick-status?direction=SHORT&entry_price=4070¤t_price=4085&stop_loss=4109
|
||||
```
|
||||
"""
|
||||
|
||||
try:
|
||||
# Quick P&L calculation
|
||||
if direction.upper() == "SHORT":
|
||||
pnl = entry_price - current_price
|
||||
pnl_percent = ((entry_price - current_price) / entry_price) * 100
|
||||
distance_to_sl = stop_loss - current_price
|
||||
else:
|
||||
pnl = current_price - entry_price
|
||||
pnl_percent = ((current_price - entry_price) / entry_price) * 100
|
||||
distance_to_sl = current_price - stop_loss
|
||||
|
||||
distance_to_sl_percent = (distance_to_sl / entry_price) * 100
|
||||
|
||||
# Quick status
|
||||
if pnl > 0:
|
||||
status = "✅ In Profit"
|
||||
color = "green"
|
||||
else:
|
||||
loss_ratio = abs(distance_to_sl_percent / ((stop_loss - entry_price) / entry_price * 100))
|
||||
if loss_ratio > 0.8:
|
||||
status = "🚨 CRITICAL - Close to stop loss"
|
||||
color = "red"
|
||||
elif loss_ratio > 0.5:
|
||||
status = "⚠️ AT RISK"
|
||||
color = "orange"
|
||||
else:
|
||||
status = "📊 Monitoring"
|
||||
color = "yellow"
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"color": color,
|
||||
"pnl": round(pnl, 2),
|
||||
"pnl_percent": round(pnl_percent, 2),
|
||||
"distance_to_stop_loss": round(abs(distance_to_sl), 2),
|
||||
"distance_to_stop_loss_percent": round(abs(distance_to_sl_percent), 2)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get quick status: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from app.schemas.schemas import PositionMetrics
|
||||
from app.services.ai_context_builder import ai_context_builder
|
||||
from app.services.price_anchor import price_anchor_service
|
||||
|
||||
router = APIRouter(prefix="/positions", tags=["Positions"])
|
||||
|
||||
|
||||
@router.get("/metrics", response_model=PositionMetrics)
|
||||
async def get_position_metrics(
|
||||
symbol: str = Query("XAUUSD", description="Symbol, e.g., XAUUSD or BTCUSDT"),
|
||||
timeframe: str = Query("1m", description="Timeframe such as 1m,5m,1h"),
|
||||
limit: int = Query(400, ge=50, le=2000, description="Number of bars to analyze"),
|
||||
) -> PositionMetrics:
|
||||
try:
|
||||
sym = symbol.upper().replace("/", "")
|
||||
ctx = ai_context_builder.build_request(sym, timeframe, limit)
|
||||
metrics = ai_context_builder.build_metrics(sym, timeframe, ctx.price_data)
|
||||
anchor_price = await price_anchor_service.get_anchor_price(sym)
|
||||
return price_anchor_service.apply_anchor(metrics, anchor_price)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to compute position metrics: {exc}")
|
||||
@@ -0,0 +1,495 @@
|
||||
"""
|
||||
Smart Trade Hub API - Unified trade entry system
|
||||
Consolidates Simulator, Manual Logger, and Broker Bridge into one intelligent interface
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Any, Dict, List, Optional, Literal
|
||||
from datetime import datetime, timezone
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.services.simulation_state import load_simulation_state
|
||||
from app.api.trading_persistent import (
|
||||
TradeRequest as PersistentTradeRequest,
|
||||
execute_trade as persistent_execute_trade,
|
||||
)
|
||||
from app.services.risk import validate_order
|
||||
from app.services.ai_context_builder import ai_context_builder
|
||||
from app.services.price_anchor import price_anchor_service
|
||||
|
||||
router = APIRouter(prefix="/api/smart-trade-hub", tags=["Smart Trade Hub"])
|
||||
|
||||
|
||||
class TradeSource(str):
|
||||
"""Enumeration of trade sources"""
|
||||
SIMULATOR = "simulator"
|
||||
MANUAL = "manual"
|
||||
BROKER = "broker"
|
||||
VOICE = "voice"
|
||||
OCR = "ocr"
|
||||
|
||||
|
||||
class SmartTradeRequest(BaseModel):
|
||||
"""Unified trade entry request with auto-detection"""
|
||||
action: Literal["BUY", "SELL", "CLOSE"]
|
||||
symbol: str = Field(default="XAU/USD", description="Trading symbol")
|
||||
quantity: Optional[float] = Field(None, description="Trade quantity (auto-filled if None)")
|
||||
price: Optional[float] = Field(None, description="Entry price (uses current market if None)")
|
||||
|
||||
# Optional guards (auto-calculated if None)
|
||||
stop_loss: Optional[float] = None
|
||||
take_profit: Optional[float] = None
|
||||
risk_percent: Optional[float] = None
|
||||
|
||||
# Source detection and metadata
|
||||
source: Optional[str] = Field(None, description="Trade source: simulator/manual/broker/voice/ocr")
|
||||
platform: Optional[str] = Field(None, description="Trading platform (e.g., MT5, TradingView)")
|
||||
notes: Optional[str] = Field(None, description="Trade notes or voice transcription")
|
||||
entry_time: Optional[str] = Field(None, description="Custom entry time (ISO format)")
|
||||
|
||||
# OCR/Voice metadata
|
||||
image_data: Optional[str] = Field(None, description="Base64 encoded screenshot for OCR")
|
||||
voice_data: Optional[str] = Field(None, description="Voice memo data")
|
||||
|
||||
# Pre-fill hints
|
||||
use_last_trade_defaults: bool = Field(True, description="Auto-fill from last trade")
|
||||
apply_smart_guards: bool = Field(True, description="Apply AI-suggested guards")
|
||||
|
||||
|
||||
class SmartTradeResponse(BaseModel):
|
||||
"""Response with executed trade and suggestions"""
|
||||
trade_id: int
|
||||
action: str
|
||||
symbol: str
|
||||
quantity: float
|
||||
price: float
|
||||
stop_loss: Optional[float]
|
||||
take_profit: Optional[float]
|
||||
risk_percent: Optional[float]
|
||||
|
||||
# Execution details
|
||||
source: str
|
||||
executed_at: str
|
||||
total_cost: float
|
||||
|
||||
# Smart suggestions applied
|
||||
guards_applied: bool
|
||||
guards_suggested: Optional[Dict] = None
|
||||
prefill_used: bool
|
||||
|
||||
# Position state after trade
|
||||
remaining_cash: float
|
||||
total_equity: float
|
||||
position_size: Optional[float]
|
||||
unrealized_pnl: Optional[float]
|
||||
|
||||
|
||||
class SmartPreFillResponse(BaseModel):
|
||||
"""Pre-fill suggestions for trade entry"""
|
||||
symbol: str
|
||||
suggested_quantity: float
|
||||
current_price: float
|
||||
suggested_guards: Dict
|
||||
last_trade_context: Optional[Dict]
|
||||
market_context: Dict
|
||||
confidence: float
|
||||
|
||||
|
||||
class SmartGuardSuggestion(BaseModel):
|
||||
"""AI-suggested risk guards"""
|
||||
stop_loss_price: float
|
||||
stop_loss_percent: float
|
||||
take_profit_price: float
|
||||
take_profit_percent: float
|
||||
risk_percent: float
|
||||
position_size: float
|
||||
risk_reward_ratio: float
|
||||
reasoning: str
|
||||
confidence: float
|
||||
|
||||
|
||||
def _get_current_market_price(symbol: str) -> float:
|
||||
"""Get current market price from price anchor service"""
|
||||
try:
|
||||
anchor_price = price_anchor_service.get_anchor_price_sync(symbol.upper().replace("/", ""))
|
||||
if anchor_price and anchor_price > 0:
|
||||
return anchor_price
|
||||
except:
|
||||
pass
|
||||
|
||||
# Fallback to a reasonable default for XAU/USD
|
||||
return 2034.0
|
||||
|
||||
|
||||
def _compute_equity(state: Dict[str, Any], price_hint: Optional[float] = None) -> float:
|
||||
"""Compute total equity using cash and current position."""
|
||||
cash = float(state.get("cash", 0.0) or 0.0)
|
||||
position = state.get("position") or {}
|
||||
|
||||
if position:
|
||||
current_price = price_hint or position.get("current_price") or position.get("avg_price") or 0.0
|
||||
quantity = position.get("quantity", 0.0) or 0.0
|
||||
cash += float(quantity) * float(current_price)
|
||||
|
||||
return cash
|
||||
|
||||
|
||||
def _get_last_trade_defaults(state: Dict[str, Any]) -> Optional[Dict]:
|
||||
"""Get defaults from the last trade"""
|
||||
trades = state.get("trades", [])
|
||||
if not trades:
|
||||
return None
|
||||
|
||||
last_trade = trades[-1]
|
||||
return {
|
||||
"quantity": last_trade.get("quantity"),
|
||||
"symbol": last_trade.get("symbol", "XAU/USD"),
|
||||
"platform": last_trade.get("platform"),
|
||||
"stop_loss": last_trade.get("stop_loss"),
|
||||
"take_profit": last_trade.get("take_profit"),
|
||||
"risk_percent": last_trade.get("risk_percent"),
|
||||
}
|
||||
|
||||
|
||||
def _calculate_smart_guards(
|
||||
symbol: str,
|
||||
action: str,
|
||||
price: float,
|
||||
quantity: float,
|
||||
equity: float
|
||||
) -> SmartGuardSuggestion:
|
||||
"""
|
||||
Calculate optimal stop loss and take profit using ATR and risk management principles
|
||||
"""
|
||||
try:
|
||||
# Get market metrics including ATR
|
||||
ctx = ai_context_builder.build_request(
|
||||
symbol.upper().replace("/", ""),
|
||||
"1h", # Use hourly for guard calculation
|
||||
100
|
||||
)
|
||||
metrics = ai_context_builder.build_metrics(
|
||||
symbol.upper().replace("/", ""),
|
||||
"1h",
|
||||
ctx.price_data
|
||||
)
|
||||
|
||||
# Extract ATR value
|
||||
atr = metrics.atr_14 if hasattr(metrics, 'atr_14') else (price * 0.015) # Default to 1.5%
|
||||
|
||||
# Calculate stop loss (1.5x ATR from entry)
|
||||
sl_distance = atr * 1.5
|
||||
sl_percent = (sl_distance / price) * 100
|
||||
|
||||
# Calculate take profit (2x stop loss for 1:2 risk/reward minimum)
|
||||
tp_distance = sl_distance * 2.0
|
||||
tp_percent = (tp_distance / price) * 100
|
||||
|
||||
if action == "BUY":
|
||||
sl_price = price - sl_distance
|
||||
tp_price = price + tp_distance
|
||||
else: # SELL
|
||||
sl_price = price + sl_distance
|
||||
tp_price = price - tp_distance
|
||||
|
||||
# Calculate position risk as % of equity
|
||||
risk_amount = quantity * sl_distance
|
||||
risk_percent = (risk_amount / equity) * 100
|
||||
|
||||
# Ensure risk doesn't exceed 2% of equity (conservative default)
|
||||
if risk_percent > 2.0:
|
||||
# Adjust quantity to maintain 2% risk
|
||||
adjusted_quantity = (equity * 0.02) / sl_distance
|
||||
risk_percent = 2.0
|
||||
else:
|
||||
adjusted_quantity = quantity
|
||||
|
||||
return SmartGuardSuggestion(
|
||||
stop_loss_price=round(sl_price, 2),
|
||||
stop_loss_percent=round(sl_percent, 2),
|
||||
take_profit_price=round(tp_price, 2),
|
||||
take_profit_percent=round(tp_percent, 2),
|
||||
risk_percent=round(risk_percent, 2),
|
||||
position_size=round(adjusted_quantity, 2),
|
||||
risk_reward_ratio=2.0,
|
||||
reasoning=f"ATR-based guards: {atr:.2f} | 1.5x ATR stop | 1:2 R:R ratio | Max 2% risk",
|
||||
confidence=0.85
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Fallback to simple percentage-based guards
|
||||
sl_percent = 2.0
|
||||
tp_percent = 4.0
|
||||
|
||||
if action == "BUY":
|
||||
sl_price = price * (1 - sl_percent / 100)
|
||||
tp_price = price * (1 + tp_percent / 100)
|
||||
else:
|
||||
sl_price = price * (1 + sl_percent / 100)
|
||||
tp_price = price * (1 - tp_percent / 100)
|
||||
|
||||
risk_amount = quantity * price * (sl_percent / 100)
|
||||
risk_percent = (risk_amount / equity) * 100
|
||||
|
||||
return SmartGuardSuggestion(
|
||||
stop_loss_price=round(sl_price, 2),
|
||||
stop_loss_percent=round(sl_percent, 2),
|
||||
take_profit_price=round(tp_price, 2),
|
||||
take_profit_percent=round(tp_percent, 2),
|
||||
risk_percent=round(risk_percent, 2),
|
||||
position_size=quantity,
|
||||
risk_reward_ratio=2.0,
|
||||
reasoning="Fallback guards: 2% stop loss | 4% take profit | 1:2 ratio",
|
||||
confidence=0.60
|
||||
)
|
||||
|
||||
|
||||
@router.post("/prefill", response_model=SmartPreFillResponse)
|
||||
async def get_smart_prefill(
|
||||
symbol: str = Query("XAU/USD"),
|
||||
action: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
user_id: str = "default",
|
||||
) -> SmartPreFillResponse:
|
||||
"""Get smart pre-fill suggestions based on last trade and current market context."""
|
||||
try:
|
||||
current_price = _get_current_market_price(symbol)
|
||||
state = load_simulation_state(db, user_id)
|
||||
last_trade = _get_last_trade_defaults(state)
|
||||
|
||||
suggested_quantity = 1.0
|
||||
if last_trade and last_trade.get("quantity"):
|
||||
suggested_quantity = last_trade["quantity"]
|
||||
|
||||
equity = _compute_equity(state, price_hint=current_price)
|
||||
trade_action = (action or "BUY").upper()
|
||||
guards = _calculate_smart_guards(
|
||||
symbol,
|
||||
trade_action,
|
||||
current_price,
|
||||
suggested_quantity,
|
||||
equity,
|
||||
)
|
||||
|
||||
market_context = {
|
||||
"current_price": current_price,
|
||||
"equity": equity,
|
||||
"cash": state.get("cash", 0.0),
|
||||
"position": state.get("position"),
|
||||
}
|
||||
|
||||
return SmartPreFillResponse(
|
||||
symbol=symbol,
|
||||
suggested_quantity=suggested_quantity,
|
||||
current_price=current_price,
|
||||
suggested_guards={
|
||||
"stop_loss": guards.stop_loss_price,
|
||||
"take_profit": guards.take_profit_price,
|
||||
"risk_percent": guards.risk_percent,
|
||||
"reasoning": guards.reasoning,
|
||||
"confidence": guards.confidence,
|
||||
},
|
||||
last_trade_context=last_trade,
|
||||
market_context=market_context,
|
||||
confidence=0.80,
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to generate pre-fill suggestions: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/execute", response_model=SmartTradeResponse)
|
||||
async def execute_smart_trade(
|
||||
request: SmartTradeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user_id: str = "default",
|
||||
) -> SmartTradeResponse:
|
||||
"""Execute a trade through the unified smart trade hub using the persistent state."""
|
||||
try:
|
||||
source = request.source or TradeSource.MANUAL
|
||||
if request.image_data:
|
||||
source = TradeSource.OCR
|
||||
elif request.voice_data:
|
||||
source = TradeSource.VOICE
|
||||
|
||||
price = request.price or _get_current_market_price(request.symbol)
|
||||
state = load_simulation_state(db, user_id)
|
||||
|
||||
last_trade = _get_last_trade_defaults(state) if request.use_last_trade_defaults else None
|
||||
quantity = request.quantity
|
||||
if quantity is None:
|
||||
if last_trade and last_trade.get("quantity"):
|
||||
quantity = last_trade["quantity"]
|
||||
else:
|
||||
quantity = 1.0
|
||||
|
||||
equity = _compute_equity(state, price_hint=price)
|
||||
|
||||
guards_applied = False
|
||||
guards_suggested: Optional[Dict[str, Any]] = None
|
||||
guards: Optional[SmartGuardSuggestion] = None
|
||||
|
||||
if request.apply_smart_guards:
|
||||
guards = _calculate_smart_guards(
|
||||
request.symbol,
|
||||
request.action,
|
||||
price,
|
||||
quantity,
|
||||
equity,
|
||||
)
|
||||
|
||||
if request.stop_loss is None:
|
||||
request.stop_loss = guards.stop_loss_price
|
||||
guards_applied = True
|
||||
|
||||
if request.take_profit is None:
|
||||
request.take_profit = guards.take_profit_price
|
||||
guards_applied = True
|
||||
|
||||
if request.risk_percent is None:
|
||||
request.risk_percent = guards.risk_percent
|
||||
guards_applied = True
|
||||
|
||||
if guards.position_size != quantity:
|
||||
quantity = guards.position_size
|
||||
guards_applied = True
|
||||
|
||||
guards_suggested = guards.model_dump()
|
||||
|
||||
if request.action == "CLOSE":
|
||||
position = state.get("position")
|
||||
if not position:
|
||||
raise HTTPException(status_code=400, detail="No position to close")
|
||||
|
||||
request.action = "SELL"
|
||||
quantity = position.get("quantity", 0.0) or 0.0
|
||||
|
||||
try:
|
||||
validate_order(state, request.action, quantity, price)
|
||||
except ValueError as ve:
|
||||
raise HTTPException(status_code=400, detail=str(ve))
|
||||
|
||||
persistent_request = PersistentTradeRequest(
|
||||
action=request.action,
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
symbol=request.symbol,
|
||||
notes=request.notes,
|
||||
stop_loss=request.stop_loss,
|
||||
take_profit=request.take_profit,
|
||||
source=source,
|
||||
platform=request.platform,
|
||||
risk_percent=request.risk_percent,
|
||||
entry_time=request.entry_time,
|
||||
)
|
||||
|
||||
result = await persistent_execute_trade(persistent_request, db=db, user_id=user_id)
|
||||
trade_info = result["trade"]
|
||||
portfolio = result["portfolio"]
|
||||
|
||||
total_cost = trade_info.get("total", quantity * price)
|
||||
executed_ts = trade_info.get("timestamp")
|
||||
executed_at = (
|
||||
datetime.fromtimestamp(executed_ts, tz=timezone.utc).isoformat()
|
||||
if executed_ts
|
||||
else datetime.now(timezone.utc).isoformat()
|
||||
)
|
||||
|
||||
position_after = portfolio.get("position") or {}
|
||||
position_size = position_after.get("quantity")
|
||||
unrealized_pnl = position_after.get("unrealized_pnl")
|
||||
total_equity = _compute_equity(portfolio, price_hint=price)
|
||||
|
||||
return SmartTradeResponse(
|
||||
trade_id=trade_info["id"],
|
||||
action=trade_info["action"],
|
||||
symbol=request.symbol,
|
||||
quantity=trade_info["quantity"],
|
||||
price=trade_info["price"],
|
||||
stop_loss=trade_info.get("stop_loss"),
|
||||
take_profit=trade_info.get("take_profit"),
|
||||
risk_percent=trade_info.get("risk_percent"),
|
||||
source=source,
|
||||
executed_at=executed_at,
|
||||
total_cost=total_cost,
|
||||
guards_applied=guards_applied,
|
||||
guards_suggested=guards_suggested,
|
||||
prefill_used=request.use_last_trade_defaults,
|
||||
remaining_cash=portfolio.get("cash", 0.0),
|
||||
total_equity=total_equity,
|
||||
position_size=position_size,
|
||||
unrealized_pnl=unrealized_pnl,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to execute smart trade: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/suggestions", response_model=SmartGuardSuggestion)
|
||||
async def get_guard_suggestions(
|
||||
symbol: str = Query("XAU/USD"),
|
||||
action: str = Query("BUY"),
|
||||
quantity: float = Query(1.0),
|
||||
price: Optional[float] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
user_id: str = "default",
|
||||
) -> SmartGuardSuggestion:
|
||||
"""Get AI-suggested stop loss and take profit guards using persistent state."""
|
||||
try:
|
||||
if price is None:
|
||||
price = _get_current_market_price(symbol)
|
||||
|
||||
state = load_simulation_state(db, user_id)
|
||||
equity = _compute_equity(state, price_hint=price)
|
||||
|
||||
return _calculate_smart_guards(symbol, action, price, quantity, equity)
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to calculate guard suggestions: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_trade_history(
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
source: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
user_id: str = "default",
|
||||
) -> Dict:
|
||||
"""Get trade history with optional source filtering from persisted trades."""
|
||||
try:
|
||||
state = load_simulation_state(db, user_id)
|
||||
trades = state.get("trades", [])
|
||||
|
||||
if source:
|
||||
trades = [t for t in trades if t.get("source") == source]
|
||||
|
||||
trades = trades[-limit:]
|
||||
|
||||
sources = {
|
||||
(t.get("source") or "unknown")
|
||||
for t in state.get("trades", [])
|
||||
}
|
||||
|
||||
return {
|
||||
"trades": trades,
|
||||
"total": len(trades),
|
||||
"sources": sorted(sources),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to retrieve trade history: {str(e)}",
|
||||
)
|
||||
@@ -0,0 +1,434 @@
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from sqlalchemy.orm import Session, selectinload
|
||||
from typing import Dict, Optional, Any, List
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.db.database import get_db
|
||||
from app.models.models import Simulation, Trade, Position, TradeAction, TradeMetadata
|
||||
from app.services.risk import validate_order
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/trading", tags=["Trading"])
|
||||
|
||||
|
||||
# Pydantic models for request/response
|
||||
class TradeRequest(BaseModel):
|
||||
action: str
|
||||
quantity: float
|
||||
price: float
|
||||
symbol: str = "XAU/USD"
|
||||
notes: Optional[str] = None
|
||||
stop_loss: Optional[float] = None
|
||||
take_profit: Optional[float] = None
|
||||
source: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
risk_percent: Optional[float] = None
|
||||
entry_time: Optional[str] = None
|
||||
|
||||
|
||||
class PortfolioState(BaseModel):
|
||||
cash: float
|
||||
initial_capital: float
|
||||
position: Optional[Dict[str, Any]] = None
|
||||
trades: List[Dict[str, Any]]
|
||||
equity_history: List[Dict[str, Any]]
|
||||
total_pnl: float
|
||||
total_pnl_percent: float
|
||||
|
||||
|
||||
def get_or_create_simulation(db: Session, user_id: str = "default") -> Simulation:
|
||||
"""Get existing simulation or create a new one"""
|
||||
simulation = db.query(Simulation).filter(Simulation.user_id == user_id).first()
|
||||
|
||||
if not simulation:
|
||||
simulation = Simulation(
|
||||
user_id=user_id,
|
||||
symbol="XAU/USD",
|
||||
initial_capital=100000.0,
|
||||
current_capital=100000.0,
|
||||
total_pnl=0.0,
|
||||
total_pnl_percent=0.0
|
||||
)
|
||||
db.add(simulation)
|
||||
db.commit()
|
||||
db.refresh(simulation)
|
||||
|
||||
return simulation
|
||||
|
||||
|
||||
def _compute_equity_at_price(simulation: Simulation, price: float, db: Session) -> float:
|
||||
"""Calculate equity based on current position and price"""
|
||||
position = db.query(Position).filter(
|
||||
Position.simulation_id == simulation.id
|
||||
).first()
|
||||
|
||||
qty = position.quantity if position else 0.0
|
||||
return float(simulation.current_capital + qty * price)
|
||||
|
||||
|
||||
def get_portfolio_state_from_db(simulation: Simulation, db: Session) -> PortfolioState:
|
||||
"""Convert DB simulation to portfolio state"""
|
||||
# Get current position
|
||||
position = db.query(Position).filter(
|
||||
Position.simulation_id == simulation.id
|
||||
).first()
|
||||
|
||||
position_dict = None
|
||||
if position:
|
||||
position_dict = {
|
||||
"symbol": position.symbol,
|
||||
"quantity": position.quantity,
|
||||
"avg_price": position.avg_price,
|
||||
"current_price": position.current_price,
|
||||
"unrealized_pnl": position.unrealized_pnl,
|
||||
"unrealized_pnl_percent": position.unrealized_pnl_percent
|
||||
}
|
||||
|
||||
# Get all trades
|
||||
trades = db.query(Trade).options(selectinload(Trade.details)).filter(
|
||||
Trade.simulation_id == simulation.id
|
||||
).order_by(Trade.timestamp).all()
|
||||
|
||||
trades_list = []
|
||||
for trade in trades:
|
||||
details = trade.details
|
||||
trades_list.append({
|
||||
"id": trade.id,
|
||||
"action": trade.action.value,
|
||||
"quantity": trade.quantity,
|
||||
"price": trade.price,
|
||||
"total": trade.total,
|
||||
"pnl": trade.pnl,
|
||||
"timestamp": int(trade.timestamp.timestamp()) if trade.timestamp else None,
|
||||
"stop_loss": details.stop_loss if details else None,
|
||||
"take_profit": details.take_profit if details else None,
|
||||
"notes": details.notes if details else None,
|
||||
"source": details.source if details else None,
|
||||
"platform": details.platform if details else None,
|
||||
"risk_percent": details.risk_percent if details else None,
|
||||
"entry_time": details.entry_time.isoformat() if details and details.entry_time else None,
|
||||
})
|
||||
|
||||
# Build equity history from trades
|
||||
equity_history = []
|
||||
running_equity = simulation.initial_capital
|
||||
for trade in trades:
|
||||
if trade.action == TradeAction.SELL and trade.pnl:
|
||||
running_equity += trade.pnl
|
||||
equity_history.append({
|
||||
"time": int(trade.timestamp.timestamp()) if trade.timestamp else 0,
|
||||
"equity": running_equity
|
||||
})
|
||||
|
||||
return PortfolioState(
|
||||
cash=simulation.current_capital,
|
||||
initial_capital=simulation.initial_capital,
|
||||
position=position_dict,
|
||||
trades=trades_list,
|
||||
equity_history=equity_history,
|
||||
total_pnl=simulation.total_pnl,
|
||||
total_pnl_percent=simulation.total_pnl_percent
|
||||
)
|
||||
|
||||
|
||||
@router.post("/execute")
|
||||
async def execute_trade(
|
||||
trade_request: TradeRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user_id: str = "default"
|
||||
):
|
||||
"""
|
||||
Execute a trade and persist to database.
|
||||
|
||||
- Validates risk rules
|
||||
- Updates cash/position in DB
|
||||
- Records trade with timestamp
|
||||
- Returns updated portfolio state
|
||||
"""
|
||||
try:
|
||||
action = trade_request.action.upper()
|
||||
quantity = trade_request.quantity
|
||||
price = trade_request.price
|
||||
|
||||
if action not in ["BUY", "SELL"]:
|
||||
raise HTTPException(status_code=400, detail="Action must be BUY or SELL")
|
||||
|
||||
# Get or create simulation
|
||||
simulation = get_or_create_simulation(db, user_id)
|
||||
|
||||
# Build state dict for risk validation
|
||||
position = db.query(Position).filter(
|
||||
Position.simulation_id == simulation.id
|
||||
).first()
|
||||
|
||||
state_dict = {
|
||||
"cash": simulation.current_capital,
|
||||
"position": {
|
||||
"quantity": position.quantity,
|
||||
"avg_price": position.avg_price
|
||||
} if position else None
|
||||
}
|
||||
|
||||
# Risk validation
|
||||
try:
|
||||
validate_order(state_dict, action, quantity, price)
|
||||
except ValueError as ve:
|
||||
raise HTTPException(status_code=400, detail=str(ve))
|
||||
|
||||
total = quantity * price
|
||||
pnl = None
|
||||
|
||||
if action == "BUY":
|
||||
if total > simulation.current_capital:
|
||||
raise HTTPException(status_code=400, detail="Insufficient funds")
|
||||
|
||||
simulation.current_capital -= total
|
||||
|
||||
if not position:
|
||||
# Create new position
|
||||
position = Position(
|
||||
simulation_id=simulation.id,
|
||||
symbol=trade_request.symbol,
|
||||
quantity=quantity,
|
||||
avg_price=price,
|
||||
current_price=price,
|
||||
unrealized_pnl=0.0,
|
||||
unrealized_pnl_percent=0.0
|
||||
)
|
||||
db.add(position)
|
||||
else:
|
||||
# Update existing position (average up)
|
||||
new_qty = position.quantity + quantity
|
||||
new_avg = (position.avg_price * position.quantity + price * quantity) / new_qty
|
||||
position.quantity = new_qty
|
||||
position.avg_price = new_avg
|
||||
position.current_price = price
|
||||
|
||||
elif action == "SELL":
|
||||
if not position or quantity > position.quantity:
|
||||
raise HTTPException(status_code=400, detail="Insufficient position")
|
||||
|
||||
simulation.current_capital += total
|
||||
pnl = (price - position.avg_price) * quantity
|
||||
|
||||
# Update simulation totals
|
||||
simulation.total_pnl += pnl
|
||||
if simulation.initial_capital > 0:
|
||||
simulation.total_pnl_percent = (simulation.total_pnl / simulation.initial_capital) * 100
|
||||
|
||||
position.quantity -= quantity
|
||||
|
||||
if position.quantity == 0:
|
||||
# Close position
|
||||
db.delete(position)
|
||||
position = None
|
||||
else:
|
||||
position.current_price = price
|
||||
|
||||
# Create trade record
|
||||
trade_timestamp = datetime.now(timezone.utc)
|
||||
trade = Trade(
|
||||
simulation_id=simulation.id,
|
||||
action=TradeAction[action],
|
||||
quantity=quantity,
|
||||
price=price,
|
||||
total=total,
|
||||
pnl=pnl,
|
||||
timestamp=trade_timestamp
|
||||
)
|
||||
db.add(trade)
|
||||
db.flush()
|
||||
|
||||
entry_time_dt = None
|
||||
if trade_request.entry_time:
|
||||
try:
|
||||
entry_time_dt = datetime.fromisoformat(trade_request.entry_time)
|
||||
if entry_time_dt.tzinfo is None:
|
||||
entry_time_dt = entry_time_dt.replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
entry_time_dt = trade_timestamp
|
||||
|
||||
metadata_fields = (
|
||||
trade_request.source,
|
||||
trade_request.platform,
|
||||
trade_request.notes,
|
||||
trade_request.stop_loss,
|
||||
trade_request.take_profit,
|
||||
trade_request.risk_percent,
|
||||
entry_time_dt,
|
||||
)
|
||||
|
||||
if any(field is not None for field in metadata_fields):
|
||||
trade_metadata = TradeMetadata(
|
||||
trade_id=trade.id,
|
||||
source=trade_request.source,
|
||||
platform=trade_request.platform,
|
||||
notes=trade_request.notes,
|
||||
stop_loss=trade_request.stop_loss,
|
||||
take_profit=trade_request.take_profit,
|
||||
risk_percent=trade_request.risk_percent,
|
||||
entry_time=entry_time_dt,
|
||||
)
|
||||
db.add(trade_metadata)
|
||||
|
||||
# Commit all changes
|
||||
db.commit()
|
||||
db.refresh(simulation)
|
||||
|
||||
# Return updated portfolio state
|
||||
portfolio_state = get_portfolio_state_from_db(simulation, db)
|
||||
|
||||
return {
|
||||
"trade": {
|
||||
"id": trade.id,
|
||||
"action": action,
|
||||
"quantity": quantity,
|
||||
"price": price,
|
||||
"total": total,
|
||||
"pnl": pnl,
|
||||
"timestamp": int(trade.timestamp.timestamp()) if trade.timestamp else None,
|
||||
"stop_loss": trade_request.stop_loss,
|
||||
"take_profit": trade_request.take_profit,
|
||||
"notes": trade_request.notes,
|
||||
"source": trade_request.source,
|
||||
"platform": trade_request.platform,
|
||||
"risk_percent": trade_request.risk_percent,
|
||||
"entry_time": entry_time_dt.isoformat() if entry_time_dt else None,
|
||||
},
|
||||
"portfolio": portfolio_state.dict()
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Trade execution failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/portfolio")
|
||||
async def get_portfolio(
|
||||
db: Session = Depends(get_db),
|
||||
user_id: str = "default"
|
||||
):
|
||||
"""Get current portfolio state from database"""
|
||||
try:
|
||||
simulation = get_or_create_simulation(db, user_id)
|
||||
portfolio_state = get_portfolio_state_from_db(simulation, db)
|
||||
return portfolio_state.dict()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get portfolio: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset_simulation(
|
||||
db: Session = Depends(get_db),
|
||||
user_id: str = "default"
|
||||
):
|
||||
"""Reset simulation to initial state"""
|
||||
try:
|
||||
simulation = db.query(Simulation).filter(Simulation.user_id == user_id).first()
|
||||
|
||||
if simulation:
|
||||
# Delete all trades and positions (cascade will handle this)
|
||||
db.delete(simulation)
|
||||
db.commit()
|
||||
|
||||
# Create new simulation
|
||||
new_simulation = Simulation(
|
||||
user_id=user_id,
|
||||
symbol="XAU/USD",
|
||||
initial_capital=100000.0,
|
||||
current_capital=100000.0,
|
||||
total_pnl=0.0,
|
||||
total_pnl_percent=0.0
|
||||
)
|
||||
db.add(new_simulation)
|
||||
db.commit()
|
||||
db.refresh(new_simulation)
|
||||
|
||||
portfolio_state = get_portfolio_state_from_db(new_simulation, db)
|
||||
|
||||
return {
|
||||
"message": "Simulation reset successfully",
|
||||
"portfolio": portfolio_state.dict()
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Reset failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_trade_history(
|
||||
db: Session = Depends(get_db),
|
||||
user_id: str = "default",
|
||||
limit: int = 100
|
||||
):
|
||||
"""Get trade history from database"""
|
||||
try:
|
||||
simulation = get_or_create_simulation(db, user_id)
|
||||
|
||||
trades = db.query(Trade).options(selectinload(Trade.details)).filter(
|
||||
Trade.simulation_id == simulation.id
|
||||
).order_by(Trade.timestamp.desc()).limit(limit).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": trade.id,
|
||||
"action": trade.action.value,
|
||||
"quantity": trade.quantity,
|
||||
"price": trade.price,
|
||||
"total": trade.total,
|
||||
"pnl": trade.pnl,
|
||||
"timestamp": int(trade.timestamp.timestamp()) if trade.timestamp else None,
|
||||
"stop_loss": trade.details.stop_loss if trade.details else None,
|
||||
"take_profit": trade.details.take_profit if trade.details else None,
|
||||
"notes": trade.details.notes if trade.details else None,
|
||||
"source": trade.details.source if trade.details else None,
|
||||
"platform": trade.details.platform if trade.details else None,
|
||||
"risk_percent": trade.details.risk_percent if trade.details else None,
|
||||
"entry_time": trade.details.entry_time.isoformat() if trade.details and trade.details.entry_time else None,
|
||||
}
|
||||
for trade in trades
|
||||
]
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get history: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_trading_stats(
|
||||
db: Session = Depends(get_db),
|
||||
user_id: str = "default"
|
||||
):
|
||||
"""Get trading statistics"""
|
||||
try:
|
||||
simulation = get_or_create_simulation(db, user_id)
|
||||
|
||||
trades = db.query(Trade).filter(
|
||||
Trade.simulation_id == simulation.id
|
||||
).all()
|
||||
|
||||
total_trades = len(trades)
|
||||
winning_trades = sum(1 for t in trades if t.pnl and t.pnl > 0)
|
||||
losing_trades = sum(1 for t in trades if t.pnl and t.pnl < 0)
|
||||
|
||||
total_profit = sum(t.pnl for t in trades if t.pnl and t.pnl > 0)
|
||||
total_loss = sum(abs(t.pnl) for t in trades if t.pnl and t.pnl < 0)
|
||||
|
||||
win_rate = (winning_trades / total_trades * 100) if total_trades > 0 else 0
|
||||
profit_factor = (total_profit / total_loss) if total_loss > 0 else 0
|
||||
|
||||
return {
|
||||
"total_trades": total_trades,
|
||||
"winning_trades": winning_trades,
|
||||
"losing_trades": losing_trades,
|
||||
"win_rate": round(win_rate, 2),
|
||||
"total_pnl": simulation.total_pnl,
|
||||
"total_pnl_percent": simulation.total_pnl_percent,
|
||||
"total_profit": total_profit,
|
||||
"total_loss": total_loss,
|
||||
"profit_factor": round(profit_factor, 2),
|
||||
"current_capital": simulation.current_capital,
|
||||
"initial_capital": simulation.initial_capital
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get stats: {str(e)}")
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Trading Schools API
|
||||
Endpoints for accessing trading methodologies, strategies, and plan templates
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from typing import Optional, List
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services.trading_schools import trading_schools, TradingSchool
|
||||
from app.services.plan_templates import plan_templates, PlanType, MarketCondition
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/trading-schools", tags=["Trading Schools"])
|
||||
|
||||
|
||||
# Pydantic Models
|
||||
class TradingSchoolInfo(BaseModel):
|
||||
"""Trading school information"""
|
||||
school: str
|
||||
name: str
|
||||
description: str
|
||||
key_concepts: List[str]
|
||||
timeframes: List[str]
|
||||
indicators: List[str]
|
||||
best_for: List[str]
|
||||
|
||||
|
||||
class GeneratePlanRequest(BaseModel):
|
||||
"""Request to generate a trading plan"""
|
||||
methodology: str # ict_smc, wyckoff, multi_confluence, etc.
|
||||
current_price: float
|
||||
market_condition: Optional[str] = "trending_up"
|
||||
session: Optional[str] = "london_ny"
|
||||
risk_tolerance: Optional[str] = "moderate"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TRADING SCHOOLS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/list")
|
||||
async def get_all_trading_schools():
|
||||
"""Get list of all available trading schools and methodologies"""
|
||||
schools = trading_schools.get_all_schools()
|
||||
|
||||
return {
|
||||
"total_schools": len(schools),
|
||||
"schools": list(schools.keys()),
|
||||
"schools_detail": schools,
|
||||
"description": "Comprehensive collection of trading methodologies"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/school/{school_name}")
|
||||
async def get_school_details(school_name: str):
|
||||
"""Get detailed information about a specific trading school"""
|
||||
schools = trading_schools.get_all_schools()
|
||||
|
||||
if school_name not in schools:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"School '{school_name}' not found. Available schools: {list(schools.keys())}"
|
||||
)
|
||||
|
||||
return schools[school_name]
|
||||
|
||||
|
||||
@router.get("/combined-strategies")
|
||||
async def get_combined_strategies():
|
||||
"""Get hybrid strategies combining multiple trading schools"""
|
||||
strategies = trading_schools.get_combined_strategies()
|
||||
|
||||
return {
|
||||
"total_strategies": len(strategies),
|
||||
"strategies": strategies,
|
||||
"description": "Hybrid approaches combining multiple methodologies for higher probability setups"
|
||||
}
|
||||
|
||||
|
||||
@router.get("/indicator-presets")
|
||||
async def get_indicator_presets(school: Optional[str] = Query(None)):
|
||||
"""Get recommended indicator configurations for trading schools"""
|
||||
if school:
|
||||
preset = trading_schools.get_indicator_presets_for_school(TradingSchool(school))
|
||||
return {
|
||||
"school": school,
|
||||
"preset": preset
|
||||
}
|
||||
|
||||
# Get all presets
|
||||
all_presets = {}
|
||||
for s in TradingSchool:
|
||||
all_presets[s.value] = trading_schools.get_indicator_presets_for_school(s)
|
||||
|
||||
return {
|
||||
"total_schools": len(all_presets),
|
||||
"presets": all_presets
|
||||
}
|
||||
|
||||
|
||||
@router.get("/risk-models")
|
||||
async def get_risk_management_models():
|
||||
"""Get advanced risk management models and position sizing strategies"""
|
||||
models = trading_schools.get_risk_models()
|
||||
|
||||
return {
|
||||
"total_models": len(models),
|
||||
"models": models,
|
||||
"recommendation": "Use Fixed Fractional (1-2% per trade) for beginners, Kelly Criterion for advanced traders with proven edge"
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TRADING PLAN TEMPLATES ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/plan-types")
|
||||
async def get_plan_types():
|
||||
"""Get all available trading plan types"""
|
||||
types = plan_templates.get_all_plan_types()
|
||||
|
||||
return {
|
||||
"total_types": len(types),
|
||||
"plan_types": types,
|
||||
"description": "Pre-built trading plan templates for different methodologies"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/generate-plan")
|
||||
async def generate_trading_plan(request: GeneratePlanRequest):
|
||||
"""
|
||||
Generate a comprehensive trading plan based on selected methodology
|
||||
|
||||
Methodologies:
|
||||
- ict_smc: ICT / Smart Money Concepts
|
||||
- wyckoff: Wyckoff Method
|
||||
- multi_confluence: Multi-Method Confluence (ICT + Fib + S/D + PA)
|
||||
- session_trading: London/NY Session-Based Trading
|
||||
"""
|
||||
try:
|
||||
# Validate market condition
|
||||
try:
|
||||
market_cond = MarketCondition(request.market_condition)
|
||||
except ValueError:
|
||||
market_cond = MarketCondition.TRENDING_UP
|
||||
|
||||
# Generate plan based on methodology
|
||||
if request.methodology == "ict_smc":
|
||||
plan = plan_templates.generate_ict_smc_plan(
|
||||
current_price=request.current_price,
|
||||
market_condition=market_cond,
|
||||
session=request.session or "london_ny"
|
||||
)
|
||||
elif request.methodology == "wyckoff":
|
||||
plan = plan_templates.generate_wyckoff_plan(
|
||||
current_price=request.current_price,
|
||||
market_condition=market_cond
|
||||
)
|
||||
elif request.methodology == "multi_confluence":
|
||||
plan = plan_templates.generate_multi_method_confluence_plan(
|
||||
current_price=request.current_price,
|
||||
market_condition=market_cond
|
||||
)
|
||||
elif request.methodology == "session_trading":
|
||||
plan = plan_templates.generate_session_based_plan(
|
||||
current_price=request.current_price,
|
||||
target_session=request.session or "london_ny_overlap"
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown methodology: {request.methodology}. Use: ict_smc, wyckoff, multi_confluence, or session_trading"
|
||||
)
|
||||
|
||||
return {
|
||||
"methodology": request.methodology,
|
||||
"current_price": request.current_price,
|
||||
"market_condition": request.market_condition,
|
||||
"plan": plan,
|
||||
"generated_at": "now"
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/quick-reference/{school}")
|
||||
async def get_quick_reference(school: str):
|
||||
"""Get a quick reference guide for a specific trading school"""
|
||||
schools = trading_schools.get_all_schools()
|
||||
|
||||
if school not in schools:
|
||||
raise HTTPException(status_code=404, detail=f"School '{school}' not found")
|
||||
|
||||
school_data = schools[school]
|
||||
|
||||
# Create quick reference
|
||||
quick_ref = {
|
||||
"name": school_data["name"],
|
||||
"school_type": school_data["school"],
|
||||
"elevator_pitch": school_data["description"],
|
||||
"key_concepts": school_data["key_concepts"][:5], # Top 5
|
||||
"timeframes": school_data["timeframes"],
|
||||
"best_for": school_data["best_for"],
|
||||
"one_sentence_summary": _get_one_liner(school)
|
||||
}
|
||||
|
||||
if "entry_criteria" in school_data:
|
||||
quick_ref["how_to_trade"] = school_data["entry_criteria"]
|
||||
|
||||
if "risk_management" in school_data:
|
||||
quick_ref["risk_management"] = school_data["risk_management"]
|
||||
|
||||
return quick_ref
|
||||
|
||||
|
||||
@router.get("/comparison")
|
||||
async def compare_trading_schools(
|
||||
schools_list: str = Query(..., description="Comma-separated list of schools to compare, e.g., ict_smc,wyckoff,price_action")
|
||||
):
|
||||
"""Compare multiple trading schools side by side"""
|
||||
school_names = [s.strip() for s in schools_list.split(",")]
|
||||
schools_data = trading_schools.get_all_schools()
|
||||
|
||||
comparison = {}
|
||||
for school_name in school_names:
|
||||
if school_name not in schools_data:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"School '{school_name}' not found"
|
||||
)
|
||||
|
||||
data = schools_data[school_name]
|
||||
comparison[school_name] = {
|
||||
"name": data["name"],
|
||||
"description": data["description"],
|
||||
"timeframes": data["timeframes"],
|
||||
"indicators": data["indicators"],
|
||||
"best_for": data["best_for"],
|
||||
"complexity": _rate_complexity(school_name)
|
||||
}
|
||||
|
||||
return {
|
||||
"schools_compared": len(comparison),
|
||||
"comparison": comparison,
|
||||
"recommendation": _get_comparison_recommendation(school_names)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/learning-path")
|
||||
async def get_learning_path():
|
||||
"""Get recommended learning path for mastering different trading schools"""
|
||||
return {
|
||||
"beginner_path": {
|
||||
"level": "Beginner (0-6 months)",
|
||||
"schools": [
|
||||
{
|
||||
"order": 1,
|
||||
"school": "price_action",
|
||||
"name": "Price Action",
|
||||
"reason": "Foundation - Learn to read candles and basic S/R",
|
||||
"time_to_learn": "2-3 months"
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"school": "fibonacci_trading",
|
||||
"name": "Fibonacci Trading",
|
||||
"reason": "Simple tool, high applicability",
|
||||
"time_to_learn": "1 month"
|
||||
},
|
||||
{
|
||||
"order": 3,
|
||||
"school": "supply_demand",
|
||||
"name": "Supply & Demand Zones",
|
||||
"reason": "Logical, builds on S/R knowledge",
|
||||
"time_to_learn": "2 months"
|
||||
}
|
||||
],
|
||||
"practice": "Demo trade minimum 3 months before real money"
|
||||
},
|
||||
"intermediate_path": {
|
||||
"level": "Intermediate (6-18 months)",
|
||||
"schools": [
|
||||
{
|
||||
"order": 1,
|
||||
"school": "ict_smc",
|
||||
"name": "ICT / Smart Money Concepts",
|
||||
"reason": "Modern, powerful for gold/forex",
|
||||
"time_to_learn": "4-6 months"
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"school": "market_profile",
|
||||
"name": "Market Profile",
|
||||
"reason": "Understand volume and value",
|
||||
"time_to_learn": "3 months"
|
||||
},
|
||||
{
|
||||
"order": 3,
|
||||
"school": "multi_timeframe",
|
||||
"name": "Multi-Timeframe Analysis",
|
||||
"reason": "Combine skills, improve timing",
|
||||
"time_to_learn": "2 months"
|
||||
}
|
||||
],
|
||||
"practice": "Start combining methods, track statistics"
|
||||
},
|
||||
"advanced_path": {
|
||||
"level": "Advanced (18+ months)",
|
||||
"schools": [
|
||||
{
|
||||
"order": 1,
|
||||
"school": "wyckoff",
|
||||
"name": "Wyckoff Method",
|
||||
"reason": "Deep market understanding, institutional perspective",
|
||||
"time_to_learn": "6-12 months"
|
||||
},
|
||||
{
|
||||
"order": 2,
|
||||
"school": "elliott_wave",
|
||||
"name": "Elliott Wave Theory",
|
||||
"reason": "Complex but powerful for major moves",
|
||||
"time_to_learn": "6-12 months"
|
||||
},
|
||||
{
|
||||
"order": 3,
|
||||
"school": "order_flow",
|
||||
"name": "Order Flow Trading",
|
||||
"reason": "Real-time institutional activity",
|
||||
"time_to_learn": "3-6 months (requires specialized tools)"
|
||||
}
|
||||
],
|
||||
"practice": "Develop personal methodology combining multiple schools"
|
||||
},
|
||||
"professional_edge": {
|
||||
"level": "Professional",
|
||||
"approach": "Multi-Method Confluence",
|
||||
"description": "Combine 3-4 methodologies for maximum probability setups",
|
||||
"schools": ["ict_smc", "fibonacci_trading", "supply_demand", "price_action"],
|
||||
"goal": "Trade only highest-quality setups with 70%+ win rate",
|
||||
"frequency": "1-3 trades per week (quality over quantity)"
|
||||
},
|
||||
"general_advice": [
|
||||
"Master ONE school completely before moving to next",
|
||||
"Journal every trade and study every setup",
|
||||
"Backtest each methodology on historical data",
|
||||
"Paper trade new methods for 2-3 months minimum",
|
||||
"Don't skip fundamentals (Price Action first!)",
|
||||
"Find 1-2 mentors for each major methodology",
|
||||
"Join communities: ICT students, Wyckoff traders, etc.",
|
||||
"Most profitable traders use 2-3 methods maximum (confluence)"
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# HELPER FUNCTIONS
|
||||
# ============================================================================
|
||||
|
||||
def _get_one_liner(school: str) -> str:
|
||||
"""Get one-sentence summary of a trading school"""
|
||||
summaries = {
|
||||
"ict_smc": "Trade like institutions: Follow liquidity, FVGs, and order blocks during killzones.",
|
||||
"wyckoff": "Identify accumulation and distribution phases using volume to trade with smart money.",
|
||||
"elliott_wave": "Count wave structures and use Fibonacci to predict major market moves.",
|
||||
"market_profile": "Find value areas and trade price rejection from high/low volume nodes.",
|
||||
"order_flow": "Read real-time buying/selling pressure to anticipate institutional moves.",
|
||||
"price_action": "Trade pure price patterns at support/resistance without indicators.",
|
||||
"supply_demand": "Identify fresh zones of imbalance and trade rejections from these levels.",
|
||||
"fibonacci_trading": "Use golden ratio levels (0.618, 1.618) for entries and targets.",
|
||||
"gold_fundamental": "Trade gold based on USD strength, yields, inflation, and geopolitical factors.",
|
||||
"multi_timeframe": "Align multiple timeframes for high-probability entries with HTF targets.",
|
||||
"london_ny_session": "Trade gold during high-liquidity sessions (3-5 AM, 8-11 AM EST) for best moves."
|
||||
}
|
||||
return summaries.get(school, "A proven trading methodology.")
|
||||
|
||||
|
||||
def _rate_complexity(school: str) -> str:
|
||||
"""Rate the complexity of learning a trading school"""
|
||||
ratings = {
|
||||
"price_action": "Beginner",
|
||||
"fibonacci_trading": "Beginner",
|
||||
"supply_demand": "Beginner-Intermediate",
|
||||
"multi_timeframe": "Intermediate",
|
||||
"ict_smc": "Intermediate",
|
||||
"market_profile": "Intermediate-Advanced",
|
||||
"gold_fundamental": "Intermediate",
|
||||
"london_ny_session": "Intermediate",
|
||||
"wyckoff": "Advanced",
|
||||
"elliott_wave": "Advanced",
|
||||
"order_flow": "Advanced"
|
||||
}
|
||||
return ratings.get(school, "Intermediate")
|
||||
|
||||
|
||||
def _get_comparison_recommendation(schools: List[str]) -> str:
|
||||
"""Get recommendation based on schools being compared"""
|
||||
if len(schools) == 1:
|
||||
return f"Focus on mastering {schools[0]} before adding other methods."
|
||||
|
||||
if "ict_smc" in schools and "fibonacci_trading" in schools and "supply_demand" in schools:
|
||||
return "Excellent combination! These three methods work very well together for confluence trading."
|
||||
|
||||
if "wyckoff" in schools and any(s in schools for s in ["market_profile", "order_flow"]):
|
||||
return "Volume-based methods pair well. Focus on volume analysis across all methods."
|
||||
|
||||
if len(schools) > 4:
|
||||
return "⚠️ Too many methods. Focus on mastering 2-3 maximum to avoid analysis paralysis."
|
||||
|
||||
return "Good selection. Look for confluence zones where multiple methods confirm the same setup."
|
||||
@@ -0,0 +1,406 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from statistics import mean
|
||||
from typing import List, Dict, Optional, Iterable
|
||||
|
||||
from app.schemas.schemas import AIAnalysisRequest, PriceData, PositionMetrics
|
||||
from app.streaming.live_store import live_store
|
||||
from app.services.candlestick_patterns import candlestick_detector
|
||||
|
||||
|
||||
BB_LENGTH = 20
|
||||
BB_MULT = 2.0
|
||||
RSI_FAST_LENGTH = 3
|
||||
ZLSMA_LENGTH = 50
|
||||
CHAND_LENGTH = 22
|
||||
CHAND_MULT = 2.0
|
||||
MAX_PATTERN_SIGNALS = 10
|
||||
|
||||
|
||||
class AIContextBuilder:
|
||||
"""Builds enriched AIAnalysisRequest payloads from live store data."""
|
||||
|
||||
def __init__(self, default_symbol: str = "XAUUSD", default_timeframe: str = "1m") -> None:
|
||||
self.default_symbol = default_symbol
|
||||
self.default_timeframe = default_timeframe
|
||||
|
||||
def build_request(self, symbol: Optional[str] = None, timeframe: Optional[str] = None, limit: int = 400) -> AIAnalysisRequest:
|
||||
sym = (symbol or self.default_symbol).upper().replace("/", "")
|
||||
tf = timeframe or self.default_timeframe
|
||||
bars = self._load_bars(sym, tf, limit)
|
||||
if not bars:
|
||||
raise ValueError(f"No live data available for {sym} {tf}")
|
||||
|
||||
trimmed = bars[-limit:]
|
||||
price_data = [
|
||||
PriceData(
|
||||
time=int(row["time"]),
|
||||
open=float(row["open"]),
|
||||
high=float(row["high"]),
|
||||
low=float(row["low"]),
|
||||
close=float(row["close"]),
|
||||
volume=float(row.get("volume", 0.0)),
|
||||
)
|
||||
for row in trimmed
|
||||
]
|
||||
|
||||
indicators = self._build_indicators(trimmed)
|
||||
current_price = price_data[-1].close if price_data else float(trimmed[-1]["close"])
|
||||
|
||||
return AIAnalysisRequest(
|
||||
price_data=price_data,
|
||||
indicators=indicators,
|
||||
current_price=current_price,
|
||||
symbol=self._format_symbol(sym),
|
||||
timeframe=tf,
|
||||
)
|
||||
|
||||
def build_metrics(
|
||||
self,
|
||||
symbol: str,
|
||||
timeframe: str,
|
||||
price_data: Iterable[PriceData] | Iterable[Dict[str, float]]
|
||||
) -> PositionMetrics:
|
||||
rows = list(price_data)
|
||||
if not rows:
|
||||
raise ValueError("No price data available for metrics")
|
||||
|
||||
def _get(row, key: str) -> float:
|
||||
if hasattr(row, key):
|
||||
return float(getattr(row, key))
|
||||
return float(row[key])
|
||||
|
||||
closes = [float(_get(row, "close")) for row in rows]
|
||||
highs = [float(_get(row, "high")) for row in rows]
|
||||
lows = [float(_get(row, "low")) for row in rows]
|
||||
|
||||
last = rows[-1]
|
||||
prev = rows[-2] if len(rows) > 1 else None
|
||||
|
||||
change = None
|
||||
change_pct = None
|
||||
if prev is not None:
|
||||
prev_close = _get(prev, "close")
|
||||
last_close = _get(last, "close")
|
||||
change = last_close - prev_close
|
||||
if prev_close:
|
||||
change_pct = (change / prev_close) * 100
|
||||
|
||||
recent_window = rows[-120:] if len(rows) > 120 else rows
|
||||
support_levels = sorted({round(_get(row, "low"), 2) for row in recent_window})[:4]
|
||||
resistance_levels = sorted({round(_get(row, "high"), 2) for row in recent_window}, reverse=True)[:4]
|
||||
|
||||
atr14 = self._atr(highs, lows, closes, 14)
|
||||
rsi14 = self._rsi(closes, 14)
|
||||
ema21 = self._ema(closes, 21)
|
||||
sma55 = self._sma(closes, 55)
|
||||
sma100 = self._sma(closes, 100)
|
||||
sma200 = self._sma(closes, 200)
|
||||
volatility = self._volatility(closes, 30)
|
||||
momentum = self._momentum(closes, 12)
|
||||
|
||||
current_price = float(_get(last, "close"))
|
||||
previous_close = float(_get(prev, "close")) if prev is not None else None
|
||||
|
||||
rsi_fast = self._rsi(closes, RSI_FAST_LENGTH)
|
||||
rsi_fast_prev = self._rsi(closes[:-1], RSI_FAST_LENGTH) if len(closes) > RSI_FAST_LENGTH + 1 else None
|
||||
bb_basis, bb_upper, bb_lower = self._bollinger_bands(closes, BB_LENGTH, BB_MULT)
|
||||
bb_prev = self._bollinger_bands(closes[:-1], BB_LENGTH, BB_MULT) if len(closes) > BB_LENGTH else (None, None, None)
|
||||
bb_signal = None
|
||||
if (
|
||||
bb_basis is not None
|
||||
and bb_upper is not None
|
||||
and bb_lower is not None
|
||||
and rsi_fast is not None
|
||||
and rsi_fast_prev is not None
|
||||
and bb_prev[0] is not None
|
||||
):
|
||||
close_prev = closes[-2]
|
||||
bb_upper_prev = bb_prev[1]
|
||||
bb_lower_prev = bb_prev[2]
|
||||
if (
|
||||
rsi_fast_prev < 30
|
||||
and close_prev < bb_lower_prev
|
||||
and rsi_fast > 30
|
||||
and closes[-1] > bb_lower
|
||||
and rsi_fast < 50
|
||||
and closes[-1] < bb_basis
|
||||
):
|
||||
bb_signal = "LONG"
|
||||
elif (
|
||||
rsi_fast_prev > 70
|
||||
and close_prev > bb_upper_prev
|
||||
and rsi_fast < 70
|
||||
and closes[-1] < bb_upper
|
||||
and rsi_fast > 50
|
||||
and closes[-1] > bb_basis
|
||||
):
|
||||
bb_signal = "SHORT"
|
||||
|
||||
zlsma = self._zlsma(closes, ZLSMA_LENGTH)
|
||||
chandelier_long_stop, chandelier_short_stop = self._chandelier_exit(
|
||||
highs, lows, closes, CHAND_LENGTH, CHAND_MULT
|
||||
)
|
||||
chandelier_signal = None
|
||||
if chandelier_long_stop is not None or chandelier_short_stop is not None:
|
||||
if chandelier_long_stop is not None and chandelier_short_stop is not None:
|
||||
if current_price > chandelier_short_stop and (zlsma is None or current_price >= zlsma):
|
||||
chandelier_signal = "LONG"
|
||||
elif current_price < chandelier_long_stop and (zlsma is None or current_price <= zlsma):
|
||||
chandelier_signal = "SHORT"
|
||||
else:
|
||||
chandelier_signal = "NEUTRAL"
|
||||
elif chandelier_long_stop is not None:
|
||||
chandelier_signal = "LONG" if current_price > chandelier_long_stop else "SHORT"
|
||||
else:
|
||||
chandelier_signal = "SHORT" if current_price < chandelier_short_stop else "LONG"
|
||||
|
||||
symbol_fmt = self._format_symbol(symbol)
|
||||
timestamp = int(_get(last, "time"))
|
||||
pattern_signals = candlestick_detector.analyze(rows)
|
||||
recent_pattern_signals = pattern_signals[-MAX_PATTERN_SIGNALS:]
|
||||
|
||||
return PositionMetrics(
|
||||
symbol=symbol_fmt,
|
||||
timeframe=timeframe,
|
||||
timestamp=timestamp,
|
||||
current_price=current_price,
|
||||
previous_close=previous_close,
|
||||
change=round(change, 4) if change is not None else None,
|
||||
change_percent=round(change_pct, 4) if change_pct is not None else None,
|
||||
high=round(max(_get(row, "high") for row in recent_window), 4) if recent_window else None,
|
||||
low=round(min(_get(row, "low") for row in recent_window), 4) if recent_window else None,
|
||||
atr14=round(atr14, 4) if atr14 is not None else None,
|
||||
rsi14=round(rsi14, 2) if rsi14 is not None else None,
|
||||
rsi3=round(rsi_fast, 2) if rsi_fast is not None else None,
|
||||
ema21=round(ema21, 4) if ema21 is not None else None,
|
||||
sma55=round(sma55, 4) if sma55 is not None else None,
|
||||
sma100=round(sma100, 4) if sma100 is not None else None,
|
||||
sma200=round(sma200, 4) if sma200 is not None else None,
|
||||
bb_basis=round(bb_basis, 4) if bb_basis is not None else None,
|
||||
bb_upper=round(bb_upper, 4) if bb_upper is not None else None,
|
||||
bb_lower=round(bb_lower, 4) if bb_lower is not None else None,
|
||||
bb_signal=bb_signal,
|
||||
zlsma=round(zlsma, 4) if zlsma is not None else None,
|
||||
chandelier_long_stop=round(chandelier_long_stop, 4) if chandelier_long_stop is not None else None,
|
||||
chandelier_short_stop=round(chandelier_short_stop, 4) if chandelier_short_stop is not None else None,
|
||||
chandelier_signal=chandelier_signal,
|
||||
volatility30=round(volatility * 100, 2) if volatility is not None else None,
|
||||
momentum12=round(momentum, 4) if momentum is not None else None,
|
||||
support_levels=support_levels,
|
||||
resistance_levels=resistance_levels,
|
||||
pattern_signals=recent_pattern_signals,
|
||||
bars_analyzed=len(rows),
|
||||
)
|
||||
|
||||
def _load_bars(self, symbol: str, timeframe: str, limit: int) -> List[Dict[str, float]]:
|
||||
bars = live_store.get_history(symbol, timeframe)
|
||||
if not bars or len(bars) < limit:
|
||||
try:
|
||||
live_store.load_historical_data(symbol, timeframe, days_back=30)
|
||||
bars = live_store.get_history(symbol, timeframe)
|
||||
except Exception:
|
||||
pass
|
||||
return bars[-limit:] if bars else []
|
||||
|
||||
def _build_indicators(self, bars: List[Dict[str, float]]) -> List[Dict[str, float]]:
|
||||
closes = [float(b["close"]) for b in bars]
|
||||
highs = [float(b["high"]) for b in bars]
|
||||
lows = [float(b["low"]) for b in bars]
|
||||
indicators: List[Dict[str, float]] = []
|
||||
|
||||
for window in (8, 21, 55, 100, 200):
|
||||
val = self._sma(closes, window)
|
||||
if val is not None:
|
||||
indicators.append({"name": f"SMA_{window}", "value": round(val, 4)})
|
||||
|
||||
ema21 = self._ema(closes, 21)
|
||||
if ema21 is not None:
|
||||
indicators.append({"name": "EMA_21", "value": round(ema21, 4)})
|
||||
|
||||
rsi14 = self._rsi(closes, 14)
|
||||
if rsi14 is not None:
|
||||
indicators.append({"name": "RSI_14", "value": round(rsi14, 2)})
|
||||
|
||||
atr14 = self._atr(highs, lows, closes, 14)
|
||||
if atr14 is not None:
|
||||
indicators.append({"name": "ATR_14", "value": round(atr14, 4)})
|
||||
|
||||
volatility = self._volatility(closes, 30)
|
||||
if volatility is not None:
|
||||
indicators.append({"name": "VOLATILITY_30", "value": round(volatility * 100, 2), "unit": "%"})
|
||||
|
||||
momentum = self._momentum(closes, 12)
|
||||
if momentum is not None:
|
||||
indicators.append({"name": "MOMENTUM_12", "value": round(momentum, 4)})
|
||||
|
||||
rsi_fast = self._rsi(closes, RSI_FAST_LENGTH)
|
||||
if rsi_fast is not None:
|
||||
indicators.append({"name": f"RSI_{RSI_FAST_LENGTH}", "value": round(rsi_fast, 2)})
|
||||
|
||||
bb_basis, bb_upper, bb_lower = self._bollinger_bands(closes, BB_LENGTH, BB_MULT)
|
||||
if bb_basis is not None:
|
||||
indicators.append({"name": f"BB_{BB_LENGTH}_BASIS", "value": round(bb_basis, 4)})
|
||||
indicators.append({"name": f"BB_{BB_LENGTH}_UPPER", "value": round(bb_upper, 4)})
|
||||
indicators.append({"name": f"BB_{BB_LENGTH}_LOWER", "value": round(bb_lower, 4)})
|
||||
|
||||
zlsma = self._zlsma(closes, ZLSMA_LENGTH)
|
||||
if zlsma is not None:
|
||||
indicators.append({"name": f"ZLSMA_{ZLSMA_LENGTH}", "value": round(zlsma, 4)})
|
||||
|
||||
chandelier_long_stop, chandelier_short_stop = self._chandelier_exit(
|
||||
highs, lows, closes, CHAND_LENGTH, CHAND_MULT
|
||||
)
|
||||
if chandelier_long_stop is not None and chandelier_short_stop is not None:
|
||||
indicators.append({"name": f"CHAND_{CHAND_LENGTH}_LONG", "value": round(chandelier_long_stop, 4)})
|
||||
indicators.append({"name": f"CHAND_{CHAND_LENGTH}_SHORT", "value": round(chandelier_short_stop, 4)})
|
||||
|
||||
return indicators
|
||||
|
||||
@staticmethod
|
||||
def _sma(values: List[float], window: int) -> Optional[float]:
|
||||
if len(values) < window:
|
||||
return None
|
||||
return mean(values[-window:])
|
||||
|
||||
@staticmethod
|
||||
def _ema(values: List[float], window: int) -> Optional[float]:
|
||||
if len(values) < window:
|
||||
return None
|
||||
k = 2 / (window + 1)
|
||||
ema = mean(values[:window])
|
||||
for price in values[window:]:
|
||||
ema = price * k + ema * (1 - k)
|
||||
return ema
|
||||
|
||||
@staticmethod
|
||||
def _rsi(values: List[float], window: int = 14) -> Optional[float]:
|
||||
if len(values) <= window:
|
||||
return None
|
||||
gains = []
|
||||
losses = []
|
||||
for i in range(1, window + 1):
|
||||
change = values[-i] - values[-i - 1]
|
||||
if change >= 0:
|
||||
gains.append(change)
|
||||
else:
|
||||
losses.append(abs(change))
|
||||
avg_gain = mean(gains) if gains else 0
|
||||
avg_loss = mean(losses) if losses else 0
|
||||
if avg_loss == 0:
|
||||
return 100.0
|
||||
rs = avg_gain / avg_loss if avg_loss else 0
|
||||
return 100 - (100 / (1 + rs))
|
||||
|
||||
@staticmethod
|
||||
def _atr(highs: List[float], lows: List[float], closes: List[float], window: int = 14) -> Optional[float]:
|
||||
if len(closes) <= window:
|
||||
return None
|
||||
true_ranges = []
|
||||
for i in range(-window + 1, 0):
|
||||
high = highs[i]
|
||||
low = lows[i]
|
||||
prev_close = closes[i - 1]
|
||||
tr = max(high - low, abs(high - prev_close), abs(low - prev_close))
|
||||
true_ranges.append(tr)
|
||||
return mean(true_ranges) if true_ranges else None
|
||||
|
||||
@staticmethod
|
||||
def _volatility(values: List[float], window: int) -> Optional[float]:
|
||||
if len(values) < window:
|
||||
return None
|
||||
subset = values[-window:]
|
||||
avg = mean(subset)
|
||||
variance = mean([(p - avg) ** 2 for p in subset])
|
||||
return (variance ** 0.5) / avg if avg else None
|
||||
|
||||
@staticmethod
|
||||
def _momentum(values: List[float], lookback: int = 12) -> Optional[float]:
|
||||
if len(values) <= lookback:
|
||||
return None
|
||||
return values[-1] - values[-lookback - 1]
|
||||
|
||||
@staticmethod
|
||||
def _bollinger_bands(values: List[float], length: int, multiplier: float) -> tuple[Optional[float], Optional[float], Optional[float]]:
|
||||
if len(values) < length:
|
||||
return (None, None, None)
|
||||
window = values[-length:]
|
||||
basis = mean(window)
|
||||
variance = mean([(price - basis) ** 2 for price in window])
|
||||
deviation = variance ** 0.5
|
||||
upper = basis + multiplier * deviation
|
||||
lower = basis - multiplier * deviation
|
||||
return (basis, upper, lower)
|
||||
|
||||
@staticmethod
|
||||
def _linreg(values: List[float], length: int) -> Optional[float]:
|
||||
if len(values) < length:
|
||||
return None
|
||||
window = values[-length:]
|
||||
x = list(range(length))
|
||||
sum_x = sum(x)
|
||||
sum_y = sum(window)
|
||||
sum_x2 = sum(i * i for i in x)
|
||||
sum_xy = sum(i * y for i, y in zip(x, window))
|
||||
denominator = length * sum_x2 - sum_x ** 2
|
||||
if denominator == 0:
|
||||
return window[-1]
|
||||
slope = (length * sum_xy - sum_x * sum_y) / denominator
|
||||
intercept = (sum_y - slope * sum_x) / length
|
||||
return intercept + slope * (length - 1)
|
||||
|
||||
@classmethod
|
||||
def _zlsma(cls, values: List[float], length: int) -> Optional[float]:
|
||||
if len(values) < length:
|
||||
return None
|
||||
|
||||
lsma_series: List[float] = []
|
||||
for idx in range(length, len(values) + 1):
|
||||
segment = values[idx - length : idx]
|
||||
lsma_val = cls._linreg(segment, length)
|
||||
if lsma_val is not None:
|
||||
lsma_series.append(lsma_val)
|
||||
|
||||
if not lsma_series:
|
||||
return None
|
||||
|
||||
lsma_last = lsma_series[-1]
|
||||
if len(lsma_series) < length:
|
||||
return lsma_last
|
||||
|
||||
lsma2_series: List[float] = []
|
||||
for idx in range(length, len(lsma_series) + 1):
|
||||
segment = lsma_series[idx - length : idx]
|
||||
lsma2_val = cls._linreg(segment, length)
|
||||
if lsma2_val is not None:
|
||||
lsma2_series.append(lsma2_val)
|
||||
|
||||
if not lsma2_series:
|
||||
return lsma_last
|
||||
|
||||
lsma2_last = lsma2_series[-1]
|
||||
return lsma_last + (lsma_last - lsma2_last)
|
||||
|
||||
@classmethod
|
||||
def _chandelier_exit(
|
||||
cls, highs: List[float], lows: List[float], closes: List[float], length: int, multiplier: float
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
if len(closes) <= length:
|
||||
return (None, None)
|
||||
|
||||
recent_high = max(highs[-length:])
|
||||
recent_low = min(lows[-length:])
|
||||
atr = cls._atr(highs, lows, closes, length)
|
||||
if atr is None:
|
||||
return (None, None)
|
||||
|
||||
long_stop = recent_high - multiplier * atr
|
||||
short_stop = recent_low + multiplier * atr
|
||||
return (long_stop, short_stop)
|
||||
|
||||
@staticmethod
|
||||
def _format_symbol(symbol: str) -> str:
|
||||
if len(symbol) == 6 and symbol.isalpha():
|
||||
return f"{symbol[:3]}/{symbol[3:]}"
|
||||
return symbol
|
||||
ai_context_builder = AIContextBuilder()
|
||||
@@ -0,0 +1,473 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import abc
|
||||
import asyncio
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
try: # Optional dependency for MetaTrader5
|
||||
import MetaTrader5 # type: ignore
|
||||
except ImportError: # pragma: no cover - optional
|
||||
MetaTrader5 = None # type: ignore
|
||||
|
||||
|
||||
class BrokerError(RuntimeError):
|
||||
"""Raised when bridge operations fail."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class BrokerProvider:
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
docs_url: str
|
||||
latency_ms: int
|
||||
features: Dict[str, bool]
|
||||
supports_demo: bool = True
|
||||
|
||||
|
||||
BROKER_PROVIDERS: List[BrokerProvider] = [
|
||||
BrokerProvider(
|
||||
id="mt5",
|
||||
name="MetaTrader 5",
|
||||
description="Direct bridge to a locally running MetaTrader 5 terminal.",
|
||||
docs_url="https://www.metatrader5.com/en/terminal/help",
|
||||
latency_ms=180,
|
||||
features={
|
||||
"trailingStops": True,
|
||||
"partialCloses": True,
|
||||
"hedging": True,
|
||||
"streaming": True,
|
||||
},
|
||||
),
|
||||
BrokerProvider(
|
||||
id="oanda",
|
||||
name="OANDA v20",
|
||||
description="REST trading for FX/CFD (practice or live)",
|
||||
docs_url="https://developer.oanda.com/rest-live-v20/",
|
||||
latency_ms=230,
|
||||
features={
|
||||
"trailingStops": True,
|
||||
"partialCloses": True,
|
||||
"hedging": False,
|
||||
"streaming": False,
|
||||
},
|
||||
),
|
||||
BrokerProvider(
|
||||
id="alpaca",
|
||||
name="Alpaca Trading",
|
||||
description="Equities/crypto order routing (paper or live)",
|
||||
docs_url="https://alpaca.markets/docs/api-references/trading-api/",
|
||||
latency_ms=120,
|
||||
features={
|
||||
"trailingStops": False,
|
||||
"partialCloses": True,
|
||||
"hedging": False,
|
||||
"streaming": True,
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
PROVIDER_LOOKUP = {provider.id: provider for provider in BROKER_PROVIDERS}
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _demo_state(balance: Optional[float] = None) -> Dict[str, Any]:
|
||||
return {
|
||||
"mode": "demo",
|
||||
"token": f"demo-{uuid.uuid4()}",
|
||||
"balance": balance if balance is not None else settings.BROKER_SIM_BALANCE,
|
||||
"positions": [],
|
||||
}
|
||||
|
||||
|
||||
class BaseConnector(abc.ABC):
|
||||
provider_id: str
|
||||
|
||||
@abc.abstractmethod
|
||||
async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def disconnect(self, state: Dict[str, Any]) -> None:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
@abc.abstractmethod
|
||||
async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
...
|
||||
|
||||
|
||||
class MetaTraderConnector(BaseConnector):
|
||||
provider_id = "mt5"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def _client(self):
|
||||
if MetaTrader5 is None:
|
||||
raise BrokerError("MetaTrader5 python package is not installed")
|
||||
return MetaTrader5
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if credentials.get("demo", True):
|
||||
return _demo_state()
|
||||
|
||||
mt5 = self._client()
|
||||
login = int(credentials["account_id"])
|
||||
password = credentials["api_key"]
|
||||
server = credentials.get("server") or settings.MT5_SERVER
|
||||
|
||||
async with self._lock:
|
||||
def _login():
|
||||
if not mt5.initialize():
|
||||
raise BrokerError(f"MetaTrader5 initialize failed: {mt5.last_error()}")
|
||||
if not mt5.login(login=login, password=password, server=server):
|
||||
raise BrokerError(f"MetaTrader5 login failed: {mt5.last_error()}")
|
||||
info = mt5.account_info()
|
||||
balance = float(info.balance) if info else None
|
||||
return {
|
||||
"mode": "live",
|
||||
"balance": balance,
|
||||
"token": f"mt5-{uuid.uuid4()}",
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_login)
|
||||
|
||||
async def disconnect(self, state: Dict[str, Any]) -> None:
|
||||
if state.get("mode") == "demo":
|
||||
return
|
||||
mt5 = self._client()
|
||||
|
||||
async with self._lock:
|
||||
def _shutdown():
|
||||
mt5.shutdown()
|
||||
|
||||
await asyncio.to_thread(_shutdown)
|
||||
|
||||
async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {
|
||||
"remote_id": f"demo-{order['action']}-{uuid.uuid4().hex[:6]}",
|
||||
"filled": True,
|
||||
}
|
||||
|
||||
mt5 = self._client()
|
||||
|
||||
def _send():
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": order["symbol"],
|
||||
"type": mt5.ORDER_TYPE_BUY if order["action"] == "BUY" else mt5.ORDER_TYPE_SELL,
|
||||
"volume": float(order["quantity"]),
|
||||
"price": float(order["price"]),
|
||||
"type_filling": mt5.ORDER_FILLING_RETURN,
|
||||
"sl": order.get("stopLoss"),
|
||||
"tp": order.get("takeProfit"),
|
||||
}
|
||||
result = mt5.order_send(request)
|
||||
if result is None or result.retcode != mt5.TRADE_RETCODE_DONE:
|
||||
raise BrokerError(f"MetaTrader5 order failed: {mt5.last_error()}")
|
||||
return {
|
||||
"remote_id": str(result.order),
|
||||
"filled": True,
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_send)
|
||||
|
||||
async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {"positions": state.setdefault("positions", []), "balance": state.get("balance")}
|
||||
|
||||
mt5 = self._client()
|
||||
|
||||
def _fetch():
|
||||
info = mt5.account_info()
|
||||
balance = float(info.balance) if info else None
|
||||
rows = mt5.positions_get()
|
||||
positions: List[Dict[str, Any]] = []
|
||||
if rows:
|
||||
for row in rows:
|
||||
positions.append(
|
||||
{
|
||||
"symbol": row.symbol,
|
||||
"quantity": float(row.volume),
|
||||
"avgPrice": float(row.price_open),
|
||||
"lastPrice": float(row.price_current),
|
||||
"pnl": float(row.profit),
|
||||
"ticket": int(row.ticket),
|
||||
}
|
||||
)
|
||||
return {"positions": positions, "balance": balance}
|
||||
|
||||
return await asyncio.to_thread(_fetch)
|
||||
|
||||
|
||||
class OandaConnector(BaseConnector):
|
||||
provider_id = "oanda"
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if credentials.get("demo", True) or not credentials.get("api_key"):
|
||||
return _demo_state()
|
||||
|
||||
account_id = credentials["account_id"]
|
||||
headers = {
|
||||
"Authorization": f"Bearer {credentials['api_key']}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
base_url = settings.OANDA_BASE_URL.rstrip("/")
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.get(f"/v3/accounts/{account_id}", headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("account", {})
|
||||
balance = float(data.get("balance", 0))
|
||||
return {
|
||||
"mode": "live",
|
||||
"headers": headers,
|
||||
"account_id": account_id,
|
||||
"base_url": base_url,
|
||||
"balance": balance,
|
||||
"token": f"oanda-{uuid.uuid4()}",
|
||||
}
|
||||
|
||||
async def disconnect(self, state: Dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {
|
||||
"remote_id": f"demo-{order['action']}-{uuid.uuid4().hex[:6]}",
|
||||
"filled": True,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"order": {
|
||||
"instrument": order["symbol"],
|
||||
"units": str(order["quantity"] if order["action"] == "BUY" else -order["quantity"]),
|
||||
"type": order.get("type", "MARKET"),
|
||||
"timeInForce": "FOK",
|
||||
"positionFill": "DEFAULT",
|
||||
}
|
||||
}
|
||||
if order.get("stopLoss"):
|
||||
payload["order"]["stopLossOnFill"] = {"price": str(order["stopLoss"])}
|
||||
if order.get("takeProfit"):
|
||||
payload["order"]["takeProfitOnFill"] = {"price": str(order["takeProfit"])}
|
||||
|
||||
async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.post(
|
||||
f"/v3/accounts/{state['account_id']}/orders",
|
||||
headers=state["headers"],
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {
|
||||
"remote_id": data.get("orderFillTransaction", {}).get("orderID") or uuid.uuid4().hex,
|
||||
"filled": True,
|
||||
}
|
||||
|
||||
async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {"positions": state.setdefault("positions", []), "balance": state.get("balance")}
|
||||
|
||||
async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.get(
|
||||
f"/v3/accounts/{state['account_id']}/openPositions",
|
||||
headers=state["headers"],
|
||||
)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
|
||||
positions: List[Dict[str, Any]] = []
|
||||
for item in payload.get("positions", []):
|
||||
net = float(item.get("net", {}).get("units", 0))
|
||||
if net == 0:
|
||||
continue
|
||||
avg_price = float(item.get("net", {}).get("averagePrice", 0))
|
||||
positions.append(
|
||||
{
|
||||
"symbol": item.get("instrument"),
|
||||
"quantity": abs(net),
|
||||
"avgPrice": avg_price,
|
||||
"lastPrice": None,
|
||||
"pnl": None,
|
||||
}
|
||||
)
|
||||
|
||||
return {"positions": positions, "balance": state.get("balance")}
|
||||
|
||||
|
||||
class AlpacaConnector(BaseConnector):
|
||||
provider_id = "alpaca"
|
||||
|
||||
async def connect(self, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if credentials.get("demo", True) or not credentials.get("api_key"):
|
||||
return _demo_state()
|
||||
|
||||
key_parts = credentials["api_key"].split(":", 1)
|
||||
if len(key_parts) != 2:
|
||||
raise BrokerError("Provide API_KEY:API_SECRET for Alpaca API key field")
|
||||
|
||||
headers = {
|
||||
"APCA-API-KEY-ID": key_parts[0],
|
||||
"APCA-API-SECRET-KEY": key_parts[1],
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
base_url = settings.ALPACA_BASE_URL.rstrip("/")
|
||||
async with httpx.AsyncClient(base_url=base_url, timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.get("/account", headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {
|
||||
"mode": "live",
|
||||
"headers": headers,
|
||||
"base_url": base_url,
|
||||
"account_id": data.get("id") or credentials.get("account_id"),
|
||||
"balance": float(data.get("cash", 0)),
|
||||
"token": f"alpaca-{uuid.uuid4()}",
|
||||
}
|
||||
|
||||
async def disconnect(self, state: Dict[str, Any]) -> None:
|
||||
return None
|
||||
|
||||
async def place_order(self, state: Dict[str, Any], order: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {
|
||||
"remote_id": f"demo-{order['action']}-{uuid.uuid4().hex[:6]}",
|
||||
"filled": True,
|
||||
}
|
||||
|
||||
payload = {
|
||||
"symbol": order["symbol"],
|
||||
"qty": order["quantity"],
|
||||
"side": "buy" if order["action"] == "BUY" else "sell",
|
||||
"type": order.get("type", "market").lower(),
|
||||
"time_in_force": "day",
|
||||
}
|
||||
if order.get("stopLoss") or order.get("takeProfit"):
|
||||
payload["order_class"] = "oto"
|
||||
payload["take_profit"] = {"limit_price": order.get("takeProfit")}
|
||||
payload["stop_loss"] = {"stop_price": order.get("stopLoss")}
|
||||
|
||||
async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.post("/orders", headers=state["headers"], json=payload)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {
|
||||
"remote_id": data.get("id", uuid.uuid4().hex),
|
||||
"filled": data.get("status") == "filled",
|
||||
}
|
||||
|
||||
async def sync_positions(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if state.get("mode") == "demo":
|
||||
return {"positions": state.setdefault("positions", []), "balance": state.get("balance")}
|
||||
|
||||
async with httpx.AsyncClient(base_url=state["base_url"], timeout=settings.BROKER_HTTP_TIMEOUT) as client:
|
||||
resp = await client.get("/positions", headers=state["headers"])
|
||||
resp.raise_for_status()
|
||||
rows = resp.json()
|
||||
positions = [
|
||||
{
|
||||
"symbol": row.get("symbol"),
|
||||
"quantity": float(row.get("qty", 0)),
|
||||
"avgPrice": float(row.get("avg_entry_price", 0)),
|
||||
"lastPrice": float(row.get("current_price", 0)),
|
||||
"pnl": float(row.get("unrealized_pl", 0)),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return {"positions": positions, "balance": state.get("balance")}
|
||||
|
||||
|
||||
CONNECTORS: Dict[str, BaseConnector] = {
|
||||
"mt5": MetaTraderConnector(),
|
||||
"oanda": OandaConnector(),
|
||||
"alpaca": AlpacaConnector(),
|
||||
}
|
||||
|
||||
|
||||
class BrokerBridgeService:
|
||||
def __init__(self) -> None:
|
||||
self._session: Optional[Dict[str, Any]] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
def list_providers(self) -> List[Dict[str, Any]]:
|
||||
return [asdict(provider) for provider in BROKER_PROVIDERS]
|
||||
|
||||
def get_session(self) -> Optional[Dict[str, Any]]:
|
||||
if not self._session:
|
||||
return None
|
||||
provider = PROVIDER_LOOKUP.get(self._session["provider_id"])
|
||||
payload = {**self._session}
|
||||
payload["provider"] = asdict(provider) if provider else None
|
||||
return payload
|
||||
|
||||
async def connect(self, provider_id: str, credentials: Dict[str, Any]) -> Dict[str, Any]:
|
||||
connector = CONNECTORS.get(provider_id)
|
||||
if not connector:
|
||||
raise BrokerError("Unsupported broker provider")
|
||||
|
||||
state = await connector.connect(credentials)
|
||||
async with self._lock:
|
||||
self._session = {
|
||||
"provider_id": provider_id,
|
||||
"credentials": credentials,
|
||||
"state": state,
|
||||
"account_id": credentials.get("account_id"),
|
||||
"demo": credentials.get("demo", True),
|
||||
"last_heartbeat": _iso_now(),
|
||||
"balance": state.get("balance"),
|
||||
"positions": state.get("positions", []),
|
||||
}
|
||||
return self.get_session() # type: ignore[return-value]
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
if not self._session:
|
||||
return
|
||||
connector = CONNECTORS.get(self._session["provider_id"])
|
||||
if connector:
|
||||
await connector.disconnect(self._session.get("state", {}))
|
||||
async with self._lock:
|
||||
self._session = None
|
||||
|
||||
async def place_order(self, order: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if not self._session:
|
||||
raise BrokerError("No active broker session")
|
||||
connector = CONNECTORS.get(self._session["provider_id"])
|
||||
if not connector:
|
||||
raise BrokerError("Unsupported broker provider")
|
||||
result = await connector.place_order(self._session.get("state", {}), order)
|
||||
self._session["last_heartbeat"] = _iso_now()
|
||||
return result
|
||||
|
||||
async def sync_positions(self) -> Dict[str, Any]:
|
||||
if not self._session:
|
||||
raise BrokerError("No active broker session")
|
||||
connector = CONNECTORS.get(self._session["provider_id"])
|
||||
if not connector:
|
||||
raise BrokerError("Unsupported broker provider")
|
||||
snapshot = await connector.sync_positions(self._session.get("state", {}))
|
||||
self._session["last_heartbeat"] = _iso_now()
|
||||
self._session["positions"] = snapshot.get("positions", [])
|
||||
self._session["balance"] = snapshot.get("balance", self._session.get("balance"))
|
||||
return {
|
||||
"positions": self._session["positions"],
|
||||
"balance": self._session.get("balance"),
|
||||
"lastHeartbeat": self._session.get("last_heartbeat"),
|
||||
}
|
||||
|
||||
|
||||
broker_bridge_service = BrokerBridgeService()
|
||||
@@ -0,0 +1,387 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, List, Sequence, Tuple, Union
|
||||
|
||||
from app.schemas.schemas import PatternSignal, PriceData
|
||||
|
||||
BarLike = Union[PriceData, dict]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candle:
|
||||
time: int
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
|
||||
@property
|
||||
def hl2(self) -> float:
|
||||
return (self.high + self.low) / 2
|
||||
|
||||
|
||||
class CandlestickPatternDetector:
|
||||
"""Translated subset of the TradingView *All Candlestick Patterns* study.
|
||||
|
||||
The detector focuses on high-signal patterns that are most useful for
|
||||
risk automation and narrative building. The implementation is intentionally
|
||||
modular so additional patterns from the Pine script can be ported quickly.
|
||||
"""
|
||||
|
||||
BODY_AVG_EMA = 14
|
||||
SHADOW_PERCENT = 5.0
|
||||
SHADOW_EQUALS_PERCENT = 100.0
|
||||
DOJI_BODY_PERCENT = 5.0
|
||||
LONG_LOWER_PERCENT = 75.0
|
||||
LONG_UPPER_PERCENT = 75.0
|
||||
HAMMER_FACTOR = 2.0
|
||||
TREND_SMA = 50
|
||||
TREND_SMA_LONG = 200
|
||||
|
||||
def analyze(self, rows: Iterable[BarLike]) -> List[PatternSignal]:
|
||||
candles = self._normalize(rows)
|
||||
if len(candles) < 3:
|
||||
return []
|
||||
|
||||
opens = [c.open for c in candles]
|
||||
highs = [c.high for c in candles]
|
||||
lows = [c.low for c in candles]
|
||||
closes = [c.close for c in candles]
|
||||
times = [c.time for c in candles]
|
||||
|
||||
body_hi = [max(o, c) for o, c in zip(opens, closes)]
|
||||
body_lo = [min(o, c) for o, c in zip(opens, closes)]
|
||||
bodies = [hi - lo for hi, lo in zip(body_hi, body_lo)]
|
||||
ranges = [h - l for h, l in zip(highs, lows)]
|
||||
upper_shadows = [h - hi for h, hi in zip(highs, body_hi)]
|
||||
lower_shadows = [lo - l for lo, l in zip(body_lo, lows)]
|
||||
body_avg = self._ema_series(bodies, self.BODY_AVG_EMA)
|
||||
sma50 = self._sma_series(closes, self.TREND_SMA)
|
||||
sma200 = self._sma_series(closes, self.TREND_SMA_LONG)
|
||||
|
||||
up_trend = [False] * len(candles)
|
||||
down_trend = [False] * len(candles)
|
||||
for idx in range(len(candles)):
|
||||
if sma50[idx] is None:
|
||||
if idx > 0:
|
||||
up_trend[idx] = closes[idx] > closes[idx - 1]
|
||||
down_trend[idx] = closes[idx] < closes[idx - 1]
|
||||
continue
|
||||
close = closes[idx]
|
||||
s50 = sma50[idx]
|
||||
s200 = sma200[idx]
|
||||
up = close > s50
|
||||
down = close < s50
|
||||
if s200 is not None:
|
||||
up = up and s50 > s200
|
||||
down = down and s50 < s200
|
||||
up_trend[idx] = up
|
||||
down_trend[idx] = down
|
||||
|
||||
pattern_signals: List[PatternSignal] = []
|
||||
|
||||
for i in range(len(candles)):
|
||||
detected = self._detect_at(
|
||||
i,
|
||||
candles,
|
||||
body_hi,
|
||||
body_lo,
|
||||
bodies,
|
||||
body_avg,
|
||||
ranges,
|
||||
upper_shadows,
|
||||
lower_shadows,
|
||||
up_trend,
|
||||
down_trend,
|
||||
)
|
||||
for pattern, classification in detected:
|
||||
pattern_signals.append(
|
||||
PatternSignal(
|
||||
pattern=pattern,
|
||||
classification=classification,
|
||||
price=closes[i],
|
||||
time=times[i],
|
||||
)
|
||||
)
|
||||
|
||||
return pattern_signals
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _detect_at(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
body_hi: Sequence[float],
|
||||
body_lo: Sequence[float],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
ranges: Sequence[float],
|
||||
upper_shadows: Sequence[float],
|
||||
lower_shadows: Sequence[float],
|
||||
up_trend: Sequence[bool],
|
||||
down_trend: Sequence[bool],
|
||||
) -> List[Tuple[str, str]]:
|
||||
signals: List[Tuple[str, str]] = []
|
||||
|
||||
if i == 0:
|
||||
return signals
|
||||
|
||||
body = bodies[i]
|
||||
body_average = body_avg[i] or 0.0
|
||||
range_ = ranges[i]
|
||||
upper = upper_shadows[i]
|
||||
lower = lower_shadows[i]
|
||||
is_white = candles[i].close > candles[i].open
|
||||
is_black = candles[i].open > candles[i].close
|
||||
prev_white = candles[i - 1].close > candles[i - 1].open
|
||||
prev_black = candles[i - 1].open > candles[i - 1].close
|
||||
small_body = body_average > 0 and body < body_average
|
||||
long_body = body_average > 0 and body > body_average
|
||||
has_upper_shadow = upper > self.SHADOW_PERCENT / 100 * body if body > 0 else False
|
||||
has_lower_shadow = lower > self.SHADOW_PERCENT / 100 * body if body > 0 else False
|
||||
doji = self._is_doji(body, range_)
|
||||
|
||||
# Single-candle patterns -------------------------------------------------
|
||||
if doji:
|
||||
signals.append(("Doji", "NEUTRAL"))
|
||||
if upper <= body:
|
||||
signals.append(("Dragonfly Doji", "BULLISH"))
|
||||
if lower <= body:
|
||||
signals.append(("Gravestone Doji", "BEARISH"))
|
||||
|
||||
if body > 0:
|
||||
if not has_upper_shadow and lower >= self.HAMMER_FACTOR * body and candles[i].hl2 < body_lo[i] and down_trend[i]:
|
||||
signals.append(("Hammer", "BULLISH"))
|
||||
if not has_upper_shadow and lower >= self.HAMMER_FACTOR * body and candles[i].hl2 < body_lo[i] and up_trend[i]:
|
||||
signals.append(("Hanging Man", "BEARISH"))
|
||||
if not has_lower_shadow and upper >= self.HAMMER_FACTOR * body and candles[i].hl2 > body_hi[i] and down_trend[i]:
|
||||
signals.append(("Inverted Hammer", "BULLISH"))
|
||||
if not has_lower_shadow and upper >= self.HAMMER_FACTOR * body and candles[i].hl2 > body_hi[i] and up_trend[i]:
|
||||
signals.append(("Shooting Star", "BEARISH"))
|
||||
|
||||
if body > 0 and upper <= body * self.SHADOW_PERCENT / 100 and lower <= body * self.SHADOW_PERCENT / 100:
|
||||
if is_white:
|
||||
signals.append(("Marubozu White", "BULLISH"))
|
||||
if is_black:
|
||||
signals.append(("Marubozu Black", "BEARISH"))
|
||||
|
||||
if lower > range_ * self.LONG_LOWER_PERCENT / 100:
|
||||
signals.append(("Long Lower Shadow", "BULLISH"))
|
||||
if upper > range_ * self.LONG_UPPER_PERCENT / 100:
|
||||
signals.append(("Long Upper Shadow", "BEARISH"))
|
||||
|
||||
# Multi-candle patterns --------------------------------------------------
|
||||
signals.extend(
|
||||
self._two_candle_patterns(
|
||||
i,
|
||||
candles,
|
||||
body_hi,
|
||||
body_lo,
|
||||
bodies,
|
||||
body_avg,
|
||||
ranges,
|
||||
up_trend,
|
||||
down_trend,
|
||||
)
|
||||
)
|
||||
signals.extend(
|
||||
self._three_candle_patterns(
|
||||
i,
|
||||
candles,
|
||||
body_hi,
|
||||
body_lo,
|
||||
bodies,
|
||||
body_avg,
|
||||
up_trend,
|
||||
down_trend,
|
||||
)
|
||||
)
|
||||
signals.extend(self._soldiers_and_crows(i, candles, bodies, body_avg))
|
||||
|
||||
return signals
|
||||
|
||||
def _two_candle_patterns(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
body_hi: Sequence[float],
|
||||
body_lo: Sequence[float],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
ranges: Sequence[float],
|
||||
up_trend: Sequence[bool],
|
||||
down_trend: Sequence[bool],
|
||||
) -> List[Tuple[str, str]]:
|
||||
if i < 1:
|
||||
return []
|
||||
signals: List[Tuple[str, str]] = []
|
||||
body = bodies[i]
|
||||
body_prev = bodies[i - 1]
|
||||
avg = body_avg[i] or 0.0
|
||||
avg_prev = body_avg[i - 1] or 0.0
|
||||
white = candles[i].close > candles[i].open
|
||||
black = candles[i].open > candles[i].close
|
||||
prev_white = candles[i - 1].close > candles[i - 1].open
|
||||
prev_black = candles[i - 1].open > candles[i - 1].close
|
||||
|
||||
tol = (avg + avg_prev) / 2 * 0.05 if (avg + avg_prev) > 0 else 0.0
|
||||
|
||||
# Tweezer patterns
|
||||
if abs(candles[i].high - candles[i - 1].high) <= tol and prev_white and black and up_trend[i - 1]:
|
||||
signals.append(("Tweezer Top", "BEARISH"))
|
||||
if abs(candles[i].low - candles[i - 1].low) <= tol and prev_black and white and down_trend[i - 1]:
|
||||
signals.append(("Tweezer Bottom", "BULLISH"))
|
||||
|
||||
# Engulfing
|
||||
if down_trend[i - 1] and prev_black and (avg_prev == 0 or body_prev <= avg_prev) and white:
|
||||
if candles[i].close >= candles[i - 1].open and candles[i].open <= candles[i - 1].close:
|
||||
signals.append(("Bullish Engulfing", "BULLISH"))
|
||||
if up_trend[i - 1] and prev_white and (avg_prev == 0 or body_prev <= avg_prev) and black:
|
||||
if candles[i].close <= candles[i - 1].open and candles[i].open >= candles[i - 1].close:
|
||||
signals.append(("Bearish Engulfing", "BEARISH"))
|
||||
|
||||
# Piercing / Dark Cloud Cover
|
||||
mid_prev = (candles[i - 1].open + candles[i - 1].close) / 2
|
||||
if down_trend[i - 1] and prev_black and white:
|
||||
if candles[i].open <= candles[i - 1].low and candles[i].close > mid_prev and candles[i].close < candles[i - 1].open:
|
||||
signals.append(("Piercing", "BULLISH"))
|
||||
if up_trend[i - 1] and prev_white and black:
|
||||
if candles[i].open >= candles[i - 1].high and candles[i].close < mid_prev and candles[i].close > candles[i - 1].open:
|
||||
signals.append(("Dark Cloud Cover", "BEARISH"))
|
||||
|
||||
# Doji Star variants
|
||||
if self._is_doji(body, ranges[i]) and up_trend[i - 1] and prev_white:
|
||||
if candles[i].open > candles[i - 1].high:
|
||||
signals.append(("Doji Star", "BEARISH"))
|
||||
if self._is_doji(body, ranges[i]) and down_trend[i - 1] and prev_black:
|
||||
if candles[i].open < candles[i - 1].low:
|
||||
signals.append(("Doji Star", "BULLISH"))
|
||||
|
||||
return signals
|
||||
|
||||
def _three_candle_patterns(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
body_hi: Sequence[float],
|
||||
body_lo: Sequence[float],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
up_trend: Sequence[bool],
|
||||
down_trend: Sequence[bool],
|
||||
) -> List[Tuple[str, str]]:
|
||||
if i < 2:
|
||||
return []
|
||||
signals: List[Tuple[str, str]] = []
|
||||
|
||||
c0, c1, c2 = candles[i - 2], candles[i - 1], candles[i]
|
||||
body0, body1, body2 = bodies[i - 2], bodies[i - 1], bodies[i]
|
||||
avg0 = body_avg[i - 2] or 0.0
|
||||
avg1 = body_avg[i - 1] or 0.0
|
||||
avg2 = body_avg[i] or 0.0
|
||||
white2 = c2.close > c2.open
|
||||
black2 = c2.open > c2.close
|
||||
small1 = avg1 > 0 and body1 < avg1
|
||||
doji1 = self._is_doji(body1, c1.high - c1.low)
|
||||
|
||||
mid0 = (c0.open + c0.close) / 2
|
||||
|
||||
if down_trend[i - 2] and (c0.open > c0.close) and small1 and white2:
|
||||
if c1.open < c0.low and c2.close >= mid0 and c2.close < c0.high:
|
||||
signals.append(("Morning Star", "BULLISH"))
|
||||
if up_trend[i - 2] and (c0.close > c0.open) and small1 and black2:
|
||||
if c1.open > c0.high and c2.close <= mid0 and c2.close > c0.low:
|
||||
signals.append(("Evening Star", "BEARISH"))
|
||||
|
||||
if down_trend[i - 2] and (c0.open > c0.close) and doji1 and white2:
|
||||
if c1.open < c0.low and c2.close >= mid0 and c2.close < c0.high:
|
||||
signals.append(("Morning Doji Star", "BULLISH"))
|
||||
if up_trend[i - 2] and (c0.close > c0.open) and doji1 and black2:
|
||||
if c1.open > c0.high and c2.close <= mid0 and c2.close > c0.low:
|
||||
signals.append(("Evening Doji Star", "BEARISH"))
|
||||
|
||||
return signals
|
||||
|
||||
def _soldiers_and_crows(
|
||||
self,
|
||||
i: int,
|
||||
candles: Sequence[Candle],
|
||||
bodies: Sequence[float],
|
||||
body_avg: Sequence[float | None],
|
||||
) -> List[Tuple[str, str]]:
|
||||
if i < 2:
|
||||
return []
|
||||
signals: List[Tuple[str, str]] = []
|
||||
c0, c1, c2 = candles[i - 2], candles[i - 1], candles[i]
|
||||
body0, body1, body2 = bodies[i - 2], bodies[i - 1], bodies[i]
|
||||
avg0 = body_avg[i - 2] or 0.0
|
||||
avg1 = body_avg[i - 1] or 0.0
|
||||
avg2 = body_avg[i] or 0.0
|
||||
|
||||
if all(b > a for b, a in zip((body0, body1, body2), (avg0, avg1, avg2))):
|
||||
if c0.close < c0.open and c1.close > c1.open and c2.close > c2.open:
|
||||
if c1.open > c0.close and c2.open > c1.close and c2.close > c1.close > c0.close:
|
||||
signals.append(("Three White Soldiers", "BULLISH"))
|
||||
if c0.close > c0.open and c1.close < c1.open and c2.close < c2.open:
|
||||
if c1.open < c0.close and c2.open < c1.close and c2.close < c1.close < c0.close:
|
||||
signals.append(("Three Black Crows", "BEARISH"))
|
||||
|
||||
return signals
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utility functions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _normalize(self, rows: Iterable[BarLike]) -> List[Candle]:
|
||||
candles: List[Candle] = []
|
||||
for row in rows:
|
||||
if isinstance(row, PriceData):
|
||||
candles.append(Candle(time=row.time, open=row.open, high=row.high, low=row.low, close=row.close))
|
||||
else:
|
||||
candles.append(
|
||||
Candle(
|
||||
time=int(row.get("time", len(candles))),
|
||||
open=float(row["open"]),
|
||||
high=float(row["high"]),
|
||||
low=float(row["low"]),
|
||||
close=float(row["close"]),
|
||||
)
|
||||
)
|
||||
return candles
|
||||
|
||||
def _ema_series(self, values: Sequence[float], length: int) -> List[float | None]:
|
||||
ema_series: List[float | None] = [None] * len(values)
|
||||
if len(values) < length:
|
||||
return ema_series
|
||||
k = 2 / (length + 1)
|
||||
ema = sum(values[:length]) / length
|
||||
ema_series[length - 1] = ema
|
||||
for idx in range(length, len(values)):
|
||||
ema = values[idx] * k + ema * (1 - k)
|
||||
ema_series[idx] = ema
|
||||
return ema_series
|
||||
|
||||
def _sma_series(self, values: Sequence[float], length: int) -> List[float | None]:
|
||||
sma_series: List[float | None] = [None] * len(values)
|
||||
if length <= 0:
|
||||
return sma_series
|
||||
window_sum = 0.0
|
||||
for idx, value in enumerate(values):
|
||||
window_sum += value
|
||||
if idx >= length:
|
||||
window_sum -= values[idx - length]
|
||||
if idx >= length - 1:
|
||||
sma_series[idx] = window_sum / length
|
||||
return sma_series
|
||||
|
||||
def _is_doji(self, body: float, candle_range: float) -> bool:
|
||||
if candle_range <= 0:
|
||||
return False
|
||||
return body <= candle_range * self.DOJI_BODY_PERCENT / 100
|
||||
|
||||
|
||||
candlestick_detector = CandlestickPatternDetector()
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
BullionVault Gold Price Service
|
||||
Fetches real-time gold prices from BullionVault's CSV data API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import csv
|
||||
import io
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BullionVaultService:
|
||||
"""
|
||||
Service to fetch gold prices from BullionVault
|
||||
BullionVault provides accurate, real-time precious metals prices
|
||||
Uses their CSV data API: https://chart-data.bullionvault.com
|
||||
"""
|
||||
|
||||
# Correct BullionVault CSV API base URL
|
||||
BASE_URL = "https://chart-data.bullionvault.com"
|
||||
|
||||
# Metal codes
|
||||
METALS = {
|
||||
'gold': 'AUX',
|
||||
'silver': 'AGX',
|
||||
'platinum': 'PTX',
|
||||
'palladium': 'PDX'
|
||||
}
|
||||
|
||||
# Interval codes (seconds between data points)
|
||||
INTERVALS = {
|
||||
'10m': 5, # 10 minutes
|
||||
'1h': 15, # 1 hour
|
||||
'6h': 120, # 6 hours
|
||||
'1d': 600, # 1 day (default)
|
||||
'1w': 3600, # 1 week
|
||||
'1m': 14400, # 1 month
|
||||
'3m': 43200, # 3 months (1 quarter)
|
||||
'1y': 172800, # 1 year
|
||||
'5y': 864000, # 5 years
|
||||
'20y': 2592000 # 20 years
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
*,
|
||||
base_url: Optional[str] = None,
|
||||
timeout: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
retry_backoff_seconds: float = 0.5,
|
||||
) -> None:
|
||||
self.base_url = base_url or self.BASE_URL
|
||||
self.max_retries = max(1, max_retries)
|
||||
self.retry_backoff_seconds = max(0.0, retry_backoff_seconds)
|
||||
|
||||
if client is None:
|
||||
self.client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
|
||||
self._owns_client = True
|
||||
else:
|
||||
self.client = client
|
||||
self._owns_client = False
|
||||
|
||||
async def __aenter__(self) -> "BullionVaultService":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info: object) -> None:
|
||||
await self.close()
|
||||
|
||||
async def get_current_gold_price(self, currency: str = "USD") -> Dict[str, Any]:
|
||||
"""
|
||||
Get current gold spot price from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code (USD, GBP, EUR, JPY, AUD, CAD, CHF)
|
||||
|
||||
Returns:
|
||||
Dict with price, high, low, change, timestamp, etc.
|
||||
"""
|
||||
try:
|
||||
# Fetch CSV data from BullionVault
|
||||
# Format: /prices/CSV/{metal}/{currency}/{interval}/Full
|
||||
metal_code = self.METALS['gold']
|
||||
interval = self.INTERVALS['1d']
|
||||
|
||||
path = f"/prices/CSV/{metal_code}/{currency.upper()}/{interval}/Full"
|
||||
|
||||
csv_text = await self._fetch_csv(path)
|
||||
|
||||
# Parse CSV data
|
||||
price_data = self._parse_csv(csv_text)
|
||||
|
||||
if not price_data:
|
||||
raise ValueError("No price data available from BullionVault")
|
||||
|
||||
# Get latest price (first row after header)
|
||||
latest = price_data[0]
|
||||
|
||||
# Calculate daily statistics
|
||||
oz_prices = [row['oz_close'] for row in price_data if row['oz_close'] is not None]
|
||||
|
||||
if not oz_prices:
|
||||
raise ValueError("No valid price points")
|
||||
|
||||
current_price = latest['oz_close']
|
||||
daily_high = max([row['oz_high'] for row in price_data if row['oz_high'] is not None])
|
||||
daily_low = min([row['oz_low'] for row in price_data if row['oz_low'] is not None])
|
||||
|
||||
# Calculate change from last data point
|
||||
first_price = price_data[-1]['oz_close'] if len(price_data) > 1 else current_price
|
||||
change = current_price - first_price
|
||||
change_percent = (change / first_price * 100) if first_price else 0.0
|
||||
|
||||
timestamp = latest['timestamp']
|
||||
|
||||
result = {
|
||||
"price": round(current_price, 2),
|
||||
"price_kg": round(latest['kg_close'], 2),
|
||||
"open": round(first_price, 2),
|
||||
"high": round(daily_high, 2),
|
||||
"low": round(daily_low, 2),
|
||||
"previous_close": round(first_price, 2),
|
||||
"change": round(change, 2),
|
||||
"change_percent": round(change_percent, 4),
|
||||
"currency": currency.upper(),
|
||||
"unit": "per troy oz",
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"source": "BullionVault",
|
||||
"trading_day": timestamp.strftime("%Y-%m-%d"),
|
||||
"data_points": len(price_data)
|
||||
}
|
||||
|
||||
logger.info(f"✅ BullionVault gold price: {currency} ${current_price:.2f}/oz")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"❌ BullionVault HTTP error: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault price fetch failed: {e}")
|
||||
raise
|
||||
|
||||
async def _fetch_csv(self, path: str) -> str:
|
||||
"""Fetch CSV data from BullionVault with simple retry logic."""
|
||||
|
||||
last_exception: Optional[Exception] = None
|
||||
base = self.base_url.rstrip("/")
|
||||
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
try:
|
||||
url = path if path.startswith("http") else f"{base}{path}"
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
csv_text = response.text.strip()
|
||||
if not csv_text:
|
||||
raise ValueError("BullionVault returned empty response body")
|
||||
|
||||
logger.debug(
|
||||
"Fetched BullionVault CSV successfully",
|
||||
extra={"path": url, "attempt": attempt},
|
||||
)
|
||||
return csv_text
|
||||
|
||||
except (httpx.RequestError, httpx.HTTPStatusError, ValueError) as exc:
|
||||
last_exception = exc
|
||||
logger.warning(
|
||||
"BullionVault CSV fetch attempt failed",
|
||||
extra={
|
||||
"path": url if "url" in locals() else path,
|
||||
"attempt": attempt,
|
||||
"max_attempts": self.max_retries,
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
|
||||
if attempt < self.max_retries:
|
||||
await asyncio.sleep(self.retry_backoff_seconds * attempt)
|
||||
|
||||
assert last_exception is not None
|
||||
raise last_exception
|
||||
|
||||
def _parse_csv(self, csv_text: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse BullionVault CSV response
|
||||
|
||||
CSV format:
|
||||
"Date",High (kg),Low (kg),Close (kg),,High (troy oz),Low (troy oz),Close (troy oz),
|
||||
"05:10:00 23-Nov-2025",130702.99,130702.99,130702.99,,4065.32,4065.32,4065.32,
|
||||
|
||||
Args:
|
||||
csv_text: Raw CSV text from BullionVault
|
||||
|
||||
Returns:
|
||||
List of price dictionaries
|
||||
"""
|
||||
result = []
|
||||
|
||||
# Parse CSV
|
||||
reader = csv.reader(io.StringIO(csv_text))
|
||||
|
||||
# Skip header
|
||||
next(reader, None)
|
||||
|
||||
for row in reader:
|
||||
if len(row) < 8:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Parse date/time: "HH:MM:SS DD-Mon-YYYY"
|
||||
date_str = row[0].strip('"')
|
||||
timestamp = datetime.strptime(date_str, "%H:%M:%S %d-%b-%Y").replace(tzinfo=timezone.utc)
|
||||
|
||||
# Extract prices (kg and oz)
|
||||
kg_high = self._to_float(row[1])
|
||||
kg_low = self._to_float(row[2])
|
||||
kg_close = self._to_float(row[3])
|
||||
|
||||
oz_high = self._to_float(row[5])
|
||||
oz_low = self._to_float(row[6])
|
||||
oz_close = self._to_float(row[7])
|
||||
|
||||
result.append({
|
||||
'timestamp': timestamp,
|
||||
'kg_high': kg_high,
|
||||
'kg_low': kg_low,
|
||||
'kg_close': kg_close,
|
||||
'oz_high': oz_high,
|
||||
'oz_low': oz_low,
|
||||
'oz_close': oz_close
|
||||
})
|
||||
|
||||
except (ValueError, IndexError) as e:
|
||||
logger.warning(f"Skipping malformed CSV row: {row} - {e}")
|
||||
continue
|
||||
|
||||
result.sort(key=lambda entry: entry['timestamp'], reverse=True)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _to_float(value: Optional[str]) -> Optional[float]:
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
async def get_gold_history(
|
||||
self,
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get historical gold price data from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code
|
||||
timeframe: Time range (10m, 1h, 6h, 1d, 1w, 1m, 3m, 1y, 5y, 20y)
|
||||
limit: Maximum number of data points to return
|
||||
|
||||
Returns:
|
||||
List of OHLC data points
|
||||
"""
|
||||
try:
|
||||
metal_code = self.METALS['gold']
|
||||
interval = self.INTERVALS.get(timeframe, self.INTERVALS['1d'])
|
||||
|
||||
path = f"/prices/CSV/{metal_code}/{currency.upper()}/{interval}/Full"
|
||||
|
||||
csv_text = await self._fetch_csv(path)
|
||||
|
||||
# Parse CSV data
|
||||
price_data = self._parse_csv(csv_text)
|
||||
|
||||
# Apply limit if specified
|
||||
if limit and len(price_data) > limit:
|
||||
price_data = price_data[:limit]
|
||||
|
||||
# Convert to OHLCV format
|
||||
result = []
|
||||
for point in price_data:
|
||||
result.append({
|
||||
"timestamp": point['timestamp'].isoformat(),
|
||||
"time": int(point['timestamp'].timestamp()),
|
||||
"open": point['oz_close'], # BullionVault doesn't provide open, use close
|
||||
"high": point['oz_high'],
|
||||
"low": point['oz_low'],
|
||||
"close": point['oz_close'],
|
||||
"volume": 0, # BullionVault doesn't provide volume
|
||||
})
|
||||
|
||||
logger.info(f"✅ BullionVault history: {len(result)} points for {timeframe}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault history fetch failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_multi_currency_prices(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get current gold prices in multiple currencies
|
||||
|
||||
Returns:
|
||||
Dict mapping currency codes to price data
|
||||
"""
|
||||
currencies = ["USD", "GBP", "EUR", "JPY", "AUD", "CAD", "CHF"]
|
||||
tasks = [self.get_current_gold_price(curr) for curr in currencies]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
prices = {}
|
||||
for curr, result in zip(currencies, results):
|
||||
if isinstance(result, dict):
|
||||
prices[curr] = result
|
||||
else:
|
||||
logger.warning(f"Failed to fetch {curr} price: {result}")
|
||||
|
||||
return prices
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
if self._owns_client:
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Global instance
|
||||
bullionvault_service = BullionVaultService()
|
||||
|
||||
|
||||
# Convenience functions
|
||||
async def get_bullionvault_gold_price(currency: str = "USD") -> Dict[str, Any]:
|
||||
"""Get current gold price from BullionVault"""
|
||||
return await bullionvault_service.get_current_gold_price(currency)
|
||||
|
||||
|
||||
async def get_bullionvault_history(
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get historical gold prices from BullionVault"""
|
||||
return await bullionvault_service.get_gold_history(currency, timeframe, limit)
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
BullionVault Gold Price Service
|
||||
Fetches real-time gold prices from BullionVault's chart data API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BullionVaultService:
|
||||
"""
|
||||
Service to fetch gold prices from BullionVault
|
||||
BullionVault provides accurate, real-time precious metals prices
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = httpx.AsyncClient(timeout=15.0)
|
||||
self.base_url = "https://www.bullionvault.com"
|
||||
# BullionVault chart data endpoint
|
||||
self.chart_data_url = f"{self.base_url}/chart/chart-data.json"
|
||||
|
||||
async def get_current_gold_price(self, currency: str = "USD") -> Dict[str, Any]:
|
||||
"""
|
||||
Get current gold spot price from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code (USD, GBP, EUR, JPY, AUD, CAD, CHF)
|
||||
|
||||
Returns:
|
||||
Dict with price, high, low, change, timestamp, etc.
|
||||
"""
|
||||
try:
|
||||
# Fetch latest gold price data
|
||||
params = {
|
||||
"bullion": "gold",
|
||||
"currency": currency.upper(),
|
||||
"timeframe": "1d", # 1 day for recent data
|
||||
"chartType": "line"
|
||||
}
|
||||
|
||||
response = await self.client.get(self.chart_data_url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data or "prices" not in data:
|
||||
raise ValueError("Invalid response from BullionVault")
|
||||
|
||||
prices = data["prices"]
|
||||
if not prices:
|
||||
raise ValueError("No price data available")
|
||||
|
||||
# Get latest price point
|
||||
latest = prices[-1]
|
||||
|
||||
# Calculate daily statistics
|
||||
daily_prices = [p[1] for p in prices if p[1] is not None]
|
||||
|
||||
if not daily_prices:
|
||||
raise ValueError("No valid price points")
|
||||
|
||||
current_price = latest[1] # Price per ounce
|
||||
daily_high = max(daily_prices)
|
||||
daily_low = min(daily_prices)
|
||||
|
||||
# Calculate change from first price of day
|
||||
first_price = prices[0][1]
|
||||
change = current_price - first_price
|
||||
change_percent = (change / first_price * 100) if first_price else 0.0
|
||||
|
||||
# Convert timestamp (BullionVault uses milliseconds)
|
||||
timestamp_ms = latest[0]
|
||||
timestamp = datetime.fromtimestamp(timestamp_ms / 1000.0)
|
||||
|
||||
result = {
|
||||
"price": round(current_price, 2),
|
||||
"open": round(first_price, 2),
|
||||
"high": round(daily_high, 2),
|
||||
"low": round(daily_low, 2),
|
||||
"previous_close": round(first_price, 2),
|
||||
"change": round(change, 2),
|
||||
"change_percent": round(change_percent, 4),
|
||||
"currency": currency.upper(),
|
||||
"unit": "per troy oz",
|
||||
"timestamp": timestamp.isoformat(),
|
||||
"timestamp_ms": timestamp_ms,
|
||||
"source": "BullionVault",
|
||||
"trading_day": timestamp.strftime("%Y-%m-%d"),
|
||||
"data_points": len(prices)
|
||||
}
|
||||
|
||||
logger.info(f"✅ BullionVault gold price: {currency} ${current_price:.2f}/oz")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"❌ BullionVault HTTP error: {e}")
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault price fetch failed: {e}")
|
||||
raise
|
||||
|
||||
async def get_gold_history(
|
||||
self,
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get historical gold price data from BullionVault
|
||||
|
||||
Args:
|
||||
currency: Currency code
|
||||
timeframe: Time range (10m, 1h, 6h, 1d, 1w, 1m, 1q, 1y, 5y, 20y)
|
||||
limit: Maximum number of data points to return
|
||||
|
||||
Returns:
|
||||
List of OHLC data points
|
||||
"""
|
||||
try:
|
||||
params = {
|
||||
"bullion": "gold",
|
||||
"currency": currency.upper(),
|
||||
"timeframe": timeframe,
|
||||
"chartType": "hlc" # High-Low-Close for OHLC data
|
||||
}
|
||||
|
||||
response = await self.client.get(self.chart_data_url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data or "prices" not in data:
|
||||
return []
|
||||
|
||||
prices = data["prices"]
|
||||
|
||||
# Apply limit if specified
|
||||
if limit and len(prices) > limit:
|
||||
prices = prices[-limit:]
|
||||
|
||||
# Convert to OHLCV format
|
||||
result = []
|
||||
for point in prices:
|
||||
if len(point) >= 4: # [timestamp, open, high, low, close]
|
||||
timestamp_ms = point[0]
|
||||
result.append({
|
||||
"timestamp": datetime.fromtimestamp(timestamp_ms / 1000.0).isoformat(),
|
||||
"time": int(timestamp_ms / 1000),
|
||||
"open": float(point[1]) if point[1] is not None else 0.0,
|
||||
"high": float(point[2]) if point[2] is not None else 0.0,
|
||||
"low": float(point[3]) if point[3] is not None else 0.0,
|
||||
"close": float(point[4]) if len(point) > 4 and point[4] is not None else float(point[1]),
|
||||
"volume": 0, # BullionVault doesn't provide volume
|
||||
})
|
||||
|
||||
logger.info(f"✅ BullionVault history: {len(result)} points for {timeframe}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ BullionVault history fetch failed: {e}")
|
||||
return []
|
||||
|
||||
async def get_multi_currency_prices(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Get current gold prices in multiple currencies
|
||||
|
||||
Returns:
|
||||
Dict mapping currency codes to price data
|
||||
"""
|
||||
currencies = ["USD", "GBP", "EUR", "JPY", "AUD", "CAD", "CHF"]
|
||||
tasks = [self.get_current_gold_price(curr) for curr in currencies]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
prices = {}
|
||||
for curr, result in zip(currencies, results):
|
||||
if isinstance(result, dict):
|
||||
prices[curr] = result
|
||||
else:
|
||||
logger.warning(f"Failed to fetch {curr} price: {result}")
|
||||
|
||||
return prices
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Global instance
|
||||
bullionvault_service = BullionVaultService()
|
||||
|
||||
|
||||
# Convenience functions
|
||||
async def get_bullionvault_gold_price(currency: str = "USD") -> Dict[str, Any]:
|
||||
"""Get current gold price from BullionVault"""
|
||||
return await bullionvault_service.get_current_gold_price(currency)
|
||||
|
||||
|
||||
async def get_bullionvault_history(
|
||||
currency: str = "USD",
|
||||
timeframe: str = "1d",
|
||||
limit: Optional[int] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get historical gold prices from BullionVault"""
|
||||
return await bullionvault_service.get_gold_history(currency, timeframe, limit)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Robust Gold Price Fetcher with Multiple Data Sources and Fallback
|
||||
Ensures accurate real-time gold pricing with redundancy
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GoldPriceFetcher:
|
||||
"""
|
||||
Multi-source gold price fetcher with automatic fallback
|
||||
|
||||
Data Sources (in priority order):
|
||||
1. Alpha Vantage - GLD ETF (reliable, free tier)
|
||||
2. Twelve Data API (if available)
|
||||
3. Yahoo Finance (backup)
|
||||
4. Static fallback to reasonable estimate
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.client = httpx.AsyncClient(timeout=10.0)
|
||||
# GLD ETF tracks ~1/10th of gold spot price
|
||||
self.gld_multiplier = 10.0
|
||||
# Gold futures (GC) are 100oz contracts, but quote is per oz
|
||||
self.gc_multiplier = 1.0
|
||||
|
||||
async def get_current_gold_price(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current gold price with automatic fallback through multiple sources
|
||||
|
||||
Returns:
|
||||
Dict with: price, source, timestamp, high_24h, low_24h, change_percent
|
||||
"""
|
||||
# Try Alpha Vantage GLD first (most reliable)
|
||||
try:
|
||||
result = await self._fetch_from_alpha_vantage_gld()
|
||||
if result:
|
||||
logger.info(f"✅ Gold price from Alpha Vantage GLD: ${result['price']:.2f}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Alpha Vantage GLD failed: {e}")
|
||||
|
||||
# Try Twelve Data if available
|
||||
try:
|
||||
result = await self._fetch_from_twelve_data()
|
||||
if result:
|
||||
logger.info(f"✅ Gold price from Twelve Data: ${result['price']:.2f}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Twelve Data failed: {e}")
|
||||
|
||||
# Try alternative free sources
|
||||
try:
|
||||
result = await self._fetch_from_metals_api()
|
||||
if result:
|
||||
logger.info(f"✅ Gold price from Metals-API: ${result['price']:.2f}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Metals-API failed: {e}")
|
||||
|
||||
# Last resort: return estimated price with warning
|
||||
logger.error("⚠️ All gold price sources failed, using estimated price")
|
||||
return self._get_fallback_price()
|
||||
|
||||
async def _fetch_from_alpha_vantage_gld(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch from Alpha Vantage using GLD ETF as proxy
|
||||
GLD tracks gold at ~1/10th spot price
|
||||
"""
|
||||
api_key = settings.ALPHA_VANTAGE_API_KEY or "M1S58UEM42CQD31T"
|
||||
url = f"https://www.alphavantage.co/query?function=GLOBAL_QUOTE&symbol=GLD&apikey={api_key}"
|
||||
|
||||
response = await self.client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if "Global Quote" not in data or not data["Global Quote"]:
|
||||
return None
|
||||
|
||||
quote = data["Global Quote"]
|
||||
gld_price = float(quote.get("05. price", 0))
|
||||
|
||||
if gld_price == 0:
|
||||
return None
|
||||
|
||||
# Convert GLD price to gold spot price (multiply by 10)
|
||||
gold_price = gld_price * self.gld_multiplier
|
||||
|
||||
return {
|
||||
"price": gold_price,
|
||||
"open": float(quote.get("02. open", 0)) * self.gld_multiplier,
|
||||
"high": float(quote.get("03. high", 0)) * self.gld_multiplier,
|
||||
"low": float(quote.get("04. low", 0)) * self.gld_multiplier,
|
||||
"volume": int(quote.get("06. volume", 0)),
|
||||
"previous_close": float(quote.get("08. previous close", 0)) * self.gld_multiplier,
|
||||
"change": float(quote.get("09. change", 0)) * self.gld_multiplier,
|
||||
"change_percent": quote.get("10. change percent", "0%"),
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"source": "Alpha Vantage (GLD ETF)",
|
||||
"trading_day": quote.get("07. latest trading day", ""),
|
||||
}
|
||||
|
||||
async def _fetch_from_twelve_data(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch from Twelve Data API (if API key available)
|
||||
They have direct XAU/USD forex pair
|
||||
"""
|
||||
# Twelve Data would require API key setup
|
||||
# Placeholder for now
|
||||
return None
|
||||
|
||||
async def _fetch_from_metals_api(self) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch from Metals-API.com free tier
|
||||
Provides direct gold spot prices
|
||||
"""
|
||||
try:
|
||||
# Free tier endpoint (limited requests)
|
||||
url = "https://metals-api.com/api/latest"
|
||||
params = {
|
||||
"access_key": "your_key_here", # Would need API key
|
||||
"base": "USD",
|
||||
"symbols": "XAU"
|
||||
}
|
||||
|
||||
# Skip if no key configured
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _get_fallback_price(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return reasonable estimated gold price when all sources fail
|
||||
Based on typical 2025 gold trading range
|
||||
"""
|
||||
# Conservative estimate for late 2025 gold prices
|
||||
estimated_price = 3800.0 # Mid-range estimate
|
||||
|
||||
return {
|
||||
"price": estimated_price,
|
||||
"open": estimated_price,
|
||||
"high": estimated_price * 1.01,
|
||||
"low": estimated_price * 0.99,
|
||||
"volume": 0,
|
||||
"previous_close": estimated_price,
|
||||
"change": 0.0,
|
||||
"change_percent": "0%",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"source": "FALLBACK_ESTIMATE",
|
||||
"trading_day": datetime.utcnow().strftime("%Y-%m-%d"),
|
||||
"warning": "⚠️ Using estimated price - all data sources unavailable"
|
||||
}
|
||||
|
||||
async def get_intraday_data(self, interval: str = "5min", limit: int = 100) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Get intraday gold price data
|
||||
|
||||
Args:
|
||||
interval: Time interval (1min, 5min, 15min, 30min, 60min)
|
||||
limit: Number of data points to return
|
||||
|
||||
Returns:
|
||||
List of OHLCV data points
|
||||
"""
|
||||
try:
|
||||
return await self._fetch_intraday_alpha_vantage(interval, limit)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to fetch intraday data: {e}")
|
||||
return []
|
||||
|
||||
async def _fetch_intraday_alpha_vantage(self, interval: str, limit: int) -> list[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch intraday data from Alpha Vantage
|
||||
Using GLD as proxy since XAU/USD intraday is premium
|
||||
"""
|
||||
api_key = settings.ALPHA_VANTAGE_API_KEY or "M1S58UEM42CQD31T"
|
||||
url = f"https://www.alphavantage.co/query"
|
||||
params = {
|
||||
"function": "TIME_SERIES_INTRADAY",
|
||||
"symbol": "GLD",
|
||||
"interval": interval,
|
||||
"apikey": api_key,
|
||||
"outputsize": "compact" # Last 100 data points
|
||||
}
|
||||
|
||||
response = await self.client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
time_series_key = f"Time Series ({interval})"
|
||||
if time_series_key not in data:
|
||||
return []
|
||||
|
||||
time_series = data[time_series_key]
|
||||
|
||||
# Convert to OHLCV format and apply gold multiplier
|
||||
result = []
|
||||
for timestamp, values in list(time_series.items())[:limit]:
|
||||
result.append({
|
||||
"timestamp": timestamp,
|
||||
"time": int(datetime.fromisoformat(timestamp.replace("Z", "+00:00")).timestamp()),
|
||||
"open": float(values["1. open"]) * self.gld_multiplier,
|
||||
"high": float(values["2. high"]) * self.gld_multiplier,
|
||||
"low": float(values["3. low"]) * self.gld_multiplier,
|
||||
"close": float(values["4. close"]) * self.gld_multiplier,
|
||||
"volume": int(values["5. volume"]),
|
||||
})
|
||||
|
||||
return sorted(result, key=lambda x: x["time"])
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self.client.aclose()
|
||||
|
||||
|
||||
# Global instance
|
||||
gold_price_fetcher = GoldPriceFetcher()
|
||||
|
||||
|
||||
# Convenience functions for backward compatibility
|
||||
async def get_current_gold_price() -> Dict[str, Any]:
|
||||
"""Get current gold spot price"""
|
||||
return await gold_price_fetcher.get_current_gold_price()
|
||||
|
||||
|
||||
async def get_gold_intraday(interval: str = "5min", limit: int = 100) -> list[Dict[str, Any]]:
|
||||
"""Get intraday gold price data"""
|
||||
return await gold_price_fetcher.get_intraday_data(interval, limit)
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
GOLDPRICE_URL_TEMPLATE = "https://data-asg.goldprice.org/dbXRates/{currency}"
|
||||
DEFAULT_CURRENCY = "USD"
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def fetch_goldprice_quote(currency: str = DEFAULT_CURRENCY) -> Optional[dict]:
|
||||
url = GOLDPRICE_URL_TEMPLATE.format(currency=currency.upper())
|
||||
async with httpx.AsyncClient(timeout=10.0, headers=HEADERS) as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
items = data.get("items") or []
|
||||
if not items:
|
||||
return None
|
||||
|
||||
quote = items[0]
|
||||
xau_price = quote.get("xauPrice")
|
||||
if xau_price is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"price": float(xau_price),
|
||||
"change": float(quote.get("chgXau") or 0.0),
|
||||
"change_percent": float(quote.get("pcXau") or 0.0),
|
||||
"previous_close": float(quote.get("xauClose") or 0.0),
|
||||
"timestamp_ms": int(data.get("ts") or 0),
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.schemas.schemas import PriceData
|
||||
|
||||
YAHOO_QUOTE_URL = "https://query1.finance.yahoo.com/v7/finance/quote"
|
||||
YAHOO_CHART_URL = "https://query1.finance.yahoo.com/v8/finance/chart/{symbol}"
|
||||
YAHOO_SYMBOL = "XAUUSD=X"
|
||||
YAHOO_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0 Safari/537.36",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
|
||||
async def fetch_yahoo_quote(symbol: str = YAHOO_SYMBOL) -> Optional[dict]:
|
||||
params = {"symbols": symbol}
|
||||
async with httpx.AsyncClient(timeout=20.0, headers=YAHOO_HEADERS) as client:
|
||||
response = await client.get(YAHOO_QUOTE_URL, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
result = (data.get("quoteResponse", {}) or {}).get("result", [])
|
||||
if not result:
|
||||
return None
|
||||
quote = result[0]
|
||||
def _safe_float(value: Optional[float], default: float = 0.0) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
return {
|
||||
"symbol": symbol,
|
||||
"price": _safe_float(quote.get("regularMarketPrice"), default=0.0),
|
||||
"high": _safe_float(quote.get("regularMarketDayHigh")),
|
||||
"low": _safe_float(quote.get("regularMarketDayLow")),
|
||||
"volume": _safe_float(quote.get("regularMarketVolume"), default=0.0),
|
||||
"previous_close": _safe_float(quote.get("regularMarketPreviousClose"), default=0.0),
|
||||
"timestamp": int(quote.get("regularMarketTime") or 0),
|
||||
}
|
||||
|
||||
|
||||
def _interval_range_for_chart(interval: str) -> tuple[str, str]:
|
||||
normalized = interval.lower()
|
||||
mapping = {
|
||||
"1m": ("1m", "1d"),
|
||||
"1min": ("1m", "1d"),
|
||||
"5m": ("5m", "5d"),
|
||||
"5min": ("5m", "5d"),
|
||||
"15m": ("15m", "1mo"),
|
||||
"15min": ("15m", "1mo"),
|
||||
"30m": ("30m", "1mo"),
|
||||
"30min": ("30m", "1mo"),
|
||||
"60m": ("60m", "1y"),
|
||||
"60min": ("60m", "1y"),
|
||||
"daily": ("1d", "5y"),
|
||||
}
|
||||
return mapping.get(normalized, ("1m", "1d"))
|
||||
|
||||
|
||||
async def fetch_yahoo_ohlcv(symbol: str = YAHOO_SYMBOL, interval: str = "1m") -> List[PriceData]:
|
||||
interval_key, range_key = _interval_range_for_chart(interval)
|
||||
url = YAHOO_CHART_URL.format(symbol=symbol)
|
||||
params = {"interval": interval_key, "range": range_key, "includePrePost": "false"}
|
||||
async with httpx.AsyncClient(timeout=20.0, headers=YAHOO_HEADERS) as client:
|
||||
response = await client.get(url, params=params)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
chart = (data.get("chart") or {}).get("result") or []
|
||||
if not chart:
|
||||
return []
|
||||
result = chart[0]
|
||||
timestamps = result.get("timestamp") or []
|
||||
indicators = (result.get("indicators") or {}).get("quote") or []
|
||||
if not indicators:
|
||||
return []
|
||||
quote = indicators[0]
|
||||
opens = quote.get("open") or []
|
||||
highs = quote.get("high") or []
|
||||
lows = quote.get("low") or []
|
||||
closes = quote.get("close") or []
|
||||
volumes = quote.get("volume") or []
|
||||
|
||||
price_data: List[PriceData] = []
|
||||
for idx, ts in enumerate(timestamps):
|
||||
open_price = opens[idx] if idx < len(opens) else None
|
||||
high_price = highs[idx] if idx < len(highs) else None
|
||||
low_price = lows[idx] if idx < len(lows) else None
|
||||
close_price = closes[idx] if idx < len(closes) else None
|
||||
if None in (open_price, high_price, low_price, close_price):
|
||||
continue
|
||||
volume_val = volumes[idx] if idx < len(volumes) else 0.0
|
||||
price_data.append(
|
||||
PriceData(
|
||||
time=int(ts),
|
||||
open=float(open_price),
|
||||
high=float(high_price),
|
||||
low=float(low_price),
|
||||
close=float(close_price),
|
||||
volume=float(volume_val or 0.0),
|
||||
)
|
||||
)
|
||||
return price_data
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import List, Optional
|
||||
|
||||
import pandas as pd
|
||||
import yfinance as yf
|
||||
|
||||
from app.schemas.schemas import PriceData
|
||||
|
||||
YA_SYMBOL = "XAUUSD=X"
|
||||
|
||||
|
||||
def _format_dataframe(df: pd.DataFrame) -> List[PriceData]:
|
||||
rows: List[PriceData] = []
|
||||
if df.empty:
|
||||
return rows
|
||||
df = df.dropna(subset=["Open", "High", "Low", "Close"])
|
||||
for idx, row in df.iterrows():
|
||||
timestamp = int(pd.Timestamp(idx).timestamp())
|
||||
rows.append(
|
||||
PriceData(
|
||||
time=timestamp,
|
||||
open=float(row["Open"]),
|
||||
high=float(row["High"]),
|
||||
low=float(row["Low"]),
|
||||
close=float(row["Close"]),
|
||||
volume=float(row.get("Volume", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
async def fetch_yfinance_history(
|
||||
symbol: str = YA_SYMBOL,
|
||||
interval: str = "1m",
|
||||
period: str = "1d",
|
||||
start: Optional[str] = None,
|
||||
end: Optional[str] = None,
|
||||
) -> List[PriceData]:
|
||||
def _download() -> pd.DataFrame:
|
||||
return yf.download(
|
||||
symbol,
|
||||
interval=interval,
|
||||
period=None if start else period,
|
||||
start=start,
|
||||
end=end,
|
||||
progress=False,
|
||||
auto_adjust=False,
|
||||
threads=False,
|
||||
)
|
||||
|
||||
df = await asyncio.to_thread(_download)
|
||||
return _format_dataframe(df)
|
||||
|
||||
|
||||
async def fetch_yfinance_quote(symbol: str = YA_SYMBOL) -> Optional[dict]:
|
||||
rows = await fetch_yfinance_history(symbol=symbol, interval="1m", period="1d")
|
||||
if not rows:
|
||||
return None
|
||||
latest = rows[-1]
|
||||
previous = rows[-2] if len(rows) > 1 else latest
|
||||
return {
|
||||
"price": latest.close,
|
||||
"previous_close": previous.close,
|
||||
"high_24h": max(r.high for r in rows[-1440:]),
|
||||
"low_24h": min(r.low for r in rows[-1440:]),
|
||||
"volume": latest.volume or 0.0,
|
||||
"updated_at": latest.time,
|
||||
"rows": rows,
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Web search integration for fetching real-time gold market news.
|
||||
|
||||
This module provides functionality to search for recent gold market news
|
||||
using various search APIs. Currently supports:
|
||||
- DuckDuckGo search (free, no API key required)
|
||||
- Extensible for Tavily, SerpAPI, or other providers
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import json
|
||||
from typing import List, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
|
||||
class NewsSearchService:
|
||||
"""Service for fetching recent gold market news from the web."""
|
||||
|
||||
def __init__(self):
|
||||
self.timeout = 10.0
|
||||
|
||||
async def search_gold_news(self, query: str = "gold price XAU/USD", max_results: int = 5) -> List[Dict]:
|
||||
"""
|
||||
Search for recent gold market news.
|
||||
|
||||
Args:
|
||||
query: Search query (default: "gold price XAU/USD")
|
||||
max_results: Maximum number of results to return
|
||||
|
||||
Returns:
|
||||
List of news articles with title, snippet, url, and date
|
||||
"""
|
||||
try:
|
||||
# Use DuckDuckGo Instant Answer API (free, no key required)
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.get(
|
||||
"https://api.duckduckgo.com/",
|
||||
params={
|
||||
"q": query,
|
||||
"format": "json",
|
||||
"no_html": 1,
|
||||
"skip_disambig": 1,
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
results = []
|
||||
|
||||
# Extract related topics (news items)
|
||||
related_topics = data.get("RelatedTopics", [])
|
||||
for topic in related_topics[:max_results]:
|
||||
if isinstance(topic, dict) and "Text" in topic:
|
||||
results.append({
|
||||
"title": topic.get("Text", "")[:100],
|
||||
"snippet": topic.get("Text", ""),
|
||||
"url": topic.get("FirstURL", ""),
|
||||
"source": "DuckDuckGo",
|
||||
"date": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
print(f"News search error: {e}")
|
||||
|
||||
# Return fallback generic news context
|
||||
return self._get_fallback_news()
|
||||
|
||||
def _get_fallback_news(self) -> List[Dict]:
|
||||
"""Return generic gold market context when search fails."""
|
||||
return [
|
||||
{
|
||||
"title": "Gold Market Overview",
|
||||
"snippet": "Gold prices influenced by USD strength, inflation expectations, and geopolitical events",
|
||||
"url": "",
|
||||
"source": "General Context",
|
||||
"date": datetime.now().isoformat()
|
||||
},
|
||||
{
|
||||
"title": "Key Gold Drivers",
|
||||
"snippet": "Federal Reserve policy, US Dollar Index (DXY), real yields, and global risk sentiment",
|
||||
"url": "",
|
||||
"source": "General Context",
|
||||
"date": datetime.now().isoformat()
|
||||
}
|
||||
]
|
||||
|
||||
async def get_news_summary(self, max_items: int = 3) -> str:
|
||||
"""
|
||||
Get a formatted summary of recent gold news for AI prompts.
|
||||
|
||||
Args:
|
||||
max_items: Maximum number of news items to include
|
||||
|
||||
Returns:
|
||||
Formatted string with news headlines and snippets
|
||||
"""
|
||||
news_items = await self.search_gold_news(max_results=max_items)
|
||||
|
||||
if not news_items:
|
||||
return "📰 Recent News: No recent news available. Analysis based on technical factors only."
|
||||
|
||||
summary = "📰 RECENT MARKET NEWS:\n"
|
||||
for i, item in enumerate(news_items, 1):
|
||||
summary += f"{i}. {item['title']}\n"
|
||||
if item['snippet'] and item['snippet'] != item['title']:
|
||||
summary += f" {item['snippet'][:150]}...\n"
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
# Global service instance
|
||||
news_search_service = NewsSearchService()
|
||||
@@ -0,0 +1,326 @@
|
||||
"""
|
||||
Ollama Local AI Service
|
||||
|
||||
Provides local AI capabilities for lightweight tasks like:
|
||||
- Quick sentiment analysis
|
||||
- Simple text summarization
|
||||
- Fast pattern classification
|
||||
- Embeddings generation
|
||||
|
||||
Falls back to OpenRouter for complex tasks.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OllamaService:
|
||||
"""Service for local AI using Ollama."""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = settings.OLLAMA_BASE_URL
|
||||
self.model = settings.OLLAMA_MODEL
|
||||
self.embed_model = settings.OLLAMA_MODEL_EMBED
|
||||
self.timeout = settings.OLLAMA_TIMEOUT
|
||||
self._available = None # Cached availability status
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""Check if Ollama is running and has the required model."""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{self.base_url}/api/tags")
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
models = [m["name"] for m in data.get("models", [])]
|
||||
self._available = self.model in models or any(self.model.split(":")[0] in m for m in models)
|
||||
return self._available
|
||||
except Exception as e:
|
||||
logger.debug(f"Ollama not available: {e}")
|
||||
self._available = False
|
||||
return False
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 500,
|
||||
model: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Generate text using local Ollama model.
|
||||
|
||||
Args:
|
||||
prompt: The user prompt
|
||||
system: Optional system prompt
|
||||
temperature: Sampling temperature (0-1)
|
||||
max_tokens: Maximum tokens to generate
|
||||
model: Override default model
|
||||
|
||||
Returns:
|
||||
Generated text or None if failed
|
||||
"""
|
||||
if not await self.is_available():
|
||||
logger.warning("Ollama not available, skipping local generation")
|
||||
return None
|
||||
|
||||
use_model = model or self.model
|
||||
|
||||
payload = {
|
||||
"model": use_model,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
if system:
|
||||
payload["system"] = system
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/generate",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("response", "").strip()
|
||||
else:
|
||||
logger.error(f"Ollama generate failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama generate error: {e}")
|
||||
return None
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 500,
|
||||
model: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Chat completion using local Ollama model.
|
||||
|
||||
Args:
|
||||
messages: List of {"role": "user/assistant/system", "content": "..."}
|
||||
temperature: Sampling temperature
|
||||
max_tokens: Maximum tokens to generate
|
||||
model: Override default model
|
||||
|
||||
Returns:
|
||||
Assistant response or None if failed
|
||||
"""
|
||||
if not await self.is_available():
|
||||
return None
|
||||
|
||||
use_model = model or self.model
|
||||
|
||||
payload = {
|
||||
"model": use_model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": temperature,
|
||||
"num_predict": max_tokens,
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/chat",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("message", {}).get("content", "").strip()
|
||||
else:
|
||||
logger.error(f"Ollama chat failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama chat error: {e}")
|
||||
return None
|
||||
|
||||
async def embed(
|
||||
self,
|
||||
text: str,
|
||||
model: Optional[str] = None
|
||||
) -> Optional[List[float]]:
|
||||
"""
|
||||
Generate embeddings using local model.
|
||||
|
||||
Args:
|
||||
text: Text to embed
|
||||
model: Override default embedding model
|
||||
|
||||
Returns:
|
||||
Embedding vector or None if failed
|
||||
"""
|
||||
if not await self.is_available():
|
||||
return None
|
||||
|
||||
use_model = model or self.embed_model
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.post(
|
||||
f"{self.base_url}/api/embeddings",
|
||||
json={"model": use_model, "prompt": text}
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return data.get("embedding")
|
||||
else:
|
||||
logger.error(f"Ollama embed failed: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama embed error: {e}")
|
||||
return None
|
||||
|
||||
async def quick_sentiment(self, text: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Quick sentiment analysis using local model.
|
||||
Optimized for speed over accuracy.
|
||||
|
||||
Args:
|
||||
text: Text to analyze
|
||||
|
||||
Returns:
|
||||
{"sentiment": "positive/negative/neutral", "confidence": 0.0-1.0}
|
||||
"""
|
||||
system = """You are a sentiment analyzer. Respond ONLY with JSON in this exact format:
|
||||
{"sentiment": "positive" or "negative" or "neutral", "confidence": 0.0 to 1.0}
|
||||
No other text."""
|
||||
|
||||
prompt = f"Analyze the sentiment of this text:\n\n{text[:500]}" # Limit input
|
||||
|
||||
result = await self.generate(
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
temperature=0.1,
|
||||
max_tokens=50
|
||||
)
|
||||
|
||||
if result:
|
||||
try:
|
||||
import json
|
||||
# Try to extract JSON from response
|
||||
if "{" in result:
|
||||
json_str = result[result.find("{"):result.rfind("}")+1]
|
||||
return json.loads(json_str)
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
async def quick_classify(
|
||||
self,
|
||||
text: str,
|
||||
categories: List[str]
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Quick text classification into predefined categories.
|
||||
|
||||
Args:
|
||||
text: Text to classify
|
||||
categories: List of possible categories
|
||||
|
||||
Returns:
|
||||
Selected category or None
|
||||
"""
|
||||
categories_str = ", ".join(categories)
|
||||
system = f"You are a classifier. Respond with ONLY one of these categories: {categories_str}. No other text."
|
||||
|
||||
prompt = f"Classify this text into one category:\n\n{text[:500]}"
|
||||
|
||||
result = await self.generate(
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
temperature=0.1,
|
||||
max_tokens=20
|
||||
)
|
||||
|
||||
if result:
|
||||
# Find matching category
|
||||
result_lower = result.lower().strip()
|
||||
for cat in categories:
|
||||
if cat.lower() in result_lower:
|
||||
return cat
|
||||
|
||||
return None
|
||||
|
||||
async def quick_summarize(self, text: str, max_sentences: int = 2) -> Optional[str]:
|
||||
"""
|
||||
Quick text summarization.
|
||||
|
||||
Args:
|
||||
text: Text to summarize
|
||||
max_sentences: Maximum sentences in summary
|
||||
|
||||
Returns:
|
||||
Summary or None
|
||||
"""
|
||||
system = f"Summarize in {max_sentences} sentence(s) or less. Be concise and direct."
|
||||
|
||||
result = await self.generate(
|
||||
prompt=text[:2000], # Limit input
|
||||
system=system,
|
||||
temperature=0.3,
|
||||
max_tokens=150
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Global instance
|
||||
ollama_service = OllamaService()
|
||||
|
||||
|
||||
async def get_ai_response(
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
use_local: bool = True,
|
||||
fallback_to_cloud: bool = True
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Unified AI response function that tries local first, then cloud.
|
||||
|
||||
Args:
|
||||
prompt: User prompt
|
||||
system: System prompt
|
||||
use_local: Whether to try Ollama first
|
||||
fallback_to_cloud: Whether to fallback to OpenRouter if local fails
|
||||
|
||||
Returns:
|
||||
AI response or None
|
||||
"""
|
||||
# Try local first if enabled
|
||||
if use_local and settings.USE_LOCAL_AI:
|
||||
result = await ollama_service.generate(prompt, system)
|
||||
if result:
|
||||
logger.info("Used local Ollama for AI response")
|
||||
return result
|
||||
|
||||
# Fallback to cloud
|
||||
if fallback_to_cloud and settings.OPENROUTER_API_KEY:
|
||||
from app.services.openrouter import openrouter_service
|
||||
# This would need a simple generate method in openrouter
|
||||
logger.info("Falling back to OpenRouter for AI response")
|
||||
# For now, return None - full integration would go here
|
||||
pass
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,663 @@
|
||||
"""
|
||||
Trading Plan Templates
|
||||
Comprehensive trading plan generators for different schools and scenarios
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional
|
||||
from datetime import datetime, date
|
||||
from enum import Enum
|
||||
from .trading_schools import TradingSchool
|
||||
|
||||
|
||||
class PlanType(str, Enum):
|
||||
"""Types of trading plans"""
|
||||
INTRADAY = "intraday" # Day trading
|
||||
SWING = "swing" # Multi-day holds
|
||||
POSITION = "position" # Weeks to months
|
||||
SCALPING = "scalping" # Quick in/out
|
||||
EVENT_DRIVEN = "event_driven" # News/economic events
|
||||
RANGE_BOUND = "range_bound" # Sideways markets
|
||||
BREAKOUT = "breakout" # Breakout strategies
|
||||
REVERSAL = "reversal" # Reversal trading
|
||||
TREND_FOLLOWING = "trend_following" # Trend continuation
|
||||
|
||||
|
||||
class MarketCondition(str, Enum):
|
||||
"""Market conditions"""
|
||||
TRENDING_UP = "trending_up"
|
||||
TRENDING_DOWN = "trending_down"
|
||||
RANGING = "ranging"
|
||||
VOLATILE = "volatile"
|
||||
QUIET = "quiet"
|
||||
BREAKOUT_PENDING = "breakout_pending"
|
||||
POST_NEWS = "post_news"
|
||||
|
||||
|
||||
class PlanTemplates:
|
||||
"""Generate trading plans based on methodology and conditions"""
|
||||
|
||||
@staticmethod
|
||||
def generate_ict_smc_plan(
|
||||
current_price: float,
|
||||
market_condition: MarketCondition,
|
||||
session: str = "london_ny"
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate ICT/Smart Money Concepts trading plan"""
|
||||
|
||||
# Adaptive targets based on price
|
||||
atr_estimate = current_price * 0.015 # ~1.5% for gold
|
||||
|
||||
if session == "london":
|
||||
killzone_start = "03:00 EST"
|
||||
killzone_end = "05:00 EST"
|
||||
elif session == "ny":
|
||||
killzone_start = "08:00 EST"
|
||||
killzone_end = "11:00 EST"
|
||||
else:
|
||||
killzone_start = "08:00 EST"
|
||||
killzone_end = "11:00 EST"
|
||||
|
||||
return {
|
||||
"plan_type": PlanType.INTRADAY,
|
||||
"methodology": "ICT / Smart Money Concepts",
|
||||
"session_focus": session.upper(),
|
||||
"killzone": f"{killzone_start} - {killzone_end}",
|
||||
"analysis_framework": [
|
||||
"1. MARKET STRUCTURE ANALYSIS",
|
||||
" □ Identify current trend (HH/HL for bullish, LH/LL for bearish)",
|
||||
" □ Mark last BOS (Break of Structure) or ChoCh (Change of Character)",
|
||||
" □ Determine market state: Trending vs Ranging",
|
||||
"",
|
||||
"2. KEY LEVEL IDENTIFICATION",
|
||||
" □ Mark all Fair Value Gaps (FVG/Imbalance)",
|
||||
" □ Identify Order Blocks (last down candle before up move, vice versa)",
|
||||
" □ Note liquidity pools (equal highs/lows, stop hunts)",
|
||||
" □ Draw Premium/Discount zones (50% of range)",
|
||||
"",
|
||||
"3. ENTRY STRATEGY",
|
||||
" □ Wait for liquidity sweep (stop hunt)",
|
||||
" □ Price retraces to FVG or Order Block",
|
||||
" □ Optimal Trade Entry: 0.618-0.79 Fibonacci of last leg",
|
||||
" □ Enter during killzone for best probability",
|
||||
" □ Look for displacement after entry (strong move)",
|
||||
"",
|
||||
"4. RISK MANAGEMENT",
|
||||
" □ Stop loss: 5-10 points beyond Order Block",
|
||||
f" □ Position size: Based on ${atr_estimate:.2f} ATR",
|
||||
" □ First target: Next FVG or liquidity",
|
||||
" □ Final target: Opposite side liquidity or major structure",
|
||||
" □ Move stop to break-even after 1:1 RR"
|
||||
],
|
||||
"entry_checklist": [
|
||||
"✓ Market structure identified (bullish/bearish)",
|
||||
"✓ BOS or ChoCh confirmed",
|
||||
"✓ FVG or Order Block located",
|
||||
"✓ Waiting for retracement to OTE (0.618-0.79)",
|
||||
"✓ Entry during killzone hours",
|
||||
"✓ Clear invalidation point defined"
|
||||
],
|
||||
"trade_scenarios": {
|
||||
"bullish_setup": {
|
||||
"prerequisites": [
|
||||
"Price creates higher high (BOS)",
|
||||
"Retracement to bullish FVG or Order Block",
|
||||
"Entry at 0.618-0.79 Fib of last bullish leg"
|
||||
],
|
||||
"entry": f"${current_price - (atr_estimate * 0.7):.2f} (at OB/FVG)",
|
||||
"stop_loss": f"${current_price - (atr_estimate * 1.2):.2f} (below OB)",
|
||||
"target_1": f"${current_price + (atr_estimate * 0.8):.2f} (FVG fill)",
|
||||
"target_2": f"${current_price + (atr_estimate * 1.5):.2f} (liquidity)",
|
||||
"rr_ratio": "1:3"
|
||||
},
|
||||
"bearish_setup": {
|
||||
"prerequisites": [
|
||||
"Price creates lower low (BOS)",
|
||||
"Retracement to bearish FVG or Order Block",
|
||||
"Entry at 0.618-0.79 Fib of last bearish leg"
|
||||
],
|
||||
"entry": f"${current_price + (atr_estimate * 0.7):.2f} (at OB/FVG)",
|
||||
"stop_loss": f"${current_price + (atr_estimate * 1.2):.2f} (above OB)",
|
||||
"target_1": f"${current_price - (atr_estimate * 0.8):.2f} (FVG fill)",
|
||||
"target_2": f"${current_price - (atr_estimate * 1.5):.2f} (liquidity)",
|
||||
"rr_ratio": "1:3"
|
||||
}
|
||||
},
|
||||
"max_trades": 2,
|
||||
"max_daily_loss": 250,
|
||||
"notes": [
|
||||
"⚠️ CRITICAL RULES:",
|
||||
"• Only trade during killzone hours (highest probability)",
|
||||
"• Must have clear FVG or Order Block - no guessing",
|
||||
"• Wait for displacement (strong candle) for confirmation",
|
||||
"• Avoid trading during major news releases",
|
||||
"• If stopped out twice, done for the session",
|
||||
"",
|
||||
"📊 MARKET MAKER MODEL:",
|
||||
"1. Accumulation: Quiet consolidation, FVG formation",
|
||||
"2. Manipulation: Liquidity sweep (stop hunt) against trend",
|
||||
"3. Distribution: True move in intended direction",
|
||||
"",
|
||||
"🎯 OPTIMAL TRADE ENTRY (OTE):",
|
||||
"• 0.618 Fib: Conservative entry",
|
||||
"• 0.705 Fib: Sweet spot",
|
||||
"• 0.79 Fib: Aggressive entry (higher risk)",
|
||||
"",
|
||||
"💡 PRO TIPS:",
|
||||
"• London session: Watch for Judas swing (false move)",
|
||||
"• NY session: Strongest moves, follow London direction",
|
||||
"• Avoid Asian session: Low liquidity, choppy",
|
||||
"• Best setups: Monday-Thursday (avoid Friday chop)"
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_wyckoff_plan(
|
||||
current_price: float,
|
||||
market_condition: MarketCondition
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate Wyckoff Method trading plan"""
|
||||
|
||||
range_size = current_price * 0.03 # 3% trading range estimate
|
||||
|
||||
return {
|
||||
"plan_type": PlanType.SWING,
|
||||
"methodology": "Wyckoff Method",
|
||||
"analysis_framework": [
|
||||
"1. PHASE IDENTIFICATION",
|
||||
" □ Accumulation (PS → SC → AR → ST → Spring → Test → SOS → LPS → BU)",
|
||||
" □ Markup (Uptrend with re-accumulation phases)",
|
||||
" □ Distribution (PSY → BC → AR → ST → UTAD → LPSY → SOW)",
|
||||
" □ Markdown (Downtrend with re-distribution phases)",
|
||||
"",
|
||||
"2. VOLUME ANALYSIS",
|
||||
" □ High volume on spring = institutional buying",
|
||||
" □ Low volume on test = supply absorbed",
|
||||
" □ High volume on UTAD = distribution warning",
|
||||
" □ Effort vs Result: High volume + small range = absorption",
|
||||
"",
|
||||
"3. SCHEMATIC ANALYSIS",
|
||||
" □ Preliminary Support (PS) - first sign of buying",
|
||||
" □ Selling Climax (SC) - panic selling, widest spread",
|
||||
" □ Automatic Rally (AR) - relief bounce",
|
||||
" □ Secondary Test (ST) - tests SC low on lower volume",
|
||||
" □ Spring - traps sellers, stops below support",
|
||||
" □ Sign of Strength (SOS) - decisive move up",
|
||||
" □ Last Point of Support (LPS) - final buy opportunity",
|
||||
"",
|
||||
"4. CAUSE & EFFECT",
|
||||
f" □ Trading Range: ~${range_size:.2f}",
|
||||
f" □ Measured Move: ~${range_size * 2:.2f}",
|
||||
" □ Count: Accumulation time predicts markup distance"
|
||||
],
|
||||
"entry_strategies": {
|
||||
"accumulation_phase": {
|
||||
"entry_point": "After spring, on LPS (Last Point of Support)",
|
||||
"confirmation": "Volume decrease on pullback, increase on SOS",
|
||||
"entry_price": f"${current_price - (range_size * 0.3):.2f}",
|
||||
"stop_loss": f"${current_price - (range_size * 0.6):.2f}",
|
||||
"target": f"${current_price + (range_size * 1.5):.2f}",
|
||||
"holding_period": "Days to weeks"
|
||||
},
|
||||
"distribution_phase": {
|
||||
"entry_point": "After UTAD (Upthrust After Distribution)",
|
||||
"confirmation": "High volume on weakness, low volume on strength",
|
||||
"entry_price": f"${current_price + (range_size * 0.3):.2f}",
|
||||
"stop_loss": f"${current_price + (range_size * 0.6):.2f}",
|
||||
"target": f"${current_price - (range_size * 1.5):.2f}",
|
||||
"holding_period": "Days to weeks"
|
||||
}
|
||||
},
|
||||
"volume_spread_analysis": [
|
||||
"VSA SIGNALS TO WATCH:",
|
||||
"• No Supply: Up bar, narrow spread, low volume = bullish",
|
||||
"• No Demand: Down bar, narrow spread, low volume = bearish",
|
||||
"• Stopping Volume: Down bar, wide spread, high volume = bottom",
|
||||
"• Climax: Wide spread, very high volume = exhaustion",
|
||||
"• Test: Down bar, narrow spread, low volume after climax = bullish",
|
||||
"• Weakness: Up bar, wide spread, low volume = top forming"
|
||||
],
|
||||
"three_laws": [
|
||||
"1. LAW OF SUPPLY & DEMAND",
|
||||
" • High demand, low supply = prices rise",
|
||||
" • Low demand, high supply = prices fall",
|
||||
"",
|
||||
"2. LAW OF CAUSE & EFFECT",
|
||||
" • Larger accumulation = larger markup",
|
||||
" • Time in range predicts extent of move",
|
||||
"",
|
||||
"3. LAW OF EFFORT VS RESULT",
|
||||
" • High volume (effort) should produce price change (result)",
|
||||
" • Low volume (low effort) producing large moves = following smart money",
|
||||
" • High volume with no price change = absorption (distribution or accumulation)"
|
||||
],
|
||||
"max_trades": 1, # Wyckoff is patient, fewer trades
|
||||
"max_daily_loss": 200,
|
||||
"notes": [
|
||||
"📚 WYCKOFF WISDOM:",
|
||||
"• \"Determine the trend and trade with it, not against it\"",
|
||||
"• \"Wait for the right moment, then strike with force\"",
|
||||
"• \"The market is controlled by the Composite Operator\"",
|
||||
"",
|
||||
"⏰ PATIENCE IS KEY:",
|
||||
"• Full Wyckoff cycle can take weeks or months",
|
||||
"• Don't rush - wait for clear phases",
|
||||
"• Best entries: After spring or after UTAD",
|
||||
"",
|
||||
"📊 CHART READING:",
|
||||
"• Use 4H and Daily charts for phase identification",
|
||||
"• Use 1H for entry timing",
|
||||
"• Volume is CRITICAL - without volume, it's not Wyckoff",
|
||||
"",
|
||||
"⚠️ WARNINGS:",
|
||||
"• Don't trade in middle of range (wait for edges)",
|
||||
"• Fake springs exist - wait for SOS confirmation",
|
||||
"• Not every range is Wyckoff - need volume characteristics"
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_multi_method_confluence_plan(
|
||||
current_price: float,
|
||||
market_condition: MarketCondition
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate plan using multiple methodologies for maximum confluence"""
|
||||
|
||||
atr = current_price * 0.015
|
||||
|
||||
return {
|
||||
"plan_type": PlanType.SWING,
|
||||
"methodology": "Multi-Method Confluence (ICT + Fibonacci + S/D + Price Action)",
|
||||
"confluence_zones": [
|
||||
"ZONE IDENTIFICATION - ALL METHODS MUST ALIGN:",
|
||||
"",
|
||||
"1. SMART MONEY CONCEPTS:",
|
||||
" □ Fair Value Gap (FVG) or Order Block identified",
|
||||
" □ BOS or ChoCh confirmed",
|
||||
" □ Within discount zone (below 50% of range for buys)",
|
||||
"",
|
||||
"2. FIBONACCI ANALYSIS:",
|
||||
" □ 0.618 or 0.786 retracement level",
|
||||
" □ Previous swing low to swing high measured",
|
||||
" □ Fib level aligns with FVG/OB zone",
|
||||
"",
|
||||
"3. SUPPLY & DEMAND:",
|
||||
" □ Fresh demand zone (for buys) or supply zone (for sells)",
|
||||
" □ Rally-Base-Rally or Drop-Base-Drop pattern",
|
||||
" □ Zone not tested more than once",
|
||||
"",
|
||||
"4. PRICE ACTION:",
|
||||
" □ Support/Resistance level confirmed",
|
||||
" □ Pin bar, engulfing, or inside bar at level",
|
||||
" □ Structure break and retest",
|
||||
"",
|
||||
"✅ REQUIRED CONFLUENCE: Minimum 3 out of 4 methods confirming same zone"
|
||||
],
|
||||
"setup_requirements": {
|
||||
"maximum_confluence": {
|
||||
"description": "All 4 methods agree - highest probability",
|
||||
"requirements": [
|
||||
"FVG/Order Block present",
|
||||
"0.618-0.786 Fibonacci level",
|
||||
"Fresh S/D zone",
|
||||
"Key S/R level + candlestick pattern"
|
||||
],
|
||||
"example_entry": f"${current_price - (atr * 0.8):.2f}",
|
||||
"example_stop": f"${current_price - (atr * 1.3):.2f}",
|
||||
"example_target": f"${current_price + (atr * 2.5):.2f}",
|
||||
"position_size": "Full size (2-3% risk)",
|
||||
"win_rate": "70-80%",
|
||||
"rr_ratio": "1:3 minimum"
|
||||
},
|
||||
"high_confluence": {
|
||||
"description": "3 out of 4 methods agree",
|
||||
"requirements": [
|
||||
"Any 3 methods confirming same zone",
|
||||
"Timeframe confluence (HTF + LTF alignment)"
|
||||
],
|
||||
"position_size": "75% of full size",
|
||||
"win_rate": "65-75%",
|
||||
"rr_ratio": "1:2.5 minimum"
|
||||
},
|
||||
"moderate_confluence": {
|
||||
"description": "2 out of 4 methods - avoid or very small size",
|
||||
"recommendation": "Skip unless highly experienced",
|
||||
"position_size": "25% if taken",
|
||||
"win_rate": "55-65%"
|
||||
}
|
||||
},
|
||||
"step_by_step_process": [
|
||||
"STEP 1: MULTI-TIMEFRAME ANALYSIS",
|
||||
"□ Monthly/Weekly: Identify major trend and key levels",
|
||||
"□ Daily: Mark swing highs/lows, draw Fibonacci",
|
||||
"□ 4H: Identify S/D zones, FVGs, Order Blocks",
|
||||
"□ 1H: Wait for price to approach confluence zone",
|
||||
"□ 15M: Look for entry trigger (candlestick pattern)",
|
||||
"",
|
||||
"STEP 2: ZONE MARKING",
|
||||
"□ Mark all FVGs and Order Blocks (ICT)",
|
||||
"□ Draw Fibonacci from last major swing (0.382, 0.5, 0.618, 0.786)",
|
||||
"□ Identify fresh S/D zones (Supply/Demand)",
|
||||
"□ Mark key horizontal S/R levels (Price Action)",
|
||||
"□ Highlight zones where 3-4 methods overlap",
|
||||
"",
|
||||
"STEP 3: CONFLUENCE VERIFICATION",
|
||||
f"□ Price approaches confluence zone: ${current_price - atr:.2f} - ${current_price - (atr * 0.6):.2f}",
|
||||
"□ Verify zone freshness (not tested multiple times)",
|
||||
"□ Check session timing (prefer London/NY for gold)",
|
||||
"□ Assess market condition (avoid choppy, low volume periods)",
|
||||
"",
|
||||
"STEP 4: ENTRY TRIGGER",
|
||||
"□ Wait for price to enter confluence zone",
|
||||
"□ Look for rejection: Pin bar, engulfing pattern, or inside bar",
|
||||
"□ Can use limit order at zone OR wait for confirmation",
|
||||
"□ Entry preference: Confirmation candle (safer) vs limit (better RR)",
|
||||
"",
|
||||
"STEP 5: TRADE MANAGEMENT",
|
||||
"□ Stop loss: 5-10 points beyond zone (below/above all confluence factors)",
|
||||
"□ Target 1 (50%): Next FVG, S/D zone, or Fib extension (1.272)",
|
||||
"□ Target 2 (50%): Major structure, opposite liquidity, or Fib 1.618",
|
||||
"□ Trail stop: Use ATR-based trail or move to break-even after T1",
|
||||
"",
|
||||
"STEP 6: POST-TRADE REVIEW",
|
||||
"□ Did all methods confirm?",
|
||||
"□ What was win rate for this confluence setup?",
|
||||
"□ Note for future: Which method was strongest predictor?",
|
||||
"□ Journal: Screenshot setup and outcome"
|
||||
],
|
||||
"example_bullish_trade": {
|
||||
"scenario": "Bullish confluence zone setup",
|
||||
"confluence_zone": f"${current_price - (atr * 0.9):.2f} - ${current_price - (atr * 0.7):.2f}",
|
||||
"methods_confirming": [
|
||||
f"✓ Bullish FVG at ${current_price - (atr * 0.8):.2f}",
|
||||
f"✓ 0.618 Fib retracement at ${current_price - (atr * 0.75):.2f}",
|
||||
f"✓ Fresh demand zone from ${current_price - (atr * 0.9):.2f} to ${current_price - (atr * 0.7):.2f}",
|
||||
f"✓ Daily support level at ${current_price - (atr * 0.8):.2f}"
|
||||
],
|
||||
"entry": f"${current_price - (atr * 0.75):.2f} (limit order in zone OR on pin bar confirmation)",
|
||||
"stop_loss": f"${current_price - (atr * 1.3):.2f} (below all confluence factors)",
|
||||
"target_1": f"${current_price + (atr * 0.5):.2f} (next minor resistance/FVG)",
|
||||
"target_2": f"${current_price + (atr * 2.0):.2f} (major structure/opposite S/D zone)",
|
||||
"risk_reward": "1:3.5",
|
||||
"position_management": "Close 50% at T1, trail remaining 50% with ATR(14) * 1.5"
|
||||
},
|
||||
"max_trades": 2,
|
||||
"max_daily_loss": 300,
|
||||
"notes": [
|
||||
"🎯 CONFLUENCE TRADING RULES:",
|
||||
"• MINIMUM 3 methods must confirm same zone",
|
||||
"• More confluence = higher probability = larger position",
|
||||
"• Never force a trade - wait for perfect setup",
|
||||
"• These setups are rare (1-3 per week on gold) - be patient!",
|
||||
"",
|
||||
"⏰ TIMING:",
|
||||
"• Best during London/NY sessions (liquidity)",
|
||||
"• Avoid: Asian session, major news events, Friday afternoons",
|
||||
"• Prefer Monday-Thursday for best follow-through",
|
||||
"",
|
||||
"📊 EXPECTATION:",
|
||||
"• Win rate: 70-80% with proper confluence",
|
||||
"• Average RR: 1:3 to 1:5",
|
||||
"• Frequency: 1-3 high-quality setups per week",
|
||||
"• This is a QUALITY over quantity approach",
|
||||
"",
|
||||
"⚠️ DISCIPLINE CHECKLIST:",
|
||||
"• ❌ Don't trade without minimum 3-method confluence",
|
||||
"• ❌ Don't increase risk on 'gut feeling'",
|
||||
"• ❌ Don't chase price if it leaves the zone",
|
||||
"• ✅ Wait for price to return to confluence zone",
|
||||
"• ✅ Journal every setup (even if you don't take it)",
|
||||
"• ✅ Review weekly: Which confluences worked best?",
|
||||
"",
|
||||
"💎 PROFESSIONAL EDGE:",
|
||||
"• Institutions look for same confluences - you're trading WITH smart money",
|
||||
"• Multiple confirmations = reduced false signals",
|
||||
"• Patient traders win - this method rewards discipline",
|
||||
"• Track your confluence setups: Over time, you'll find your highest-probability patterns"
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def generate_session_based_plan(
|
||||
current_price: float,
|
||||
target_session: str = "london_ny_overlap"
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate session-specific trading plan for gold"""
|
||||
|
||||
atr = current_price * 0.015
|
||||
|
||||
sessions = {
|
||||
"asian": {
|
||||
"time": "6 PM - 3 AM EST",
|
||||
"characteristics": "Low volatility, range-bound, choppy",
|
||||
"strategy": "Range trading or avoid",
|
||||
"avg_range": f"${atr * 0.5:.2f} - ${atr * 0.8:.2f}"
|
||||
},
|
||||
"london": {
|
||||
"time": "3 AM - 12 PM EST",
|
||||
"characteristics": "High volatility, trend moves, breakouts",
|
||||
"strategy": "Breakout or trend continuation",
|
||||
"avg_range": f"${atr * 1.2:.2f} - ${atr * 1.8:.2f}",
|
||||
"killzone": "3 AM - 5 AM EST"
|
||||
},
|
||||
"ny": {
|
||||
"time": "8 AM - 5 PM EST",
|
||||
"characteristics": "Highest volatility, strong directional moves",
|
||||
"strategy": "Continuation of London or reversal",
|
||||
"avg_range": f"${atr * 1.5:.2f} - ${atr * 2.0:.2f}",
|
||||
"killzone": "8 AM - 11 AM EST"
|
||||
},
|
||||
"london_ny_overlap": {
|
||||
"time": "8 AM - 12 PM EST",
|
||||
"characteristics": "Maximum liquidity, most volume, best opportunities",
|
||||
"strategy": "All strategies valid, highest probability",
|
||||
"avg_range": f"${atr * 1.8:.2f} - ${atr * 2.5:.2f}"
|
||||
}
|
||||
}
|
||||
|
||||
session_info = sessions.get(target_session, sessions["london_ny_overlap"])
|
||||
|
||||
return {
|
||||
"plan_type": PlanType.INTRADAY,
|
||||
"methodology": f"{target_session.upper().replace('_', ' ')} Session Trading",
|
||||
"session_details": session_info,
|
||||
"daily_playbook": [
|
||||
"GOLD TRADING SESSION PLAYBOOK:",
|
||||
"",
|
||||
"🌏 ASIAN SESSION (6 PM - 3 AM EST):",
|
||||
"• Price action: Consolidation, range-bound",
|
||||
"• Volume: Lowest of the day",
|
||||
"• Strategy: Mark Asian range high/low for breakouts",
|
||||
"• Approach: Generally avoid or trade mean reversion in range",
|
||||
f"• Expected range: {sessions['asian']['avg_range']}",
|
||||
"",
|
||||
"🇬🇧 LONDON SESSION (3 AM - 12 PM EST):",
|
||||
"• Price action: Breakouts, trend establishment",
|
||||
"• Volume: High (60% of daily gold volume)",
|
||||
"• Strategy: Trade breakouts of Asian range",
|
||||
"• Killzone: 3-5 AM EST (highest probability)",
|
||||
f"• Expected range: {sessions['london']['avg_range']}",
|
||||
"• Watch for: Judas Swing (false move 3-4 AM, real move 5-8 AM)",
|
||||
"",
|
||||
"🇺🇸 NY SESSION (8 AM - 5 PM EST):",
|
||||
"• Price action: Continuation or reversal",
|
||||
"• Volume: Highest (overlap with London 8 AM-12 PM)",
|
||||
"• Strategy: Follow London direction or trade reversals",
|
||||
"• Killzone: 8-11 AM EST (absolute best time)",
|
||||
f"• Expected range: {sessions['ny']['avg_range']}",
|
||||
"• Watch for: US economic data releases (8:30 AM, 10 AM)",
|
||||
"",
|
||||
"🏆 LONDON/NY OVERLAP (8 AM - 12 PM EST):",
|
||||
"• Price action: Maximum movement, strong trends",
|
||||
"• Volume: Peak liquidity",
|
||||
"• Strategy: ALL strategies valid, focus here",
|
||||
f"• Expected range: {sessions['london_ny_overlap']['avg_range']}",
|
||||
"• This is THE WINDOW for gold day trading"
|
||||
],
|
||||
"intraday_scenarios": {
|
||||
"scenario_1_breakout": {
|
||||
"name": "Asian Range Breakout (Most Common)",
|
||||
"setup": [
|
||||
"1. Mark Asian session high and low (6 PM - 3 AM)",
|
||||
f"2. Asian range: typically ${atr * 0.5:.2f} - ${atr * 0.8:.2f}",
|
||||
"3. Wait for London open (3 AM EST)",
|
||||
"4. Watch for breakout of range + close outside",
|
||||
"5. Enter on retest of broken level OR on break candle"
|
||||
],
|
||||
"entry_long": f"${current_price + (atr * 0.3):.2f} (break above Asian high)",
|
||||
"stop_long": f"${current_price - (atr * 0.4):.2f} (below Asian low)",
|
||||
"target_long": f"${current_price + (atr * 1.5):.2f} (1.5x Asian range)",
|
||||
"timing": "3-5 AM EST (London killzone)"
|
||||
},
|
||||
"scenario_2_judas_swing": {
|
||||
"name": "Judas Swing (ICT Concept)",
|
||||
"setup": [
|
||||
"1. London opens with move in one direction (3-4 AM)",
|
||||
"2. Move is FALSE - designed to trap traders",
|
||||
"3. Price reverses sharply (4-6 AM)",
|
||||
"4. Real move happens opposite to initial direction",
|
||||
"5. Enter on reversal confirmation"
|
||||
],
|
||||
"example": "Gold breaks up at 3 AM → Reverses down 4 AM → Continues down rest of session",
|
||||
"entry": "After reversal candle, when false high is broken back down",
|
||||
"stop": "Above false high + buffer",
|
||||
"target": f"${atr * 1.5:.2f} - ${atr * 2.0:.2f} move in true direction"
|
||||
},
|
||||
"scenario_3_continuation": {
|
||||
"name": "NY Continuation (Follows London)",
|
||||
"setup": [
|
||||
"1. London session establishes clear direction",
|
||||
"2. NY open (8 AM) continues same direction",
|
||||
"3. Pullback to FVG or Order Block during overlap",
|
||||
"4. Enter on continuation"
|
||||
],
|
||||
"entry": f"${current_price:.2f} (at pullback zone)",
|
||||
"stop": f"${current_price - (atr * 0.8):.2f} (beyond retracement)",
|
||||
"target": f"${current_price + (atr * 1.5):.2f} (session extension)",
|
||||
"timing": "8 AM - 11 AM EST"
|
||||
},
|
||||
"scenario_4_reversal": {
|
||||
"name": "NY Reversal (Opposite London)",
|
||||
"setup": [
|
||||
"1. London session exhausts in one direction",
|
||||
"2. Signs of exhaustion: Wicks, slowing momentum, volume decrease",
|
||||
"3. NY open triggers reversal",
|
||||
"4. Enter on confirmed reversal pattern"
|
||||
],
|
||||
"entry": f"${current_price:.2f} (on reversal candle close)",
|
||||
"stop": f"${current_price + (atr * 0.8):.2f} (beyond reversal level)",
|
||||
"target": f"${current_price - (atr * 1.5):.2f} (back to Asian range or key level)",
|
||||
"timing": "8 AM - 10 AM EST",
|
||||
"note": "Less common than continuation, wait for strong confirmation"
|
||||
}
|
||||
},
|
||||
"time_based_rules": [
|
||||
"⏰ TIME-BASED TRADING RULES:",
|
||||
"",
|
||||
"DO NOT TRADE:",
|
||||
"• Before 3 AM EST (Asian session - too choppy)",
|
||||
"• After 12 PM EST (liquidity dries up, whipsaws increase)",
|
||||
"• During major US news releases (wait 15-30 min after)",
|
||||
"• Friday after 10 AM EST (early close, low volume)",
|
||||
"",
|
||||
"BEST TRADING WINDOWS:",
|
||||
"• 3-5 AM EST: London killzone (breakouts)",
|
||||
"• 8-11 AM EST: NY killzone (strongest moves)",
|
||||
"• 8-10 AM EST: Absolute prime time (London/NY overlap peak)",
|
||||
"",
|
||||
"VOLUME PROFILE:",
|
||||
"• 3-8 AM: Building volume, establishing direction",
|
||||
"• 8-11 AM: Peak volume, maximum movement",
|
||||
"• 11 AM-12 PM: Reduced volatility, range trading",
|
||||
"• After 12 PM: Avoid or tight ranges only"
|
||||
],
|
||||
"daily_routine": [
|
||||
"📋 SESSION TRADER DAILY ROUTINE:",
|
||||
"",
|
||||
"2:30 AM EST - Pre-London Preparation:",
|
||||
"□ Review overnight news and economic calendar",
|
||||
"□ Mark Asian session high/low",
|
||||
"□ Identify key levels from previous day",
|
||||
"□ Check DXY, yields, and market correlations",
|
||||
"□ Plan: What will you do if price breaks up? Breaks down?",
|
||||
"",
|
||||
"3:00 AM EST - London Open:",
|
||||
"□ Watch for initial direction",
|
||||
"□ Is it breaking Asian range or staying within?",
|
||||
"□ Look for Judas Swing setup (false move)",
|
||||
"□ Mark any FVGs or Order Blocks forming",
|
||||
"",
|
||||
"7:30 AM EST - Pre-NY Prep:",
|
||||
"□ Assess London session direction (up/down/ranging)",
|
||||
"□ Check for US economic releases at 8:30 AM",
|
||||
"□ Identify: Will NY continue or reverse?",
|
||||
"□ Plan entry zones for both scenarios",
|
||||
"",
|
||||
"8:00 AM EST - NY Open (Prime Time):",
|
||||
"□ Execute plan based on setup",
|
||||
"□ Take trades ONLY if setup is perfect",
|
||||
"□ Maximum 2 trades during this window",
|
||||
"□ Focus on quality over quantity",
|
||||
"",
|
||||
"11:00 AM EST - Session Wind-Down:",
|
||||
"□ Close or protect any open positions",
|
||||
"□ Move stops to break-even minimum",
|
||||
"□ Avoid new entries after 11 AM",
|
||||
"",
|
||||
"12:00 PM EST - Day Complete:",
|
||||
"□ Close all positions or trail stops",
|
||||
"□ Journal trades and setups",
|
||||
"□ No more trading for the day - walk away",
|
||||
"□ Review: What worked? What didn't?"
|
||||
],
|
||||
"max_trades": 3,
|
||||
"max_daily_loss": 250,
|
||||
"notes": [
|
||||
"🌟 SESSION TRADING WISDOM:",
|
||||
"",
|
||||
"• \"The best trades happen in the first 3 hours of London and NY sessions\"",
|
||||
"• \"Asian session is for planning, not trading (for most retail traders)\"",
|
||||
"• \"The Judas Swing is real - London often fakes a move before the real direction\"",
|
||||
"• \"When London and NY agree on direction, moves are powerful\"",
|
||||
"",
|
||||
"📊 STATISTICS (Approximate for Gold):",
|
||||
"• 60% of daily range happens during London session",
|
||||
"• 30% happens during NY session",
|
||||
"• 10% happens during Asian session",
|
||||
"• Highest probability trades: 8-10 AM EST (80%+ of best setups)",
|
||||
"",
|
||||
"⚠️ COMMON MISTAKES:",
|
||||
"• Trading too early (before 3 AM EST)",
|
||||
"• Trading too late (after 12 PM EST)",
|
||||
"• Not respecting the Judas Swing (getting trapped)",
|
||||
"• Overtrading during low-probability times",
|
||||
"• Ignoring session characteristics (trying to breakout trade in Asian session)",
|
||||
"",
|
||||
"💡 PRO TIPS:",
|
||||
"• Set alarms: 2:45 AM (London prep), 7:45 AM (NY prep)",
|
||||
"• Most profitable gold traders trade ONLY 8-11 AM EST",
|
||||
"• If you miss the killzones, skip the day (there's always tomorrow)",
|
||||
"• Friday: Close all positions by 10 AM EST, weekend risk not worth it"
|
||||
]
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_all_plan_types() -> Dict[str, str]:
|
||||
"""Get all available plan types"""
|
||||
return {
|
||||
"ict_smc": "ICT / Smart Money Concepts",
|
||||
"wyckoff": "Wyckoff Method",
|
||||
"elliott_wave": "Elliott Wave Theory",
|
||||
"supply_demand": "Supply & Demand Zones",
|
||||
"fibonacci": "Fibonacci Trading",
|
||||
"multi_confluence": "Multi-Method Confluence",
|
||||
"session_trading": "London/NY Session Trading",
|
||||
"price_action": "Pure Price Action",
|
||||
"fundamental": "Fundamental Analysis",
|
||||
"scalping": "Scalping (1-5 min)",
|
||||
"swing": "Swing Trading (Days)",
|
||||
"position": "Position Trading (Weeks+)"
|
||||
}
|
||||
|
||||
|
||||
# Global instance
|
||||
plan_templates = PlanTemplates()
|
||||
@@ -0,0 +1,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable, Awaitable, Optional, Sequence
|
||||
|
||||
from app.schemas.schemas import PositionMetrics, PatternSignal
|
||||
from app.services.metals.bullionvault_service import get_bullionvault_gold_price
|
||||
from app.services.metals.gold_price_fetcher import gold_price_fetcher
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PriceAnchorService:
|
||||
"""Rescales simulated metric snapshots to the live gold price feed."""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 30) -> None:
|
||||
self._ttl = ttl_seconds
|
||||
self._cache_price: Optional[float] = None
|
||||
self._cache_ts: float = 0.0
|
||||
|
||||
async def get_anchor_price(self, symbol: str = "XAUUSD") -> Optional[float]:
|
||||
now = time.time()
|
||||
if self._cache_price and (now - self._cache_ts) < self._ttl:
|
||||
return self._cache_price
|
||||
|
||||
fetchers: Sequence[Callable[[], Awaitable[Optional[float]]]] = (
|
||||
self._get_bullionvault_price,
|
||||
self._get_fallback_price,
|
||||
)
|
||||
for fetch in fetchers:
|
||||
try:
|
||||
price = await fetch()
|
||||
except Exception as exc: # pragma: no cover - best effort logging only
|
||||
logger.warning("Price anchor fetch failed: %s", exc)
|
||||
continue
|
||||
if price and price > 0:
|
||||
self._cache_price = float(price)
|
||||
self._cache_ts = now
|
||||
return self._cache_price
|
||||
return self._cache_price
|
||||
|
||||
def get_anchor_price_sync(self, symbol: str = "XAUUSD") -> Optional[float]:
|
||||
"""Synchronous version that returns cached price only"""
|
||||
now = time.time()
|
||||
if self._cache_price and (now - self._cache_ts) < self._ttl:
|
||||
return self._cache_price
|
||||
return self._cache_price
|
||||
|
||||
async def _get_bullionvault_price(self) -> Optional[float]:
|
||||
data = await get_bullionvault_gold_price("USD")
|
||||
return float(data["price"]) if data and data.get("price") else None
|
||||
|
||||
async def _get_fallback_price(self) -> Optional[float]:
|
||||
data = await gold_price_fetcher.get_current_gold_price()
|
||||
return float(data["price"]) if data and data.get("price") else None
|
||||
|
||||
def apply_anchor(self, metrics: PositionMetrics, anchor_price: Optional[float]) -> PositionMetrics:
|
||||
if not anchor_price or metrics.current_price <= 0:
|
||||
return metrics
|
||||
|
||||
scale = anchor_price / metrics.current_price
|
||||
if abs(scale - 1.0) < 0.005:
|
||||
# Already close enough to the anchor, skip unnecessary work
|
||||
return metrics
|
||||
|
||||
if not 0.2 <= scale <= 5:
|
||||
logger.warning("Skipping unrealistic price anchor scaling (scale=%.4f)", scale)
|
||||
return metrics
|
||||
|
||||
scaled = metrics.model_copy(deep=True)
|
||||
|
||||
def scale_value(value: Optional[float], decimals: int = 4) -> Optional[float]:
|
||||
if value is None:
|
||||
return None
|
||||
return round(value * scale, decimals)
|
||||
|
||||
def scale_list(values: list[float]) -> list[float]:
|
||||
return [round(v * scale, 2) for v in values]
|
||||
|
||||
scaled.current_price = round(anchor_price, 2)
|
||||
scaled.previous_close = scale_value(scaled.previous_close, 2)
|
||||
scaled.high = scale_value(scaled.high, 2)
|
||||
scaled.low = scale_value(scaled.low, 2)
|
||||
scaled.atr14 = scale_value(scaled.atr14)
|
||||
scaled.ema21 = scale_value(scaled.ema21)
|
||||
scaled.sma55 = scale_value(scaled.sma55)
|
||||
scaled.sma100 = scale_value(scaled.sma100)
|
||||
scaled.sma200 = scale_value(scaled.sma200)
|
||||
scaled.bb_basis = scale_value(scaled.bb_basis)
|
||||
scaled.bb_upper = scale_value(scaled.bb_upper)
|
||||
scaled.bb_lower = scale_value(scaled.bb_lower)
|
||||
scaled.zlsma = scale_value(scaled.zlsma)
|
||||
scaled.chandelier_long_stop = scale_value(scaled.chandelier_long_stop, 2)
|
||||
scaled.chandelier_short_stop = scale_value(scaled.chandelier_short_stop, 2)
|
||||
scaled.momentum12 = scale_value(scaled.momentum12)
|
||||
scaled.support_levels = scale_list(scaled.support_levels)
|
||||
scaled.resistance_levels = scale_list(scaled.resistance_levels)
|
||||
|
||||
scaled.pattern_signals = [
|
||||
signal.model_copy(update={"price": scale_value(signal.price, 2)})
|
||||
for signal in scaled.pattern_signals
|
||||
]
|
||||
|
||||
if scaled.previous_close is not None:
|
||||
scaled.change = round(scaled.current_price - scaled.previous_close, 4)
|
||||
if scaled.previous_close:
|
||||
scaled.change_percent = round((scaled.change / scaled.previous_close) * 100, 4)
|
||||
else:
|
||||
scaled.change = scale_value(scaled.change)
|
||||
if scaled.previous_close:
|
||||
scaled.change_percent = round((scaled.change or 0.0) / scaled.previous_close * 100, 4)
|
||||
|
||||
return scaled
|
||||
|
||||
|
||||
price_anchor_service = PriceAnchorService()
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Helpers for loading the latest trading simulation snapshot from the database."""
|
||||
from typing import Any, Dict
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.trading_persistent import get_or_create_simulation, get_portfolio_state_from_db
|
||||
|
||||
|
||||
def load_simulation_state(db: Session, user_id: str = "default") -> Dict[str, Any]:
|
||||
"""Return the current simulation state as a serializable dict."""
|
||||
try:
|
||||
simulation = get_or_create_simulation(db, user_id)
|
||||
portfolio = get_portfolio_state_from_db(simulation, db)
|
||||
return portfolio.dict()
|
||||
except Exception as e:
|
||||
# If database tables don't exist, return default state
|
||||
print(f"Warning: Could not load simulation state: {e}")
|
||||
return {
|
||||
"cash": 100000.0,
|
||||
"position": None,
|
||||
"trades": [],
|
||||
"total_pnl": 0.0,
|
||||
"win_rate": 0.0,
|
||||
"avg_win": 0.0,
|
||||
"avg_loss": 0.0,
|
||||
"trade_count": 0
|
||||
}
|
||||
@@ -0,0 +1,641 @@
|
||||
"""
|
||||
Trading Schools & Methodologies
|
||||
Comprehensive collection of trading approaches combining different schools of thought
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class TradingSchool(str, Enum):
|
||||
"""Major trading methodologies and schools"""
|
||||
ICT = "ict" # Inner Circle Trader / Smart Money Concepts
|
||||
WYCKOFF = "wyckoff" # Wyckoff Method
|
||||
ELLIOTT_WAVE = "elliott_wave" # Elliott Wave Theory
|
||||
MARKET_PROFILE = "market_profile" # Market Profile / Volume Profile
|
||||
ORDER_FLOW = "order_flow" # Order Flow / Footprint
|
||||
PRICE_ACTION = "price_action" # Pure Price Action
|
||||
TECHNICAL_ANALYSIS = "technical_analysis" # Classical Technical Analysis
|
||||
SUPPLY_DEMAND = "supply_demand" # Supply & Demand Zones
|
||||
FIBONACCI = "fibonacci" # Fibonacci-based Trading
|
||||
FUNDAMENTAL = "fundamental" # Fundamental Analysis for Gold
|
||||
SENTIMENT = "sentiment" # Market Sentiment Analysis
|
||||
SEASONAL = "seasonal" # Seasonal Patterns
|
||||
INTERMARKET = "intermarket" # Intermarket Analysis
|
||||
|
||||
|
||||
class TradingStrategy:
|
||||
"""Base class for trading strategies"""
|
||||
|
||||
@staticmethod
|
||||
def get_all_schools() -> Dict[str, Dict[str, Any]]:
|
||||
"""Get comprehensive information about all trading schools"""
|
||||
return {
|
||||
"ict_smc": {
|
||||
"name": "ICT / Smart Money Concepts",
|
||||
"school": TradingSchool.ICT,
|
||||
"description": "Inner Circle Trader methodology focusing on institutional order flow, liquidity sweeps, and market structure",
|
||||
"key_concepts": [
|
||||
"Order Blocks (OB)",
|
||||
"Fair Value Gaps (FVG/Imbalance)",
|
||||
"Liquidity Voids",
|
||||
"Break of Structure (BOS)",
|
||||
"Change of Character (ChoCh)",
|
||||
"Displacement",
|
||||
"Premium/Discount Zones",
|
||||
"London/NY Killzones",
|
||||
"Judas Swing",
|
||||
"Optimal Trade Entry (OTE 0.618-0.79)",
|
||||
"Stop Hunt/Liquidity Grab",
|
||||
"Market Maker Model (Accumulation, Manipulation, Distribution)"
|
||||
],
|
||||
"timeframes": ["5m", "15m", "1h", "4h", "1D"],
|
||||
"indicators": [], # Pure price action, minimal indicators
|
||||
"best_for": ["Day trading", "Swing trading", "Gold/Forex"],
|
||||
"sessions": ["London (3-5 AM EST)", "NY (8-11 AM EST)"],
|
||||
"entry_criteria": [
|
||||
"Identify market structure (bullish/bearish)",
|
||||
"Wait for BOS or ChoCh",
|
||||
"Find FVG or Order Block",
|
||||
"Look for liquidity sweep",
|
||||
"Enter on retracement to OTE (0.618-0.79 Fib)",
|
||||
"Target opposite liquidity"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Above/below order block or FVG",
|
||||
"take_profit": "Opposite side liquidity, FVG, or major structure",
|
||||
"rr_ratio": "Minimum 1:2, typically 1:3+"
|
||||
}
|
||||
},
|
||||
"wyckoff": {
|
||||
"name": "Wyckoff Method",
|
||||
"school": TradingSchool.WYCKOFF,
|
||||
"description": "Volume-based methodology analyzing accumulation, distribution, and composite operator behavior",
|
||||
"key_concepts": [
|
||||
"Accumulation (Spring, Backup, SOS)",
|
||||
"Distribution (UTAD, SOW)",
|
||||
"Re-accumulation",
|
||||
"Re-distribution",
|
||||
"Cause and Effect",
|
||||
"Effort vs Result",
|
||||
"Composite Man/Operator",
|
||||
"Three Laws (Supply/Demand, Cause/Effect, Effort/Result)",
|
||||
"Volume Spread Analysis (VSA)",
|
||||
"Schematic Patterns (AR, ST, Creek, Spring)"
|
||||
],
|
||||
"timeframes": ["4h", "1D", "1W"],
|
||||
"indicators": ["Volume", "Volume Profile", "OBV"],
|
||||
"best_for": ["Position trading", "Swing trading"],
|
||||
"phases": ["Accumulation Phase", "Markup Phase", "Distribution Phase", "Markdown Phase"],
|
||||
"entry_criteria": [
|
||||
"Identify current phase",
|
||||
"Wait for spring (accumulation) or upthrust (distribution)",
|
||||
"Confirm with volume",
|
||||
"Enter on Sign of Strength (SOS) or Last Point of Support (LPS)",
|
||||
"Target: Measured move based on trading range"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Below spring or support area",
|
||||
"take_profit": "Measured move from accumulation range",
|
||||
"rr_ratio": "Minimum 1:2"
|
||||
}
|
||||
},
|
||||
"elliott_wave": {
|
||||
"name": "Elliott Wave Theory",
|
||||
"school": TradingSchool.ELLIOTT_WAVE,
|
||||
"description": "Fractal pattern analysis based on wave structures and Fibonacci relationships",
|
||||
"key_concepts": [
|
||||
"Impulse Waves (1-2-3-4-5)",
|
||||
"Corrective Waves (A-B-C)",
|
||||
"Wave Degrees (Grand Super Cycle to Sub-Minuette)",
|
||||
"Fibonacci Extensions (1.618, 2.618)",
|
||||
"Fibonacci Retracements (0.382, 0.5, 0.618)",
|
||||
"Wave Personality (Wave 3 strongest)",
|
||||
"Alternation Principle",
|
||||
"Channeling Techniques",
|
||||
"Wave Equality",
|
||||
"Ending Diagonals",
|
||||
"Leading Diagonals"
|
||||
],
|
||||
"timeframes": ["1h", "4h", "1D", "1W"],
|
||||
"indicators": ["Fibonacci", "EMA", "RSI for divergence"],
|
||||
"best_for": ["Swing trading", "Position trading"],
|
||||
"entry_criteria": [
|
||||
"Identify current wave structure",
|
||||
"Enter at wave 2 or 4 retracement (0.618)",
|
||||
"Enter at wave C completion (corrective)",
|
||||
"Confirm with volume and momentum",
|
||||
"Target: Wave 3 = 1.618x Wave 1, Wave 5 = Wave 1"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Below wave 1 start or key Fibonacci level",
|
||||
"take_profit": "Fibonacci extensions (1.618, 2.618)",
|
||||
"rr_ratio": "Minimum 1:3"
|
||||
}
|
||||
},
|
||||
"market_profile": {
|
||||
"name": "Market Profile / Volume Profile",
|
||||
"school": TradingSchool.MARKET_PROFILE,
|
||||
"description": "Time and volume-based analysis identifying value areas and market acceptance",
|
||||
"key_concepts": [
|
||||
"Point of Control (POC)",
|
||||
"Value Area (VA)",
|
||||
"Value Area High (VAH)",
|
||||
"Value Area Low (VAL)",
|
||||
"Initial Balance (IB)",
|
||||
"TPO (Time Price Opportunity)",
|
||||
"High Volume Nodes (HVN)",
|
||||
"Low Volume Nodes (LVN)",
|
||||
"Excess",
|
||||
"Poor Highs/Lows",
|
||||
"Single Prints",
|
||||
"Profiles (P-shaped, b-shaped, D-shaped)"
|
||||
],
|
||||
"timeframes": ["30m", "1h", "1D"],
|
||||
"indicators": ["Volume Profile", "VWAP", "Volume"],
|
||||
"best_for": ["Day trading", "Swing trading"],
|
||||
"entry_criteria": [
|
||||
"Identify POC and Value Area",
|
||||
"Enter at Value Area extremes (VAL/VAH)",
|
||||
"Trade rejections from LVN",
|
||||
"Target: Opposite side of value area or POC",
|
||||
"Look for acceptance/rejection at key levels"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Beyond value area or single prints",
|
||||
"take_profit": "POC, opposite VA extreme, or LVN",
|
||||
"rr_ratio": "Minimum 1:2"
|
||||
}
|
||||
},
|
||||
"order_flow": {
|
||||
"name": "Order Flow Trading",
|
||||
"school": TradingSchool.ORDER_FLOW,
|
||||
"description": "Real-time bid/ask analysis, footprint charts, and institutional order detection",
|
||||
"key_concepts": [
|
||||
"Delta (Buy - Sell volume)",
|
||||
"Cumulative Delta",
|
||||
"Volume Imbalance",
|
||||
"Absorption",
|
||||
"Stacked Imbalances",
|
||||
"Exhaustion",
|
||||
"Iceberg Orders",
|
||||
"Tape Reading",
|
||||
"Bid/Ask Ladder",
|
||||
"Footprint Charts",
|
||||
"Volume Clusters",
|
||||
"Unfinished Business"
|
||||
],
|
||||
"timeframes": ["1m", "5m", "15m"],
|
||||
"indicators": ["Delta", "Volume Profile", "Cumulative Delta"],
|
||||
"best_for": ["Scalping", "Day trading"],
|
||||
"entry_criteria": [
|
||||
"Identify delta divergence",
|
||||
"Look for absorption at key levels",
|
||||
"Watch for stacked imbalances",
|
||||
"Enter on confirmation of institutional flow",
|
||||
"Target: Next volume cluster or imbalance"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Tight stops beyond absorption zone",
|
||||
"take_profit": "Volume imbalance fill or delta reversal",
|
||||
"rr_ratio": "Minimum 1:1.5 (high win rate strategy)"
|
||||
}
|
||||
},
|
||||
"price_action": {
|
||||
"name": "Pure Price Action",
|
||||
"school": TradingSchool.PRICE_ACTION,
|
||||
"description": "Trading based solely on candlestick patterns, support/resistance, and market structure",
|
||||
"key_concepts": [
|
||||
"Support and Resistance",
|
||||
"Trend Lines",
|
||||
"Horizontal Levels",
|
||||
"Higher Highs / Higher Lows (HH/HL)",
|
||||
"Lower Highs / Lower Lows (LH/LL)",
|
||||
"Pin Bars",
|
||||
"Inside Bars",
|
||||
"Outside Bars",
|
||||
"Engulfing Patterns",
|
||||
"Double Tops/Bottoms",
|
||||
"Head & Shoulders",
|
||||
"Triangles, Flags, Pennants",
|
||||
"Break and Retest"
|
||||
],
|
||||
"timeframes": ["15m", "1h", "4h", "1D"],
|
||||
"indicators": [], # None, pure price action
|
||||
"best_for": ["All trading styles"],
|
||||
"entry_criteria": [
|
||||
"Identify trend and structure",
|
||||
"Wait for pattern formation at key level",
|
||||
"Enter on confirmation candle",
|
||||
"Target: Next major S/R level",
|
||||
"Look for confluence of multiple factors"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Beyond pattern or S/R level",
|
||||
"take_profit": "Risk-reward based on structure",
|
||||
"rr_ratio": "Minimum 1:2"
|
||||
}
|
||||
},
|
||||
"supply_demand": {
|
||||
"name": "Supply & Demand Zones",
|
||||
"school": TradingSchool.SUPPLY_DEMAND,
|
||||
"description": "Zone-based trading focusing on areas of institutional activity and imbalance",
|
||||
"key_concepts": [
|
||||
"Demand Zones (buying pressure)",
|
||||
"Supply Zones (selling pressure)",
|
||||
"Fresh Zones (untested)",
|
||||
"Tested Zones (touched once)",
|
||||
"Rally-Base-Rally (RBR)",
|
||||
"Drop-Base-Drop (DBD)",
|
||||
"Rally-Base-Drop (RBD)",
|
||||
"Drop-Base-Rally (DBR)",
|
||||
"Flip Zones (S/D conversion)",
|
||||
"Strong Zones (sharp moves)",
|
||||
"Weak Zones (slow consolidation)"
|
||||
],
|
||||
"timeframes": ["15m", "1h", "4h", "1D"],
|
||||
"indicators": ["Minimal - sometimes volume"],
|
||||
"best_for": ["Day trading", "Swing trading"],
|
||||
"entry_criteria": [
|
||||
"Identify fresh demand/supply zones",
|
||||
"Wait for price to return to zone",
|
||||
"Enter on confirmation (pin bar, engulfing)",
|
||||
"Target: Opposite supply/demand zone",
|
||||
"Use limit orders in zone"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Beyond zone (few pips/points)",
|
||||
"take_profit": "Next major zone or measured move",
|
||||
"rr_ratio": "Minimum 1:3"
|
||||
}
|
||||
},
|
||||
"fibonacci_trading": {
|
||||
"name": "Fibonacci-Based Trading",
|
||||
"school": TradingSchool.FIBONACCI,
|
||||
"description": "Trading using Fibonacci ratios for retracements, extensions, and time analysis",
|
||||
"key_concepts": [
|
||||
"Fibonacci Retracement (0.236, 0.382, 0.5, 0.618, 0.786)",
|
||||
"Fibonacci Extension (1.272, 1.414, 1.618, 2.618)",
|
||||
"Fibonacci Fans",
|
||||
"Fibonacci Arcs",
|
||||
"Fibonacci Time Zones",
|
||||
"Golden Ratio (1.618)",
|
||||
"Confluence Zones",
|
||||
"AB=CD Pattern",
|
||||
"Gartley Patterns",
|
||||
"Harmonic Patterns (Bat, Butterfly, Crab)"
|
||||
],
|
||||
"timeframes": ["1h", "4h", "1D"],
|
||||
"indicators": ["Fibonacci tools", "RSI for confirmation"],
|
||||
"best_for": ["Swing trading", "Position trading"],
|
||||
"entry_criteria": [
|
||||
"Identify completed impulse move",
|
||||
"Draw Fibonacci from swing low to swing high (or vice versa)",
|
||||
"Wait for retracement to 0.618 or 0.786",
|
||||
"Confirm with candlestick pattern or indicator",
|
||||
"Target: Fibonacci extensions (1.618, 2.618)"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Beyond 0.786 or 1.0 level",
|
||||
"take_profit": "Fibonacci extensions",
|
||||
"rr_ratio": "Minimum 1:2"
|
||||
}
|
||||
},
|
||||
"gold_fundamental": {
|
||||
"name": "Gold Fundamental Analysis",
|
||||
"school": TradingSchool.FUNDAMENTAL,
|
||||
"description": "Trading gold based on macroeconomic factors and fundamental drivers",
|
||||
"key_concepts": [
|
||||
"US Dollar Strength (DXY inverse correlation)",
|
||||
"Real Interest Rates (negative = bullish gold)",
|
||||
"Inflation (CPI, PCE)",
|
||||
"Fed Policy (rate decisions, QE/QT)",
|
||||
"Geopolitical Tensions (safe haven)",
|
||||
"Central Bank Buying",
|
||||
"Bond Yields (10-year Treasury)",
|
||||
"Risk Sentiment (VIX, SPX correlation)",
|
||||
"Physical Demand (jewelry, industrial)",
|
||||
"Gold ETF Flows (GLD, IAU)",
|
||||
"Mining Production",
|
||||
"Seasonal Patterns (Indian wedding season)"
|
||||
],
|
||||
"timeframes": ["1D", "1W", "1M"],
|
||||
"indicators": ["DXY", "10Y Yield", "VIX", "Correlation analysis"],
|
||||
"best_for": ["Position trading", "Long-term investing"],
|
||||
"entry_criteria": [
|
||||
"Analyze macroeconomic backdrop",
|
||||
"USD weakness = gold strength",
|
||||
"Rising inflation + dovish Fed = bullish",
|
||||
"Geopolitical crisis = safe haven bid",
|
||||
"Technical confirmation on daily/weekly"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Based on technical structure",
|
||||
"take_profit": "Major psychological levels ($2000, $2100, etc.)",
|
||||
"rr_ratio": "Variable, often 1:3+"
|
||||
}
|
||||
},
|
||||
"multi_timeframe": {
|
||||
"name": "Multi-Timeframe Analysis",
|
||||
"school": TradingSchool.TECHNICAL_ANALYSIS,
|
||||
"description": "Top-down analysis using multiple timeframes for confluence",
|
||||
"key_concepts": [
|
||||
"Top-Down Approach (Monthly → Weekly → Daily → 4H → 1H)",
|
||||
"Timeframe Confluence",
|
||||
"Higher TF Trend",
|
||||
"Lower TF Entry",
|
||||
"Trend Alignment",
|
||||
"S/R Level Confluence",
|
||||
"3 Timeframe Rule",
|
||||
"Risk-On/Risk-Off Daily",
|
||||
"Bias from HTF, Entry from LTF"
|
||||
],
|
||||
"timeframes": ["1M", "1W", "1D", "4H", "1H", "15M"],
|
||||
"indicators": ["EMA 21/55/200", "RSI", "MACD"],
|
||||
"best_for": ["All trading styles"],
|
||||
"entry_criteria": [
|
||||
"Identify HTF trend (Daily/Weekly)",
|
||||
"Find HTF S/R levels",
|
||||
"Wait for retracement on MTF",
|
||||
"Enter on LTF confirmation",
|
||||
"All timeframes aligned"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Based on LTF structure",
|
||||
"take_profit": "HTF targets",
|
||||
"rr_ratio": "Minimum 1:3"
|
||||
}
|
||||
},
|
||||
"london_ny_session": {
|
||||
"name": "London/NY Session Trading",
|
||||
"school": TradingSchool.ICT,
|
||||
"description": "Trading based on major forex session characteristics and time-based patterns",
|
||||
"key_concepts": [
|
||||
"Asian Session (Low Volatility, Range)",
|
||||
"London Open (3 AM EST - High Volatility)",
|
||||
"London Killzone (2-5 AM EST)",
|
||||
"NY Open (8 AM EST - Highest Volatility)",
|
||||
"NY Killzone (8-11 AM EST)",
|
||||
"London/NY Overlap (8 AM-12 PM EST)",
|
||||
"Judas Swing (False move before real direction)",
|
||||
"London Close (12 PM EST)",
|
||||
"Asian Range Breakout",
|
||||
"Time-Based Entries"
|
||||
],
|
||||
"timeframes": ["5m", "15m", "1h"],
|
||||
"indicators": ["Minimal - ATR for volatility"],
|
||||
"best_for": ["Day trading gold/forex"],
|
||||
"entry_criteria": [
|
||||
"Identify Asian range",
|
||||
"Watch for London open breakout",
|
||||
"Fade false move (Judas Swing)",
|
||||
"Enter on true direction confirmation",
|
||||
"Most activity in London/NY killzones"
|
||||
],
|
||||
"risk_management": {
|
||||
"stop_loss": "Opposite side of range or FVG",
|
||||
"take_profit": "Intraday targets, session highs/lows",
|
||||
"rr_ratio": "Minimum 1:2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_combined_strategies() -> Dict[str, Dict[str, Any]]:
|
||||
"""Get hybrid strategies combining multiple schools"""
|
||||
return {
|
||||
"smc_fibonacci": {
|
||||
"name": "SMC + Fibonacci Confluence",
|
||||
"schools": [TradingSchool.ICT, TradingSchool.FIBONACCI],
|
||||
"description": "Combine Smart Money Concepts with Fibonacci for high-probability entries",
|
||||
"setup": [
|
||||
"1. Identify market structure (BOS/ChoCh) using SMC",
|
||||
"2. Mark FVG and Order Blocks",
|
||||
"3. Draw Fibonacci from last swing low to swing high",
|
||||
"4. Look for confluence: FVG/OB + 0.618/0.79 Fib level",
|
||||
"5. Enter at confluence zone during killzone",
|
||||
"6. Target: Opposite liquidity + Fib extension"
|
||||
],
|
||||
"indicators": [],
|
||||
"timeframes": ["15m", "1h", "4h"],
|
||||
"win_rate": "65-75%",
|
||||
"rr_ratio": "1:3"
|
||||
},
|
||||
"wyckoff_vsa": {
|
||||
"name": "Wyckoff + Volume Spread Analysis",
|
||||
"schools": [TradingSchool.WYCKOFF, TradingSchool.ORDER_FLOW],
|
||||
"description": "Combine Wyckoff accumulation/distribution with volume analysis",
|
||||
"setup": [
|
||||
"1. Identify Wyckoff phase (Accumulation/Distribution)",
|
||||
"2. Look for spring or upthrust",
|
||||
"3. Confirm with volume: High volume on spring = bullish",
|
||||
"4. Check for effort vs result divergence",
|
||||
"5. Enter on LPS (Last Point of Support) or LPSY",
|
||||
"6. Target: Measured move from trading range"
|
||||
],
|
||||
"indicators": ["Volume", "Volume Profile", "OBV"],
|
||||
"timeframes": ["4h", "1D"],
|
||||
"win_rate": "60-70%",
|
||||
"rr_ratio": "1:3"
|
||||
},
|
||||
"elliott_fibonacci": {
|
||||
"name": "Elliott Wave + Fibonacci",
|
||||
"schools": [TradingSchool.ELLIOTT_WAVE, TradingSchool.FIBONACCI],
|
||||
"description": "Natural combination - Elliott Wave theory is based on Fibonacci",
|
||||
"setup": [
|
||||
"1. Count wave structure (Impulse 1-2-3-4-5)",
|
||||
"2. Wait for Wave 2 or 4 correction",
|
||||
"3. Fib retracement: Wave 2 = 0.618, Wave 4 = 0.382",
|
||||
"4. Enter at Fib level with confirmation",
|
||||
"5. Target: Wave 3 = 1.618x Wave 1, Wave 5 = Wave 1",
|
||||
"6. Use Fib extensions for profit targets"
|
||||
],
|
||||
"indicators": ["Fibonacci", "EMA 21/55", "RSI"],
|
||||
"timeframes": ["1h", "4h", "1D"],
|
||||
"win_rate": "60-70%",
|
||||
"rr_ratio": "1:3"
|
||||
},
|
||||
"supply_demand_session": {
|
||||
"name": "Supply/Demand + Session Trading",
|
||||
"schools": [TradingSchool.SUPPLY_DEMAND, TradingSchool.ICT],
|
||||
"description": "Trade fresh S/D zones during high-liquidity sessions",
|
||||
"setup": [
|
||||
"1. Mark fresh supply/demand zones on 4H/1D",
|
||||
"2. Wait for price to approach zone during killzone",
|
||||
"3. Enter on confirmation in London/NY session",
|
||||
"4. Higher probability during high-volume periods",
|
||||
"5. Target: Opposite zone or session high/low"
|
||||
],
|
||||
"indicators": ["Volume", "ATR"],
|
||||
"timeframes": ["15m", "1h", "4h"],
|
||||
"win_rate": "65-75%",
|
||||
"rr_ratio": "1:3"
|
||||
},
|
||||
"multi_method_confluence": {
|
||||
"name": "Multi-Method Confluence",
|
||||
"schools": [TradingSchool.ICT, TradingSchool.FIBONACCI, TradingSchool.SUPPLY_DEMAND, TradingSchool.PRICE_ACTION],
|
||||
"description": "Ultimate confluence: Multiple methodologies confirming same zone",
|
||||
"setup": [
|
||||
"1. Identify trend and structure (Price Action)",
|
||||
"2. Mark Supply/Demand zones",
|
||||
"3. Draw Fibonacci retracements",
|
||||
"4. Identify FVG and Order Blocks (SMC)",
|
||||
"5. Find confluence: All methods pointing to same zone",
|
||||
"6. Enter only at maximum confluence during killzone",
|
||||
"7. Target: Multiple method targets"
|
||||
],
|
||||
"indicators": [],
|
||||
"timeframes": ["15m", "1h", "4h"],
|
||||
"win_rate": "70-80%",
|
||||
"rr_ratio": "1:3+",
|
||||
"difficulty": "Advanced"
|
||||
},
|
||||
"fundamental_technical": {
|
||||
"name": "Fundamental + Technical Combo",
|
||||
"schools": [TradingSchool.FUNDAMENTAL, TradingSchool.TECHNICAL_ANALYSIS],
|
||||
"description": "Use fundamentals for bias, technicals for entry/exit",
|
||||
"setup": [
|
||||
"1. Analyze gold fundamentals (USD, rates, geopolitics)",
|
||||
"2. Determine fundamental bias (bullish/bearish)",
|
||||
"3. Wait for technical setup aligned with bias",
|
||||
"4. Use SMC, S/D, or Fibonacci for precise entry",
|
||||
"5. Enter with fundamental and technical confluence",
|
||||
"6. Hold longer-term positions"
|
||||
],
|
||||
"indicators": ["DXY", "10Y Yield", "EMA 50/200", "RSI"],
|
||||
"timeframes": ["1D", "1W"],
|
||||
"win_rate": "65-75%",
|
||||
"rr_ratio": "1:4+",
|
||||
"holding_period": "Days to weeks"
|
||||
}
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_indicator_presets_for_school(school: TradingSchool) -> Dict[str, Any]:
|
||||
"""Get recommended indicators for each trading school"""
|
||||
presets = {
|
||||
TradingSchool.ICT: {
|
||||
"indicators": [], # Pure price action
|
||||
"tools": ["Market Structure", "FVG Finder", "Order Block Detector"],
|
||||
"note": "ICT/SMC uses minimal to no indicators"
|
||||
},
|
||||
TradingSchool.WYCKOFF: {
|
||||
"indicators": ["Volume", "OBV", "Volume Profile"],
|
||||
"tools": ["Volume Spread Analysis"],
|
||||
"note": "Volume is critical for Wyckoff"
|
||||
},
|
||||
TradingSchool.ELLIOTT_WAVE: {
|
||||
"indicators": ["Fibonacci", "EMA 21", "EMA 55", "RSI"],
|
||||
"tools": ["Wave Counter", "Fibonacci Extensions"],
|
||||
"note": "Fibonacci is integral to Elliott Wave"
|
||||
},
|
||||
TradingSchool.MARKET_PROFILE: {
|
||||
"indicators": ["Volume Profile", "VWAP", "Volume"],
|
||||
"tools": ["TPO Chart", "Value Area Calculation"],
|
||||
"note": "Time and volume distribution is key"
|
||||
},
|
||||
TradingSchool.ORDER_FLOW: {
|
||||
"indicators": ["Delta", "Cumulative Delta", "Volume"],
|
||||
"tools": ["Footprint Chart", "Bid/Ask Ladder", "Order Book"],
|
||||
"note": "Requires specialized order flow tools"
|
||||
},
|
||||
TradingSchool.PRICE_ACTION: {
|
||||
"indicators": [], # Minimal
|
||||
"tools": ["Candlestick Patterns", "S/R Levels", "Trend Lines"],
|
||||
"note": "Pure price action, no indicators"
|
||||
},
|
||||
TradingSchool.SUPPLY_DEMAND: {
|
||||
"indicators": ["Volume (optional)"],
|
||||
"tools": ["Zone Drawer", "Base Identifier"],
|
||||
"note": "Zones are key, indicators optional"
|
||||
},
|
||||
TradingSchool.FIBONACCI: {
|
||||
"indicators": ["Fibonacci Retracement", "Fibonacci Extension", "RSI", "MACD"],
|
||||
"tools": ["Fib Tools", "Harmonic Pattern Scanner"],
|
||||
"note": "Fibonacci levels are primary tool"
|
||||
},
|
||||
TradingSchool.FUNDAMENTAL: {
|
||||
"indicators": ["DXY", "10Y Yield", "VIX", "Correlation Heatmap"],
|
||||
"tools": ["Economic Calendar", "Central Bank Tracker"],
|
||||
"note": "Macro analysis is primary, technicals for timing"
|
||||
},
|
||||
TradingSchool.TECHNICAL_ANALYSIS: {
|
||||
"indicators": ["EMA 21/55/200", "RSI 14", "MACD", "BB 20", "ATR 14"],
|
||||
"tools": ["Multi-Timeframe Analysis"],
|
||||
"note": "Classic indicator suite"
|
||||
}
|
||||
}
|
||||
return presets.get(school, {})
|
||||
|
||||
@staticmethod
|
||||
def get_risk_models() -> Dict[str, Dict[str, Any]]:
|
||||
"""Advanced risk management models"""
|
||||
return {
|
||||
"kelly_criterion": {
|
||||
"name": "Kelly Criterion Position Sizing",
|
||||
"formula": "f* = (bp - q) / b",
|
||||
"variables": {
|
||||
"f*": "Fraction of capital to risk",
|
||||
"b": "Odds received (reward:risk ratio - 1)",
|
||||
"p": "Probability of winning",
|
||||
"q": "Probability of losing (1 - p)"
|
||||
},
|
||||
"example": {
|
||||
"win_rate": 0.60,
|
||||
"rr_ratio": 2.0,
|
||||
"calculation": "f* = (2 * 0.60 - 0.40) / 2 = 0.40 or 40%",
|
||||
"recommended": "Use half-Kelly (20%) for safety"
|
||||
},
|
||||
"best_for": "High win rate, consistent strategies"
|
||||
},
|
||||
"fixed_fractional": {
|
||||
"name": "Fixed Fractional Risk",
|
||||
"description": "Risk fixed percentage of capital per trade",
|
||||
"recommended": {
|
||||
"conservative": "1-2% per trade",
|
||||
"moderate": "2-3% per trade",
|
||||
"aggressive": "3-5% per trade"
|
||||
},
|
||||
"best_for": "All traders, most reliable method"
|
||||
},
|
||||
"volatility_based": {
|
||||
"name": "ATR-Based Position Sizing",
|
||||
"description": "Adjust position size based on market volatility",
|
||||
"formula": "Position Size = (Account Risk $) / (ATR * Multiplier)",
|
||||
"example": {
|
||||
"account": 100000,
|
||||
"risk_pct": 0.02,
|
||||
"atr": 15.0,
|
||||
"multiplier": 1.5,
|
||||
"position_size": "(100000 * 0.02) / (15 * 1.5) = 88.89 units"
|
||||
},
|
||||
"best_for": "Volatility-sensitive strategies"
|
||||
},
|
||||
"time_based": {
|
||||
"name": "Time-Based Risk Adjustment",
|
||||
"description": "Reduce risk during low liquidity or high event risk",
|
||||
"rules": {
|
||||
"normal_hours": "Full position size",
|
||||
"low_liquidity": "50% position size",
|
||||
"news_events": "25% position size or avoid",
|
||||
"weekend_gaps": "Reduced or no overnight positions"
|
||||
},
|
||||
"best_for": "Day traders, news-sensitive markets"
|
||||
},
|
||||
"correlation_based": {
|
||||
"name": "Correlation-Adjusted Risk",
|
||||
"description": "Account for correlated positions",
|
||||
"rules": {
|
||||
"uncorrelated": "Full risk per position",
|
||||
"low_correlation": "75% risk adjustment",
|
||||
"high_correlation": "50% risk adjustment",
|
||||
"perfect_correlation": "Count as one position"
|
||||
},
|
||||
"example": "Gold + Silver high correlation → reduce combined risk",
|
||||
"best_for": "Multi-asset traders"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Global instance
|
||||
trading_schools = TradingStrategy()
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Iterable, Awaitable
|
||||
|
||||
from app.config import settings
|
||||
from app.streaming.binance_hub import hub as binance_hub
|
||||
from app.streaming.data_provider import data_provider
|
||||
|
||||
|
||||
async def bootstrap_streams() -> None:
|
||||
"""Ensure configured streams are hot even before clients connect."""
|
||||
if not settings.STREAM_AUTO_BOOTSTRAP:
|
||||
return
|
||||
|
||||
symbols: Iterable[str] = settings.STREAM_WARM_SYMBOLS or []
|
||||
timeframe = settings.STREAM_WARM_TIMEFRAME or "1m"
|
||||
coros: list[Awaitable[None]] = []
|
||||
|
||||
for raw in symbols:
|
||||
sym = (raw or "").strip()
|
||||
if not sym:
|
||||
continue
|
||||
if sym.upper().startswith("XAU"):
|
||||
coros.append(data_provider.ensure_stream(sym, timeframe))
|
||||
else:
|
||||
coros.append(binance_hub.ensure_stream(sym, timeframe))
|
||||
|
||||
if coros:
|
||||
await asyncio.gather(*coros, return_exceptions=True)
|
||||
@@ -0,0 +1,138 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""CSV/Parquet replay feed.
|
||||
|
||||
Loads OHLCV data from disk and replays it into live_store at a configurable
|
||||
speed. Useful for offline demos or backtesting visualizations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Set, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.streaming.live_store import live_store
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CSVKey:
|
||||
symbol: str
|
||||
timeframe: str
|
||||
|
||||
|
||||
class CSVFeedProvider:
|
||||
def __init__(self, data_dir: str | Path | None = None) -> None:
|
||||
self._subs: Dict[CSVKey, Set[asyncio.Queue]] = {}
|
||||
self._tasks: Dict[CSVKey, asyncio.Task] = {}
|
||||
self._pinned: Set[CSVKey] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._data_dir = Path(data_dir or Path.cwd() / "data" / "parquet" / "live")
|
||||
self._speed = 1.0 # 1x realtime replay
|
||||
|
||||
def get_status(self) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for key, subs in self._subs.items():
|
||||
out.append(
|
||||
{
|
||||
"symbol": key.symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"subscribers": len(subs),
|
||||
"source": "csv_replay",
|
||||
"data_dir": str(self._data_dir),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]:
|
||||
key = CSVKey(symbol.upper().replace("/", ""), timeframe)
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
async with self._lock:
|
||||
subs = self._subs.setdefault(key, set())
|
||||
subs.add(queue)
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_replay(key))
|
||||
|
||||
async def _unsubscribe() -> None:
|
||||
async with self._lock:
|
||||
s = self._subs.get(key)
|
||||
if s and queue in s:
|
||||
s.remove(queue)
|
||||
try:
|
||||
queue.put_nowait(None)
|
||||
except Exception:
|
||||
pass
|
||||
if s and len(s) == 0 and key not in self._pinned:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._subs.pop(key, None)
|
||||
|
||||
return queue, _unsubscribe
|
||||
|
||||
async def ensure_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
key = CSVKey(symbol.upper().replace("/", ""), timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.add(key)
|
||||
self._subs.setdefault(key, set())
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_replay(key))
|
||||
|
||||
def set_speed(self, speed: float) -> None:
|
||||
self._speed = max(0.1, speed)
|
||||
|
||||
async def _run_replay(self, key: CSVKey) -> None:
|
||||
file_path = self._resolve_file(key.symbol, key.timeframe)
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"Replay file not found: {file_path}")
|
||||
|
||||
df = self._load_file(file_path)
|
||||
for row in df.itertuples():
|
||||
evt = {
|
||||
"symbol": key.symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"open_time": datetime.utcfromtimestamp(int(row.time)).isoformat(),
|
||||
"close_time": datetime.utcfromtimestamp(int(row.time)).isoformat(),
|
||||
"open": float(row.open),
|
||||
"high": float(row.high),
|
||||
"low": float(row.low),
|
||||
"close": float(row.close),
|
||||
"volume": float(getattr(row, "volume", 0.0)),
|
||||
"is_closed": True,
|
||||
"source": "csv_replay",
|
||||
}
|
||||
live_store.ingest_bar(
|
||||
symbol=key.symbol,
|
||||
timeframe=key.timeframe,
|
||||
bar={
|
||||
"time": int(row.time),
|
||||
"open": evt["open"],
|
||||
"high": evt["high"],
|
||||
"low": evt["low"],
|
||||
"close": evt["close"],
|
||||
"volume": evt["volume"],
|
||||
},
|
||||
)
|
||||
subs = self._subs.get(key) or set()
|
||||
for queue in list(subs):
|
||||
try:
|
||||
if queue.full():
|
||||
queue.get_nowait()
|
||||
queue.put_nowait(evt)
|
||||
except Exception:
|
||||
subs.discard(queue)
|
||||
await asyncio.sleep((60 / self._speed)) # default 1m bars -> 1 minute
|
||||
|
||||
def _resolve_file(self, symbol: str, timeframe: str) -> Path:
|
||||
filename = f"{symbol}_{timeframe}.parquet"
|
||||
return self._data_dir / filename
|
||||
|
||||
def _load_file(self, path: Path) -> pd.DataFrame:
|
||||
if path.suffix == ".csv":
|
||||
return pd.read_csv(path)
|
||||
return pd.read_parquet(path)
|
||||
|
||||
|
||||
csv_feed = CSVFeedProvider()
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config import settings
|
||||
from app.streaming.local_feed import local_feed
|
||||
from app.streaming.metatrader_feed import metatrader_feed
|
||||
from app.streaming.csv_feed import csv_feed
|
||||
from app.streaming.historical_replay import historical_replay
|
||||
|
||||
|
||||
# Registry for future providers. For now only the local simulator is available.
|
||||
_PROVIDER_REGISTRY = {
|
||||
"historical_replay": historical_replay,
|
||||
"local_simulator": local_feed,
|
||||
"metatrader": metatrader_feed,
|
||||
"csv_replay": csv_feed,
|
||||
}
|
||||
|
||||
provider_key = settings.DATA_PROVIDER.lower().strip()
|
||||
data_provider = _PROVIDER_REGISTRY.get(provider_key)
|
||||
|
||||
if data_provider is None:
|
||||
raise ValueError(
|
||||
f"Unsupported DATA_PROVIDER '{settings.DATA_PROVIDER}'. Available: {', '.join(_PROVIDER_REGISTRY)}"
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Tuple, Set
|
||||
|
||||
import pyarrow.parquet as pq
|
||||
|
||||
from app.config import settings
|
||||
from app.streaming.live_store import live_store, _TIMEFRAME_SECONDS
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReplayKey:
|
||||
symbol: str
|
||||
timeframe: str
|
||||
|
||||
|
||||
class HistoricalReplayProvider:
|
||||
"""Streams OHLCV data by replaying parquet partitions as live candles."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
root: str | Path | None = None,
|
||||
speed: float | None = None,
|
||||
days: int | None = None,
|
||||
loop: bool | None = None,
|
||||
) -> None:
|
||||
default_root = Path(__file__).resolve().parents[3] / "data" / "parquet" / "live"
|
||||
resolved_root = Path(root) if root else Path(settings.HISTORICAL_REPLAY_ROOT or default_root)
|
||||
self._root = resolved_root
|
||||
self._speed = max(speed or settings.HISTORICAL_REPLAY_SPEED, 0.1)
|
||||
self._days_back = max(days or settings.HISTORICAL_REPLAY_DAYS, 1)
|
||||
self._loop = settings.HISTORICAL_REPLAY_LOOP if loop is None else loop
|
||||
|
||||
self._tasks: Dict[ReplayKey, asyncio.Task] = {}
|
||||
self._pinned: Set[ReplayKey] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._active_counts: Dict[ReplayKey, int] = {}
|
||||
self._bar_cache: Dict[ReplayKey, Tuple[float, List[Dict[str, Any]]]] = {}
|
||||
self._positions: Dict[ReplayKey, int] = {}
|
||||
self._last_times: Dict[ReplayKey, int] = {}
|
||||
|
||||
def _sleep_seconds(self, timeframe: str) -> float:
|
||||
base = _TIMEFRAME_SECONDS.get(timeframe.lower(), 60)
|
||||
return max(base / self._speed, 0.5)
|
||||
|
||||
def _ensure_history(self, key: ReplayKey) -> None:
|
||||
try:
|
||||
if not live_store.get_history(key.symbol, key.timeframe):
|
||||
live_store.load_historical_data(key.symbol, key.timeframe, days_back=self._days_back)
|
||||
except Exception:
|
||||
# Best-effort warmup; ignore errors so streaming can proceed
|
||||
pass
|
||||
|
||||
def _resolve_partitions(self, key: ReplayKey) -> List[Tuple[datetime, Path]]:
|
||||
base_path = self._root / key.symbol / key.timeframe
|
||||
partitions: List[Tuple[datetime, Path]] = []
|
||||
if not base_path.exists():
|
||||
return partitions
|
||||
|
||||
for part in base_path.glob("date=*"):
|
||||
if not part.is_dir():
|
||||
continue
|
||||
_, _, date_part = part.name.partition("=")
|
||||
try:
|
||||
dt = datetime.strptime(date_part, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
continue
|
||||
partitions.append((dt, part))
|
||||
partitions.sort(key=lambda x: x[0])
|
||||
return partitions
|
||||
|
||||
def _load_bars(self, key: ReplayKey) -> List[Dict[str, Any]]:
|
||||
cached = self._bar_cache.get(key)
|
||||
now = time.time()
|
||||
if cached and now - cached[0] < 300:
|
||||
return cached[1]
|
||||
|
||||
partitions = self._resolve_partitions(key)
|
||||
if not partitions:
|
||||
self._bar_cache[key] = (now, [])
|
||||
return []
|
||||
|
||||
cutoff_date = partitions[-1][0] - timedelta(days=self._days_back - 1)
|
||||
eligible = [p for p in partitions if p[0] >= cutoff_date]
|
||||
if not eligible:
|
||||
eligible = partitions[-self._days_back :] if len(partitions) >= self._days_back else partitions
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for _, part in eligible:
|
||||
files = sorted(part.glob("*.parquet"))
|
||||
for file in files:
|
||||
try:
|
||||
table = pq.read_table(file, columns=["time", "open", "high", "low", "close", "volume"])
|
||||
except Exception:
|
||||
continue
|
||||
for row in table.to_pylist():
|
||||
try:
|
||||
rows.append(
|
||||
{
|
||||
"time": int(row["time"]),
|
||||
"open": float(row["open"]),
|
||||
"high": float(row["high"]),
|
||||
"low": float(row["low"]),
|
||||
"close": float(row["close"]),
|
||||
"volume": float(row.get("volume") or 0.0),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
rows.sort(key=lambda r: r["time"])
|
||||
self._bar_cache[key] = (now, rows)
|
||||
return rows
|
||||
|
||||
async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]:
|
||||
if timeframe != "1m":
|
||||
raise ValueError("HistoricalReplayProvider currently supports timeframe '1m' only")
|
||||
key = ReplayKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=200)
|
||||
live_store.subscribe(key.symbol, key.timeframe, queue)
|
||||
async with self._lock:
|
||||
self._active_counts[key] = self._active_counts.get(key, 0) + 1
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_replay(key))
|
||||
|
||||
async def _unsubscribe() -> None:
|
||||
live_store.unsubscribe(key.symbol, key.timeframe, queue)
|
||||
async with self._lock:
|
||||
self._active_counts[key] = max(0, self._active_counts.get(key, 0) - 1)
|
||||
if self._active_counts.get(key, 0) == 0 and key not in self._pinned:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._active_counts.pop(key, None)
|
||||
return queue, _unsubscribe
|
||||
|
||||
async def ensure_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
if timeframe != "1m":
|
||||
raise ValueError("HistoricalReplayProvider currently supports timeframe '1m' only")
|
||||
key = ReplayKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.add(key)
|
||||
self._active_counts.setdefault(key, 0)
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_replay(key))
|
||||
|
||||
async def release_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
key = ReplayKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.discard(key)
|
||||
if self._active_counts.get(key, 0) == 0:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._active_counts.pop(key, None)
|
||||
|
||||
async def _run_replay(self, key: ReplayKey) -> None:
|
||||
self._ensure_history(key)
|
||||
sleep_secs = self._sleep_seconds(key.timeframe)
|
||||
tf_seconds = _TIMEFRAME_SECONDS.get(key.timeframe.lower(), 60)
|
||||
self._positions.setdefault(key, 0)
|
||||
self._last_times.setdefault(key, 0)
|
||||
|
||||
while True:
|
||||
try:
|
||||
bars = self._load_bars(key)
|
||||
if not bars:
|
||||
await asyncio.sleep(5.0)
|
||||
continue
|
||||
|
||||
idx = self._positions.get(key, 0)
|
||||
if idx >= len(bars):
|
||||
if not self._loop:
|
||||
await asyncio.sleep(sleep_secs)
|
||||
continue
|
||||
idx = 0
|
||||
bar = bars[idx]
|
||||
self._positions[key] = idx + 1
|
||||
|
||||
now = int(time.time())
|
||||
aligned = (now // tf_seconds) * tf_seconds
|
||||
last_time = self._last_times.get(key) or 0
|
||||
if aligned <= last_time:
|
||||
aligned = last_time + tf_seconds
|
||||
|
||||
payload = {
|
||||
"time": aligned,
|
||||
"open": bar["open"],
|
||||
"high": bar["high"],
|
||||
"low": bar["low"],
|
||||
"close": bar["close"],
|
||||
"volume": bar.get("volume", 0.0),
|
||||
}
|
||||
live_store.ingest_bar(symbol=key.symbol, timeframe=key.timeframe, bar=payload)
|
||||
self._last_times[key] = aligned
|
||||
|
||||
await asyncio.sleep(sleep_secs)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(min(5.0, sleep_secs))
|
||||
|
||||
|
||||
historical_replay = HistoricalReplayProvider()
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Set, Tuple
|
||||
|
||||
from app.streaming.live_store import live_store
|
||||
from app.services.price_simulator import gold_simulator
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FeedKey:
|
||||
symbol: str
|
||||
timeframe: str
|
||||
|
||||
|
||||
class LocalFeedProvider:
|
||||
"""In-memory price feed backed by the gold price simulator."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tasks: Dict[FeedKey, asyncio.Task] = {}
|
||||
self._pinned: Set[FeedKey] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._active_counts: Dict[FeedKey, int] = {}
|
||||
|
||||
def get_status(self) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for key, task in self._tasks.items():
|
||||
hist = live_store.get_history(key.symbol, key.timeframe)
|
||||
last_ts = hist[-1]["time"] if hist else None
|
||||
last_iso = None
|
||||
if isinstance(last_ts, (int, float)):
|
||||
try:
|
||||
last_iso = datetime.utcfromtimestamp(int(last_ts)).isoformat() + "Z"
|
||||
except Exception:
|
||||
last_iso = None
|
||||
out.append(
|
||||
{
|
||||
"symbol": key.symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"subscribers": self._active_counts.get(key, 0),
|
||||
"last_event_time": last_iso,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]:
|
||||
if timeframe != "1m":
|
||||
raise ValueError("LocalFeedProvider currently supports timeframe '1m' only")
|
||||
key = FeedKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
live_store.subscribe(key.symbol, key.timeframe, queue)
|
||||
async with self._lock:
|
||||
self._active_counts[key] = self._active_counts.get(key, 0) + 1
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_simulator_poller(key))
|
||||
|
||||
async def _unsubscribe() -> None:
|
||||
live_store.unsubscribe(key.symbol, key.timeframe, queue)
|
||||
async with self._lock:
|
||||
self._active_counts[key] = max(0, self._active_counts.get(key, 0) - 1)
|
||||
if self._active_counts.get(key, 0) == 0 and key not in self._pinned:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._active_counts.pop(key, None)
|
||||
|
||||
return queue, _unsubscribe
|
||||
|
||||
async def ensure_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
if timeframe != "1m":
|
||||
raise ValueError("LocalFeedProvider currently supports timeframe '1m' only")
|
||||
key = FeedKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.add(key)
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_simulator_poller(key))
|
||||
|
||||
async def release_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
key = FeedKey(symbol=symbol.upper().replace("/", ""), timeframe=timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.discard(key)
|
||||
if self._active_counts.get(key, 0) == 0:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
|
||||
async def _run_simulator_poller(self, key: FeedKey) -> None:
|
||||
symbol = key.symbol
|
||||
simulator = gold_simulator
|
||||
if simulator.current_price < 2400 or simulator.current_price > 2900:
|
||||
simulator.current_price = 2650.0
|
||||
simulator.base_price = 2650.0
|
||||
|
||||
last_ts: int | None = None
|
||||
poll_interval = 3
|
||||
|
||||
while True:
|
||||
try:
|
||||
candle = simulator.get_live_candle(interval="1min")
|
||||
tsec = candle.time
|
||||
|
||||
if last_ts is None or tsec > last_ts:
|
||||
dt = datetime.utcfromtimestamp(tsec)
|
||||
evt = {
|
||||
"symbol": symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"open_time": dt.isoformat(),
|
||||
"close_time": dt.isoformat(),
|
||||
"open": candle.open,
|
||||
"high": candle.high,
|
||||
"low": candle.low,
|
||||
"close": candle.close,
|
||||
"volume": candle.volume or 0.0,
|
||||
"is_closed": True,
|
||||
"source": "local_simulator",
|
||||
}
|
||||
try:
|
||||
live_store.ingest_bar(
|
||||
symbol=symbol,
|
||||
timeframe="1m",
|
||||
bar={
|
||||
"time": tsec,
|
||||
"open": evt["open"],
|
||||
"high": evt["high"],
|
||||
"low": evt["low"],
|
||||
"close": evt["close"],
|
||||
"volume": evt["volume"],
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
last_ts = tsec
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
|
||||
local_feed = LocalFeedProvider()
|
||||
@@ -0,0 +1,168 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""MetaTrader-powered price feed (scaffold).
|
||||
|
||||
The actual MT5 integration lives here so we can keep the rest of the app agnostic.
|
||||
Implement the ``_connect`` and ``_fetch`` routines once MetaTrader 5 is available
|
||||
on the host machine.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Tuple
|
||||
|
||||
try:
|
||||
import MetaTrader5 as mt5 # type: ignore
|
||||
except Exception: # pragma: no cover - MetaTrader library not always installed
|
||||
mt5 = None
|
||||
|
||||
from app.streaming.live_store import live_store
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MTKey:
|
||||
symbol: str
|
||||
timeframe: str
|
||||
|
||||
|
||||
class MetaTraderFeed:
|
||||
def __init__(self) -> None:
|
||||
self._tasks: Dict[MTKey, asyncio.Task] = {}
|
||||
self._pinned: set[MTKey] = set()
|
||||
self._lock = asyncio.Lock()
|
||||
self._active_counts: Dict[MTKey, int] = {}
|
||||
|
||||
def get_status(self) -> list[dict]:
|
||||
out: list[dict] = []
|
||||
for key, task in self._tasks.items():
|
||||
hist = live_store.get_history(key.symbol, key.timeframe)
|
||||
last_ts = hist[-1]["time"] if hist else None
|
||||
last_iso = None
|
||||
if isinstance(last_ts, (int, float)):
|
||||
last_iso = datetime.utcfromtimestamp(int(last_ts)).isoformat() + "Z"
|
||||
out.append(
|
||||
{
|
||||
"symbol": key.symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"subscribers": self._active_counts.get(key, 0),
|
||||
"last_event_time": last_iso,
|
||||
"connected": mt5 is not None and mt5.terminal_info() is not None,
|
||||
"poller_running": not task.done(),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
async def subscribe(self, symbol: str, timeframe: str = "1m") -> Tuple[asyncio.Queue, Any]:
|
||||
key = MTKey(symbol.upper().replace("/", ""), timeframe)
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=100)
|
||||
live_store.subscribe(key.symbol, key.timeframe, queue)
|
||||
|
||||
async with self._lock:
|
||||
self._active_counts[key] = self._active_counts.get(key, 0) + 1
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_mt_poller(key))
|
||||
|
||||
async def _unsubscribe() -> None:
|
||||
live_store.unsubscribe(key.symbol, key.timeframe, queue)
|
||||
async with self._lock:
|
||||
self._active_counts[key] = max(0, self._active_counts.get(key, 0) - 1)
|
||||
if self._active_counts.get(key, 0) == 0 and key not in self._pinned:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._active_counts.pop(key, None)
|
||||
|
||||
return queue, _unsubscribe
|
||||
|
||||
async def ensure_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
key = MTKey(symbol.upper().replace("/", ""), timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.add(key)
|
||||
self._active_counts.setdefault(key, 0)
|
||||
if key not in self._tasks:
|
||||
self._tasks[key] = asyncio.create_task(self._run_mt_poller(key))
|
||||
|
||||
async def release_stream(self, symbol: str, timeframe: str = "1m") -> None:
|
||||
"""Allow external callers to drop pinning once no longer needed."""
|
||||
key = MTKey(symbol.upper().replace("/", ""), timeframe)
|
||||
async with self._lock:
|
||||
self._pinned.discard(key)
|
||||
if self._active_counts.get(key, 0) == 0:
|
||||
task = self._tasks.pop(key, None)
|
||||
if task:
|
||||
task.cancel()
|
||||
self._active_counts.pop(key, None)
|
||||
|
||||
async def _run_mt_poller(self, key: MTKey) -> None:
|
||||
if mt5 is None:
|
||||
raise RuntimeError("MetaTrader5 package not installed. Install to enable MT feed.")
|
||||
|
||||
if not mt5.initialize():
|
||||
raise RuntimeError(f"Unable to initialize MetaTrader5: {mt5.last_error()}")
|
||||
|
||||
interval = self._resolve_timeframe(key.timeframe)
|
||||
last_ts: int | None = None
|
||||
poll_interval = 5
|
||||
|
||||
while True:
|
||||
try:
|
||||
rates = mt5.copy_rates_from_pos(key.symbol, interval, 0, 1)
|
||||
if not rates:
|
||||
await asyncio.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
row = rates[0]
|
||||
tsec = int(row["time"])
|
||||
if last_ts is not None and tsec <= last_ts:
|
||||
await asyncio.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
last_ts = tsec
|
||||
evt = {
|
||||
"symbol": key.symbol,
|
||||
"timeframe": key.timeframe,
|
||||
"open_time": datetime.utcfromtimestamp(tsec).isoformat(),
|
||||
"close_time": datetime.utcfromtimestamp(tsec).isoformat(),
|
||||
"open": float(row["open"]),
|
||||
"high": float(row["high"]),
|
||||
"low": float(row["low"]),
|
||||
"close": float(row["close"]),
|
||||
"volume": float(row.get("tick_volume", 0.0)),
|
||||
"is_closed": True,
|
||||
"source": "metatrader",
|
||||
}
|
||||
live_store.ingest_bar(
|
||||
symbol=key.symbol,
|
||||
timeframe=key.timeframe,
|
||||
bar={
|
||||
"time": tsec,
|
||||
"open": evt["open"],
|
||||
"high": evt["high"],
|
||||
"low": evt["low"],
|
||||
"close": evt["close"],
|
||||
"volume": evt["volume"],
|
||||
},
|
||||
)
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception:
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
def _resolve_timeframe(self, name: str):
|
||||
if mt5 is None:
|
||||
raise RuntimeError("MetaTrader5 package not available")
|
||||
mapping = {
|
||||
"1m": mt5.TIMEFRAME_M1,
|
||||
"5m": mt5.TIMEFRAME_M5,
|
||||
"15m": mt5.TIMEFRAME_M15,
|
||||
"30m": mt5.TIMEFRAME_M30,
|
||||
"60m": mt5.TIMEFRAME_H1,
|
||||
"1h": mt5.TIMEFRAME_H1,
|
||||
}
|
||||
return mapping.get(name.lower(), mt5.TIMEFRAME_M1)
|
||||
|
||||
|
||||
metatrader_feed = MetaTraderFeed()
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check for duplicates and data integrity in the generated historical gold price data.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pandas as pd
|
||||
import pyarrow.parquet as pq
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
|
||||
def check_data_integrity():
|
||||
"""Check for duplicates and gaps in the historical data."""
|
||||
|
||||
# Find all parquet files
|
||||
data_dir = "/Users/user/Downloads/gold-trading-simulator/data/parquet/live/XAUUSD/1m"
|
||||
all_timestamps = []
|
||||
all_records = []
|
||||
|
||||
print("🔍 Scanning historical gold price data for duplicates and integrity issues...")
|
||||
print("=" * 80)
|
||||
|
||||
# Walk through all date directories
|
||||
file_count = 0
|
||||
for root, dirs, files in os.walk(data_dir):
|
||||
for file in files:
|
||||
if file.endswith('.parquet'):
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
# Read parquet file
|
||||
table = pq.read_table(file_path)
|
||||
df = table.to_pandas()
|
||||
|
||||
file_count += 1
|
||||
if file_count <= 5: # Show progress for first few files
|
||||
print(f"📁 Processing {os.path.basename(root)}: {len(df)} records")
|
||||
|
||||
# Collect timestamps
|
||||
timestamps = df['time'].tolist()
|
||||
all_timestamps.extend(timestamps)
|
||||
|
||||
# Collect full records for duplicate detection
|
||||
for _, row in df.iterrows():
|
||||
record_tuple = (row['time'], row['open'], row['high'], row['low'], row['close'], row['volume'])
|
||||
all_records.append(record_tuple)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error reading {file_path}: {e}")
|
||||
|
||||
print(f"📊 Processed {file_count} parquet files")
|
||||
print(f"📊 Total records found: {len(all_records):,}")
|
||||
print(f"📊 Total timestamps found: {len(all_timestamps):,}")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("🕐 DUPLICATE TIMESTAMP ANALYSIS")
|
||||
print("="*80)
|
||||
|
||||
# Check for duplicate timestamps
|
||||
timestamp_counts = Counter(all_timestamps)
|
||||
duplicate_timestamps = {ts: count for ts, count in timestamp_counts.items() if count > 1}
|
||||
|
||||
print(f"Unique timestamps: {len(timestamp_counts):,}")
|
||||
print(f"Total timestamps: {len(all_timestamps):,}")
|
||||
print(f"Duplicate timestamps found: {len(duplicate_timestamps):,}")
|
||||
|
||||
if duplicate_timestamps:
|
||||
print("\n⚠️ DUPLICATE TIMESTAMPS DETECTED:")
|
||||
for i, (ts, count) in enumerate(list(duplicate_timestamps.items())[:10]):
|
||||
dt = datetime.utcfromtimestamp(ts)
|
||||
print(f" • Timestamp {ts} ({dt}): appears {count} times")
|
||||
if i >= 9:
|
||||
print(f" ... and {len(duplicate_timestamps) - 10} more")
|
||||
break
|
||||
else:
|
||||
print("✅ No duplicate timestamps found!")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("📋 DUPLICATE RECORD ANALYSIS")
|
||||
print("="*80)
|
||||
|
||||
# Check for duplicate full records
|
||||
record_counts = Counter(all_records)
|
||||
duplicate_records = {record: count for record, count in record_counts.items() if count > 1}
|
||||
|
||||
print(f"Unique records: {len(record_counts):,}")
|
||||
print(f"Total records: {len(all_records):,}")
|
||||
print(f"Duplicate records found: {len(duplicate_records):,}")
|
||||
|
||||
if duplicate_records:
|
||||
print("\n⚠️ DUPLICATE RECORDS DETECTED:")
|
||||
for i, (record, count) in enumerate(list(duplicate_records.items())[:5]):
|
||||
ts, open_price, high, low, close, volume = record
|
||||
dt = datetime.utcfromtimestamp(ts)
|
||||
print(f" • {dt}: O={open_price}, H={high}, L={low}, C={close}, V={volume} (appears {count} times)")
|
||||
if i >= 4:
|
||||
print(f" ... and {len(duplicate_records) - 5} more")
|
||||
break
|
||||
else:
|
||||
print("✅ No duplicate records found!")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("⏰ TIMESTAMP CONTINUITY ANALYSIS")
|
||||
print("="*80)
|
||||
|
||||
# Check timestamp continuity and gaps
|
||||
sorted_timestamps = sorted(all_timestamps)
|
||||
|
||||
print(f"Earliest timestamp: {datetime.utcfromtimestamp(sorted_timestamps[0])}")
|
||||
print(f"Latest timestamp: {datetime.utcfromtimestamp(sorted_timestamps[-1])}")
|
||||
|
||||
# Check for gaps (should be 60 seconds apart for 1-minute data)
|
||||
gaps_found = []
|
||||
large_gaps = []
|
||||
|
||||
for i in range(1, len(sorted_timestamps)):
|
||||
time_diff = sorted_timestamps[i] - sorted_timestamps[i-1]
|
||||
|
||||
# Expected is 60 seconds (1 minute)
|
||||
if time_diff != 60:
|
||||
gap_info = {
|
||||
'prev_time': datetime.utcfromtimestamp(sorted_timestamps[i-1]),
|
||||
'curr_time': datetime.utcfromtimestamp(sorted_timestamps[i]),
|
||||
'gap_seconds': time_diff,
|
||||
'gap_minutes': time_diff // 60
|
||||
}
|
||||
gaps_found.append(gap_info)
|
||||
|
||||
# Track large gaps (more than 1 day)
|
||||
if time_diff > 86400: # 24 hours
|
||||
large_gaps.append(gap_info)
|
||||
|
||||
print(f"\nGaps analysis:")
|
||||
print(f"Total gaps found: {len(gaps_found):,}")
|
||||
print(f"Large gaps (>24h): {len(large_gaps):,}")
|
||||
|
||||
if large_gaps:
|
||||
print(f"\n📅 Large gaps (expected for weekends):")
|
||||
for i, gap in enumerate(large_gaps[:10]):
|
||||
days = gap['gap_minutes'] // (60 * 24)
|
||||
hours = (gap['gap_minutes'] % (60 * 24)) // 60
|
||||
print(f" • {gap['prev_time']} -> {gap['curr_time']} ({days}d {hours}h)")
|
||||
if i >= 9:
|
||||
print(f" ... and {len(large_gaps) - 10} more")
|
||||
break
|
||||
|
||||
# Check weekday patterns for gaps
|
||||
weekend_gaps = 0
|
||||
weekday_gaps = 0
|
||||
|
||||
for gap in gaps_found:
|
||||
# Check if gap starts on Friday or Saturday (weekend gap)
|
||||
prev_weekday = gap['prev_time'].weekday() # Monday=0, Sunday=6
|
||||
if prev_weekday >= 4: # Friday or Saturday
|
||||
weekend_gaps += 1
|
||||
else:
|
||||
weekday_gaps += 1
|
||||
|
||||
print(f"\nGap patterns:")
|
||||
print(f"Weekend gaps (Fri-Mon): {weekend_gaps:,}")
|
||||
print(f"Weekday gaps: {weekday_gaps:,}")
|
||||
|
||||
if weekday_gaps > 0:
|
||||
print(f"\n⚠️ Unexpected weekday gaps:")
|
||||
weekday_gap_count = 0
|
||||
for gap in gaps_found:
|
||||
prev_weekday = gap['prev_time'].weekday()
|
||||
if prev_weekday < 4 and weekday_gap_count < 5: # Not Friday/Saturday
|
||||
print(f" • {gap['prev_time']} -> {gap['curr_time']} ({gap['gap_minutes']} minutes)")
|
||||
weekday_gap_count += 1
|
||||
if weekday_gaps > 5:
|
||||
print(f" ... and {weekday_gaps - 5} more weekday gaps")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print("📈 DATA QUALITY SUMMARY")
|
||||
print("="*80)
|
||||
|
||||
# Summary
|
||||
issues = []
|
||||
if duplicate_timestamps:
|
||||
issues.append(f"{len(duplicate_timestamps)} duplicate timestamps")
|
||||
if duplicate_records:
|
||||
issues.append(f"{len(duplicate_records)} duplicate records")
|
||||
if weekday_gaps > 0:
|
||||
issues.append(f"{weekday_gaps} unexpected weekday gaps")
|
||||
|
||||
if issues:
|
||||
print("⚠️ Issues found:")
|
||||
for issue in issues:
|
||||
print(f" • {issue}")
|
||||
else:
|
||||
print("✅ Data quality check PASSED!")
|
||||
print(" • No duplicate timestamps")
|
||||
print(" • No duplicate records")
|
||||
print(" • Weekend gaps are expected (market closed)")
|
||||
print(f" • Total records: {len(all_records):,}")
|
||||
print(f" • Date range: {datetime.utcfromtimestamp(sorted_timestamps[0]).date()} to {datetime.utcfromtimestamp(sorted_timestamps[-1]).date()}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
check_data_integrity()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user